Jump to…
qoxwzsukwmkxmerged#1

initial commit

124 files+38841−0
Collapse all
Comparingv1 against its parent
A.gitignore+22−0
@@ −0,0 +1,22 @@
1+# secrets
2+.env
3+
4+# rust
5+/target
6+**/*.rs.bk
7+
8+# generated test fixtures — rebuilt by fixtures/gen.sh, never committed
9+/fixtures/repos/
10+/fixtures/bin/
11+
12+# node (reference prototype only)
13+reference/prototype/node_modules/
14+reference/prototype/.next/
15+
16+# assets built by tailwind
17+crates/df-web/assets/dist/
18+
19+# local repo storage when running outside docker
20+/srv/
21+
22+**/node_modules/
ACargo.lock6162 lines+6162−0
@@ −0,0 +1,6162 @@
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.5.2"
14+source = "registry+https://github.com/rust-lang/crates.io-index"
15+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
16+dependencies = [
17+ "crypto-common",
18+ "generic-array",
19+]
20+
21+[[package]]
22+name = "aes"
23+version = "0.8.4"
24+source = "registry+https://github.com/rust-lang/crates.io-index"
25+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
26+dependencies = [
27+ "cfg-if",
28+ "cipher",
29+ "cpufeatures 0.2.17",
30+]
31+
32+[[package]]
33+name = "aes-gcm"
34+version = "0.10.3"
35+source = "registry+https://github.com/rust-lang/crates.io-index"
36+checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
37+dependencies = [
38+ "aead",
39+ "aes",
40+ "cipher",
41+ "ctr",
42+ "ghash",
43+ "subtle",
44+]
45+
46+[[package]]
47+name = "ahash"
48+version = "0.8.12"
49+source = "registry+https://github.com/rust-lang/crates.io-index"
50+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
51+dependencies = [
52+ "cfg-if",
53+ "once_cell",
54+ "version_check",
55+ "zerocopy",
56+]
57+
58+[[package]]
59+name = "aho-corasick"
60+version = "1.1.4"
61+source = "registry+https://github.com/rust-lang/crates.io-index"
62+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
63+dependencies = [
64+ "memchr",
65+]
66+
67+[[package]]
68+name = "allocator-api2"
69+version = "0.2.21"
70+source = "registry+https://github.com/rust-lang/crates.io-index"
71+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
72+
73+[[package]]
74+name = "ammonia"
75+version = "4.1.4"
76+source = "registry+https://github.com/rust-lang/crates.io-index"
77+checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
78+dependencies = [
79+ "cssparser",
80+ "html5ever",
81+ "maplit",
82+ "url",
83+]
84+
85+[[package]]
86+name = "android_system_properties"
87+version = "0.1.5"
88+source = "registry+https://github.com/rust-lang/crates.io-index"
89+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
90+dependencies = [
91+ "libc",
92+]
93+
94+[[package]]
95+name = "anstream"
96+version = "1.0.0"
97+source = "registry+https://github.com/rust-lang/crates.io-index"
98+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
99+dependencies = [
100+ "anstyle",
101+ "anstyle-parse",
102+ "anstyle-query",
103+ "anstyle-wincon",
104+ "colorchoice",
105+ "is_terminal_polyfill",
106+ "utf8parse",
107+]
108+
109+[[package]]
110+name = "anstyle"
111+version = "1.0.14"
112+source = "registry+https://github.com/rust-lang/crates.io-index"
113+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
114+
115+[[package]]
116+name = "anstyle-parse"
117+version = "1.0.0"
118+source = "registry+https://github.com/rust-lang/crates.io-index"
119+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
120+dependencies = [
121+ "utf8parse",
122+]
123+
124+[[package]]
125+name = "anstyle-query"
126+version = "1.1.5"
127+source = "registry+https://github.com/rust-lang/crates.io-index"
128+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
129+dependencies = [
130+ "windows-sys 0.61.2",
131+]
132+
133+[[package]]
134+name = "anstyle-wincon"
135+version = "3.0.11"
136+source = "registry+https://github.com/rust-lang/crates.io-index"
137+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
138+dependencies = [
139+ "anstyle",
140+ "once_cell_polyfill",
141+ "windows-sys 0.61.2",
142+]
143+
144+[[package]]
145+name = "anyhow"
146+version = "1.0.104"
147+source = "registry+https://github.com/rust-lang/crates.io-index"
148+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
149+
150+[[package]]
151+name = "arc-swap"
152+version = "1.9.2"
153+source = "registry+https://github.com/rust-lang/crates.io-index"
154+checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
155+dependencies = [
156+ "rustversion",
157+]
158+
159+[[package]]
160+name = "argon2"
161+version = "0.5.3"
162+source = "registry+https://github.com/rust-lang/crates.io-index"
163+checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
164+dependencies = [
165+ "base64ct",
166+ "blake2",
167+ "cpufeatures 0.2.17",
168+ "password-hash",
169+]
170+
171+[[package]]
172+name = "arrayvec"
173+version = "0.7.8"
174+source = "registry+https://github.com/rust-lang/crates.io-index"
175+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
176+
177+[[package]]
178+name = "async-compression"
179+version = "0.4.43"
180+source = "registry+https://github.com/rust-lang/crates.io-index"
181+checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8"
182+dependencies = [
183+ "compression-codecs",
184+ "compression-core",
185+ "pin-project-lite",
186+ "tokio",
187+]
188+
189+[[package]]
190+name = "async-trait"
191+version = "0.1.91"
192+source = "registry+https://github.com/rust-lang/crates.io-index"
193+checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
194+dependencies = [
195+ "proc-macro2",
196+ "quote",
197+ "syn 3.0.3",
198+]
199+
200+[[package]]
201+name = "atoi"
202+version = "2.0.0"
203+source = "registry+https://github.com/rust-lang/crates.io-index"
204+checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
205+dependencies = [
206+ "num-traits",
207+]
208+
209+[[package]]
210+name = "atomic-waker"
211+version = "1.1.2"
212+source = "registry+https://github.com/rust-lang/crates.io-index"
213+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
214+
215+[[package]]
216+name = "autocfg"
217+version = "1.5.1"
218+source = "registry+https://github.com/rust-lang/crates.io-index"
219+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
220+
221+[[package]]
222+name = "aws-lc-rs"
223+version = "1.17.3"
224+source = "registry+https://github.com/rust-lang/crates.io-index"
225+checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1"
226+dependencies = [
227+ "aws-lc-sys",
228+ "zeroize",
229+]
230+
231+[[package]]
232+name = "aws-lc-sys"
233+version = "0.43.0"
234+source = "registry+https://github.com/rust-lang/crates.io-index"
235+checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c"
236+dependencies = [
237+ "cc",
238+ "cmake",
239+ "dunce",
240+ "fs_extra",
241+ "pkg-config",
242+]
243+
244+[[package]]
245+name = "axum"
246+version = "0.8.9"
247+source = "registry+https://github.com/rust-lang/crates.io-index"
248+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
249+dependencies = [
250+ "axum-core",
251+ "axum-macros",
252+ "bytes",
253+ "form_urlencoded",
254+ "futures-util",
255+ "http",
256+ "http-body",
257+ "http-body-util",
258+ "hyper",
259+ "hyper-util",
260+ "itoa",
261+ "matchit",
262+ "memchr",
263+ "mime",
264+ "percent-encoding",
265+ "pin-project-lite",
266+ "serde_core",
267+ "serde_json",
268+ "serde_path_to_error",
269+ "serde_urlencoded",
270+ "sync_wrapper",
271+ "tokio",
272+ "tower",
273+ "tower-layer",
274+ "tower-service",
275+ "tracing",
276+]
277+
278+[[package]]
279+name = "axum-core"
280+version = "0.5.6"
281+source = "registry+https://github.com/rust-lang/crates.io-index"
282+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
283+dependencies = [
284+ "bytes",
285+ "futures-core",
286+ "http",
287+ "http-body",
288+ "http-body-util",
289+ "mime",
290+ "pin-project-lite",
291+ "sync_wrapper",
292+ "tower-layer",
293+ "tower-service",
294+ "tracing",
295+]
296+
297+[[package]]
298+name = "axum-extra"
299+version = "0.10.3"
300+source = "registry+https://github.com/rust-lang/crates.io-index"
301+checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96"
302+dependencies = [
303+ "axum",
304+ "axum-core",
305+ "bytes",
306+ "cookie",
307+ "futures-util",
308+ "headers",
309+ "http",
310+ "http-body",
311+ "http-body-util",
312+ "mime",
313+ "pin-project-lite",
314+ "rustversion",
315+ "serde_core",
316+ "tower-layer",
317+ "tower-service",
318+ "tracing",
319+]
320+
321+[[package]]
322+name = "axum-macros"
323+version = "0.5.1"
324+source = "registry+https://github.com/rust-lang/crates.io-index"
325+checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
326+dependencies = [
327+ "proc-macro2",
328+ "quote",
329+ "syn 2.0.119",
330+]
331+
332+[[package]]
333+name = "base16ct"
334+version = "0.2.0"
335+source = "registry+https://github.com/rust-lang/crates.io-index"
336+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
337+
338+[[package]]
339+name = "base64"
340+version = "0.21.7"
341+source = "registry+https://github.com/rust-lang/crates.io-index"
342+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
343+
344+[[package]]
345+name = "base64"
346+version = "0.22.1"
347+source = "registry+https://github.com/rust-lang/crates.io-index"
348+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
349+
350+[[package]]
351+name = "base64ct"
352+version = "1.8.3"
353+source = "registry+https://github.com/rust-lang/crates.io-index"
354+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
355+
356+[[package]]
357+name = "bcrypt-pbkdf"
358+version = "0.10.0"
359+source = "registry+https://github.com/rust-lang/crates.io-index"
360+checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2"
361+dependencies = [
362+ "blowfish",
363+ "pbkdf2",
364+ "sha2",
365+]
366+
367+[[package]]
368+name = "bincode"
369+version = "1.3.3"
370+source = "registry+https://github.com/rust-lang/crates.io-index"
371+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
372+dependencies = [
373+ "serde",
374+]
375+
376+[[package]]
377+name = "bit-set"
378+version = "0.8.0"
379+source = "registry+https://github.com/rust-lang/crates.io-index"
380+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
381+dependencies = [
382+ "bit-vec",
383+]
384+
385+[[package]]
386+name = "bit-vec"
387+version = "0.8.0"
388+source = "registry+https://github.com/rust-lang/crates.io-index"
389+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
390+
391+[[package]]
392+name = "bitflags"
393+version = "1.3.2"
394+source = "registry+https://github.com/rust-lang/crates.io-index"
395+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
396+
397+[[package]]
398+name = "bitflags"
399+version = "2.13.1"
400+source = "registry+https://github.com/rust-lang/crates.io-index"
401+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
402+dependencies = [
403+ "serde_core",
404+]
405+
406+[[package]]
407+name = "blake2"
408+version = "0.10.6"
409+source = "registry+https://github.com/rust-lang/crates.io-index"
410+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
411+dependencies = [
412+ "digest",
413+]
414+
415+[[package]]
416+name = "block-buffer"
417+version = "0.10.4"
418+source = "registry+https://github.com/rust-lang/crates.io-index"
419+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
420+dependencies = [
421+ "generic-array",
422+]
423+
424+[[package]]
425+name = "block-padding"
426+version = "0.3.3"
427+source = "registry+https://github.com/rust-lang/crates.io-index"
428+checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
429+dependencies = [
430+ "generic-array",
431+]
432+
433+[[package]]
434+name = "blowfish"
435+version = "0.9.1"
436+source = "registry+https://github.com/rust-lang/crates.io-index"
437+checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
438+dependencies = [
439+ "byteorder",
440+ "cipher",
441+]
442+
443+[[package]]
444+name = "bon"
445+version = "3.9.3"
446+source = "registry+https://github.com/rust-lang/crates.io-index"
447+checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561"
448+dependencies = [
449+ "bon-macros",
450+ "rustversion",
451+]
452+
453+[[package]]
454+name = "bon-macros"
455+version = "3.9.3"
456+source = "registry+https://github.com/rust-lang/crates.io-index"
457+checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f"
458+dependencies = [
459+ "darling",
460+ "ident_case",
461+ "prettyplease",
462+ "proc-macro2",
463+ "quote",
464+ "rustversion",
465+ "syn 2.0.119",
466+]
467+
468+[[package]]
469+name = "bs58"
470+version = "0.5.1"
471+source = "registry+https://github.com/rust-lang/crates.io-index"
472+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
473+dependencies = [
474+ "tinyvec",
475+]
476+
477+[[package]]
478+name = "bstr"
479+version = "1.13.0"
480+source = "registry+https://github.com/rust-lang/crates.io-index"
481+checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530"
482+dependencies = [
483+ "memchr",
484+ "regex-automata",
485+ "serde_core",
486+]
487+
488+[[package]]
489+name = "bumpalo"
490+version = "3.20.3"
491+source = "registry+https://github.com/rust-lang/crates.io-index"
492+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
493+
494+[[package]]
495+name = "byteorder"
496+version = "1.5.0"
497+source = "registry+https://github.com/rust-lang/crates.io-index"
498+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
499+
500+[[package]]
501+name = "bytes"
502+version = "1.12.1"
503+source = "registry+https://github.com/rust-lang/crates.io-index"
504+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
505+
506+[[package]]
507+name = "caseless"
508+version = "0.2.2"
509+source = "registry+https://github.com/rust-lang/crates.io-index"
510+checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8"
511+dependencies = [
512+ "unicode-normalization",
513+]
514+
515+[[package]]
516+name = "cbc"
517+version = "0.1.2"
518+source = "registry+https://github.com/rust-lang/crates.io-index"
519+checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
520+dependencies = [
521+ "cipher",
522+]
523+
524+[[package]]
525+name = "cc"
526+version = "1.2.67"
527+source = "registry+https://github.com/rust-lang/crates.io-index"
528+checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
529+dependencies = [
530+ "find-msvc-tools",
531+ "jobserver",
532+ "libc",
533+ "shlex",
534+]
535+
536+[[package]]
537+name = "cfg-if"
538+version = "1.0.4"
539+source = "registry+https://github.com/rust-lang/crates.io-index"
540+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
541+
542+[[package]]
543+name = "cfg_aliases"
544+version = "0.2.2"
545+source = "registry+https://github.com/rust-lang/crates.io-index"
546+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
547+
548+[[package]]
549+name = "chacha20"
550+version = "0.9.1"
551+source = "registry+https://github.com/rust-lang/crates.io-index"
552+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
553+dependencies = [
554+ "cfg-if",
555+ "cipher",
556+ "cpufeatures 0.2.17",
557+]
558+
559+[[package]]
560+name = "chacha20"
561+version = "0.10.1"
562+source = "registry+https://github.com/rust-lang/crates.io-index"
563+checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
564+dependencies = [
565+ "cfg-if",
566+ "cpufeatures 0.3.0",
567+ "rand_core 0.10.1",
568+]
569+
570+[[package]]
571+name = "chrono"
572+version = "0.4.45"
573+source = "registry+https://github.com/rust-lang/crates.io-index"
574+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
575+dependencies = [
576+ "iana-time-zone",
577+ "js-sys",
578+ "num-traits",
579+ "serde",
580+ "wasm-bindgen",
581+ "windows-link",
582+]
583+
584+[[package]]
585+name = "cipher"
586+version = "0.4.4"
587+source = "registry+https://github.com/rust-lang/crates.io-index"
588+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
589+dependencies = [
590+ "crypto-common",
591+ "inout",
592+]
593+
594+[[package]]
595+name = "clap"
596+version = "4.6.4"
597+source = "registry+https://github.com/rust-lang/crates.io-index"
598+checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7"
599+dependencies = [
600+ "clap_builder",
601+ "clap_derive",
602+]
603+
604+[[package]]
605+name = "clap_builder"
606+version = "4.6.2"
607+source = "registry+https://github.com/rust-lang/crates.io-index"
608+checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
609+dependencies = [
610+ "anstream",
611+ "anstyle",
612+ "clap_lex",
613+ "strsim",
614+ "terminal_size",
615+]
616+
617+[[package]]
618+name = "clap_derive"
619+version = "4.6.4"
620+source = "registry+https://github.com/rust-lang/crates.io-index"
621+checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
622+dependencies = [
623+ "heck",
624+ "proc-macro2",
625+ "quote",
626+ "syn 3.0.3",
627+]
628+
629+[[package]]
630+name = "clap_lex"
631+version = "1.1.0"
632+source = "registry+https://github.com/rust-lang/crates.io-index"
633+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
634+
635+[[package]]
636+name = "clru"
637+version = "0.6.3"
638+source = "registry+https://github.com/rust-lang/crates.io-index"
639+checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5"
640+dependencies = [
641+ "hashbrown 0.16.1",
642+]
643+
644+[[package]]
645+name = "cmake"
646+version = "0.1.58"
647+source = "registry+https://github.com/rust-lang/crates.io-index"
648+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
649+dependencies = [
650+ "cc",
651+]
652+
653+[[package]]
654+name = "colorchoice"
655+version = "1.0.5"
656+source = "registry+https://github.com/rust-lang/crates.io-index"
657+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
658+
659+[[package]]
660+name = "compression-codecs"
661+version = "0.4.38"
662+source = "registry+https://github.com/rust-lang/crates.io-index"
663+checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
664+dependencies = [
665+ "compression-core",
666+ "flate2",
667+ "memchr",
668+]
669+
670+[[package]]
671+name = "compression-core"
672+version = "0.4.32"
673+source = "registry+https://github.com/rust-lang/crates.io-index"
674+checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
675+
676+[[package]]
677+name = "comrak"
678+version = "0.39.1"
679+source = "registry+https://github.com/rust-lang/crates.io-index"
680+checksum = "2fefab951771fc3beeed0773ce66a4f7b706273fc6c4c95b08dd1615744abcf5"
681+dependencies = [
682+ "bon",
683+ "caseless",
684+ "clap",
685+ "entities",
686+ "memchr",
687+ "shell-words",
688+ "slug",
689+ "syntect",
690+ "typed-arena",
691+ "unicode_categories",
692+ "xdg",
693+]
694+
695+[[package]]
696+name = "const-oid"
697+version = "0.9.6"
698+source = "registry+https://github.com/rust-lang/crates.io-index"
699+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
700+
701+[[package]]
702+name = "cookie"
703+version = "0.18.1"
704+source = "registry+https://github.com/rust-lang/crates.io-index"
705+checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
706+dependencies = [
707+ "percent-encoding",
708+ "time",
709+ "version_check",
710+]
711+
712+[[package]]
713+name = "core-foundation-sys"
714+version = "0.8.7"
715+source = "registry+https://github.com/rust-lang/crates.io-index"
716+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
717+
718+[[package]]
719+name = "cpufeatures"
720+version = "0.2.17"
721+source = "registry+https://github.com/rust-lang/crates.io-index"
722+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
723+dependencies = [
724+ "libc",
725+]
726+
727+[[package]]
728+name = "cpufeatures"
729+version = "0.3.0"
730+source = "registry+https://github.com/rust-lang/crates.io-index"
731+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
732+dependencies = [
733+ "libc",
734+]
735+
736+[[package]]
737+name = "crc"
738+version = "3.4.0"
739+source = "registry+https://github.com/rust-lang/crates.io-index"
740+checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
741+dependencies = [
742+ "crc-catalog",
743+]
744+
745+[[package]]
746+name = "crc-catalog"
747+version = "2.5.0"
748+source = "registry+https://github.com/rust-lang/crates.io-index"
749+checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
750+
751+[[package]]
752+name = "crc32fast"
753+version = "1.5.0"
754+source = "registry+https://github.com/rust-lang/crates.io-index"
755+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
756+dependencies = [
757+ "cfg-if",
758+]
759+
760+[[package]]
761+name = "crossbeam-channel"
762+version = "0.5.16"
763+source = "registry+https://github.com/rust-lang/crates.io-index"
764+checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
765+dependencies = [
766+ "crossbeam-utils",
767+]
768+
769+[[package]]
770+name = "crossbeam-queue"
771+version = "0.3.13"
772+source = "registry+https://github.com/rust-lang/crates.io-index"
773+checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26"
774+dependencies = [
775+ "crossbeam-utils",
776+]
777+
778+[[package]]
779+name = "crossbeam-utils"
780+version = "0.8.22"
781+source = "registry+https://github.com/rust-lang/crates.io-index"
782+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
783+
784+[[package]]
785+name = "crypto-bigint"
786+version = "0.5.5"
787+source = "registry+https://github.com/rust-lang/crates.io-index"
788+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
789+dependencies = [
790+ "generic-array",
791+ "rand_core 0.6.4",
792+ "subtle",
793+ "zeroize",
794+]
795+
796+[[package]]
797+name = "crypto-common"
798+version = "0.1.7"
799+source = "registry+https://github.com/rust-lang/crates.io-index"
800+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
801+dependencies = [
802+ "generic-array",
803+ "rand_core 0.6.4",
804+ "typenum",
805+]
806+
807+[[package]]
808+name = "cssparser"
809+version = "0.37.0"
810+source = "registry+https://github.com/rust-lang/crates.io-index"
811+checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
812+dependencies = [
813+ "dtoa-short",
814+ "itoa",
815+ "smallvec",
816+]
817+
818+[[package]]
819+name = "ctr"
820+version = "0.9.2"
821+source = "registry+https://github.com/rust-lang/crates.io-index"
822+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
823+dependencies = [
824+ "cipher",
825+]
826+
827+[[package]]
828+name = "curve25519-dalek"
829+version = "4.1.3"
830+source = "registry+https://github.com/rust-lang/crates.io-index"
831+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
832+dependencies = [
833+ "cfg-if",
834+ "cpufeatures 0.2.17",
835+ "curve25519-dalek-derive",
836+ "digest",
837+ "fiat-crypto",
838+ "rustc_version",
839+ "subtle",
840+ "zeroize",
841+]
842+
843+[[package]]
844+name = "curve25519-dalek-derive"
845+version = "0.1.1"
846+source = "registry+https://github.com/rust-lang/crates.io-index"
847+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
848+dependencies = [
849+ "proc-macro2",
850+ "quote",
851+ "syn 2.0.119",
852+]
853+
854+[[package]]
855+name = "darling"
856+version = "0.23.0"
857+source = "registry+https://github.com/rust-lang/crates.io-index"
858+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
859+dependencies = [
860+ "darling_core",
861+ "darling_macro",
862+]
863+
864+[[package]]
865+name = "darling_core"
866+version = "0.23.0"
867+source = "registry+https://github.com/rust-lang/crates.io-index"
868+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
869+dependencies = [
870+ "ident_case",
871+ "proc-macro2",
872+ "quote",
873+ "strsim",
874+ "syn 2.0.119",
875+]
876+
877+[[package]]
878+name = "darling_macro"
879+version = "0.23.0"
880+source = "registry+https://github.com/rust-lang/crates.io-index"
881+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
882+dependencies = [
883+ "darling_core",
884+ "quote",
885+ "syn 2.0.119",
886+]
887+
888+[[package]]
889+name = "dashmap"
890+version = "6.2.1"
891+source = "registry+https://github.com/rust-lang/crates.io-index"
892+checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
893+dependencies = [
894+ "cfg-if",
895+ "crossbeam-utils",
896+ "hashbrown 0.14.5",
897+ "lock_api",
898+ "once_cell",
899+ "parking_lot_core",
900+]
901+
902+[[package]]
903+name = "data-encoding"
904+version = "2.11.0"
905+source = "registry+https://github.com/rust-lang/crates.io-index"
906+checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
907+
908+[[package]]
909+name = "defmt"
910+version = "1.1.1"
911+source = "registry+https://github.com/rust-lang/crates.io-index"
912+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
913+dependencies = [
914+ "bitflags 1.3.2",
915+ "defmt-macros",
916+]
917+
918+[[package]]
919+name = "defmt-macros"
920+version = "1.1.1"
921+source = "registry+https://github.com/rust-lang/crates.io-index"
922+checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
923+dependencies = [
924+ "defmt-parser",
925+ "proc-macro2",
926+ "quote",
927+ "syn 2.0.119",
928+]
929+
930+[[package]]
931+name = "defmt-parser"
932+version = "1.0.0"
933+source = "registry+https://github.com/rust-lang/crates.io-index"
934+checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
935+dependencies = [
936+ "thiserror 2.0.19",
937+]
938+
939+[[package]]
940+name = "delegate"
941+version = "0.13.5"
942+source = "registry+https://github.com/rust-lang/crates.io-index"
943+checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370"
944+dependencies = [
945+ "proc-macro2",
946+ "quote",
947+ "syn 2.0.119",
948+]
949+
950+[[package]]
951+name = "der"
952+version = "0.7.10"
953+source = "registry+https://github.com/rust-lang/crates.io-index"
954+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
955+dependencies = [
956+ "const-oid",
957+ "pem-rfc7468",
958+ "zeroize",
959+]
960+
961+[[package]]
962+name = "deranged"
963+version = "0.5.8"
964+source = "registry+https://github.com/rust-lang/crates.io-index"
965+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
966+dependencies = [
967+ "serde_core",
968+]
969+
970+[[package]]
971+name = "deunicode"
972+version = "1.6.2"
973+source = "registry+https://github.com/rust-lang/crates.io-index"
974+checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
975+
976+[[package]]
977+name = "df-admin"
978+version = "0.1.0"
979+dependencies = [
980+ "anyhow",
981+ "chrono",
982+ "clap",
983+ "df-auth",
984+ "df-db",
985+ "dotenvy",
986+ "serde_json",
987+ "sqlx",
988+ "tokio",
989+ "uuid",
990+]
991+
992+[[package]]
993+name = "df-auth"
994+version = "0.1.0"
995+dependencies = [
996+ "anyhow",
997+ "argon2",
998+ "chrono",
999+ "df-db",
1000+ "hex",
1001+ "hmac",
1002+ "openidconnect",
1003+ "rand 0.8.7",
1004+ "serde",
1005+ "serde_json",
1006+ "sha2",
1007+ "sqlx",
1008+ "ssh-key",
1009+ "subtle",
1010+ "thiserror 2.0.19",
1011+ "tracing",
1012+ "uuid",
1013+]
1014+
1015+[[package]]
1016+name = "df-db"
1017+version = "0.1.0"
1018+dependencies = [
1019+ "anyhow",
1020+ "chrono",
1021+ "futures",
1022+ "serde",
1023+ "serde_json",
1024+ "sqlx",
1025+ "thiserror 2.0.19",
1026+ "tokio",
1027+ "tracing",
1028+ "uuid",
1029+]
1030+
1031+[[package]]
1032+name = "df-hook"
1033+version = "0.1.0"
1034+dependencies = [
1035+ "df-db",
1036+ "df-store",
1037+ "sqlx",
1038+ "tokio",
1039+ "uuid",
1040+]
1041+
1042+[[package]]
1043+name = "df-index"
1044+version = "0.1.0"
1045+dependencies = [
1046+ "chrono",
1047+ "hex",
1048+ "serde_json",
1049+ "sha2",
1050+ "similar",
1051+ "thiserror 2.0.19",
1052+ "tracing",
1053+]
1054+
1055+[[package]]
1056+name = "df-render"
1057+version = "0.1.0"
1058+dependencies = [
1059+ "ammonia",
1060+ "comrak",
1061+ "tree-sitter",
1062+ "tree-sitter-bash",
1063+ "tree-sitter-c",
1064+ "tree-sitter-c-sharp",
1065+ "tree-sitter-cpp",
1066+ "tree-sitter-css",
1067+ "tree-sitter-elixir",
1068+ "tree-sitter-go",
1069+ "tree-sitter-haskell",
1070+ "tree-sitter-highlight",
1071+ "tree-sitter-html",
1072+ "tree-sitter-java",
1073+ "tree-sitter-javascript",
1074+ "tree-sitter-json",
1075+ "tree-sitter-lua",
1076+ "tree-sitter-md",
1077+ "tree-sitter-nix",
1078+ "tree-sitter-php",
1079+ "tree-sitter-python",
1080+ "tree-sitter-ruby",
1081+ "tree-sitter-rust",
1082+ "tree-sitter-scala",
1083+ "tree-sitter-sequel",
1084+ "tree-sitter-swift",
1085+ "tree-sitter-toml-ng",
1086+ "tree-sitter-typescript",
1087+ "tree-sitter-yaml",
1088+ "tree-sitter-zig",
1089+]
1090+
1091+[[package]]
1092+name = "df-ssh"
1093+version = "0.1.0"
1094+dependencies = [
1095+ "anyhow",
1096+ "df-auth",
1097+ "df-db",
1098+ "dotenvy",
1099+ "rand 0.8.7",
1100+ "russh",
1101+ "sqlx",
1102+ "tokio",
1103+ "tracing",
1104+ "tracing-subscriber",
1105+ "uuid",
1106+]
1107+
1108+[[package]]
1109+name = "df-store"
1110+version = "0.1.0"
1111+dependencies = [
1112+ "anyhow",
1113+ "async-trait",
1114+ "chrono",
1115+ "df-index",
1116+ "gix",
1117+ "lru",
1118+ "serde_json",
1119+ "similar",
1120+ "tempfile",
1121+ "thiserror 2.0.19",
1122+ "tokio",
1123+ "tracing",
1124+ "uuid",
1125+]
1126+
1127+[[package]]
1128+name = "df-web"
1129+version = "0.1.0"
1130+dependencies = [
1131+ "anyhow",
1132+ "async-trait",
1133+ "axum",
1134+ "axum-extra",
1135+ "base64 0.22.1",
1136+ "chrono",
1137+ "df-auth",
1138+ "df-db",
1139+ "df-render",
1140+ "df-store",
1141+ "dotenvy",
1142+ "form_urlencoded",
1143+ "futures",
1144+ "hex",
1145+ "hmac",
1146+ "maud",
1147+ "percent-encoding",
1148+ "rand 0.8.7",
1149+ "serde",
1150+ "serde_json",
1151+ "sha2",
1152+ "sqlx",
1153+ "subtle",
1154+ "time",
1155+ "tokio",
1156+ "tower",
1157+ "tower-http",
1158+ "tracing",
1159+ "tracing-subscriber",
1160+ "uuid",
1161+]
1162+
1163+[[package]]
1164+name = "df-worker"
1165+version = "0.1.0"
1166+dependencies = [
1167+ "anyhow",
1168+ "chrono",
1169+ "df-db",
1170+ "df-index",
1171+ "df-store",
1172+ "dotenvy",
1173+ "serde",
1174+ "serde_json",
1175+ "sqlx",
1176+ "tokio",
1177+ "tracing",
1178+ "tracing-subscriber",
1179+ "uuid",
1180+]
1181+
1182+[[package]]
1183+name = "digest"
1184+version = "0.10.7"
1185+source = "registry+https://github.com/rust-lang/crates.io-index"
1186+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
1187+dependencies = [
1188+ "block-buffer",
1189+ "const-oid",
1190+ "crypto-common",
1191+ "subtle",
1192+]
1193+
1194+[[package]]
1195+name = "displaydoc"
1196+version = "0.2.7"
1197+source = "registry+https://github.com/rust-lang/crates.io-index"
1198+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
1199+dependencies = [
1200+ "proc-macro2",
1201+ "quote",
1202+ "syn 3.0.3",
1203+]
1204+
1205+[[package]]
1206+name = "dotenvy"
1207+version = "0.15.7"
1208+source = "registry+https://github.com/rust-lang/crates.io-index"
1209+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
1210+
1211+[[package]]
1212+name = "dtoa"
1213+version = "1.0.11"
1214+source = "registry+https://github.com/rust-lang/crates.io-index"
1215+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
1216+
1217+[[package]]
1218+name = "dtoa-short"
1219+version = "0.3.5"
1220+source = "registry+https://github.com/rust-lang/crates.io-index"
1221+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
1222+dependencies = [
1223+ "dtoa",
1224+]
1225+
1226+[[package]]
1227+name = "dunce"
1228+version = "1.0.5"
1229+source = "registry+https://github.com/rust-lang/crates.io-index"
1230+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
1231+
1232+[[package]]
1233+name = "dyn-clone"
1234+version = "1.0.20"
1235+source = "registry+https://github.com/rust-lang/crates.io-index"
1236+checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
1237+
1238+[[package]]
1239+name = "ecdsa"
1240+version = "0.16.9"
1241+source = "registry+https://github.com/rust-lang/crates.io-index"
1242+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
1243+dependencies = [
1244+ "der",
1245+ "digest",
1246+ "elliptic-curve",
1247+ "rfc6979",
1248+ "signature",
1249+ "spki",
1250+]
1251+
1252+[[package]]
1253+name = "ed25519"
1254+version = "2.2.3"
1255+source = "registry+https://github.com/rust-lang/crates.io-index"
1256+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
1257+dependencies = [
1258+ "pkcs8",
1259+ "signature",
1260+]
1261+
1262+[[package]]
1263+name = "ed25519-dalek"
1264+version = "2.2.0"
1265+source = "registry+https://github.com/rust-lang/crates.io-index"
1266+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
1267+dependencies = [
1268+ "curve25519-dalek",
1269+ "ed25519",
1270+ "rand_core 0.6.4",
1271+ "serde",
1272+ "sha2",
1273+ "subtle",
1274+ "zeroize",
1275+]
1276+
1277+[[package]]
1278+name = "either"
1279+version = "1.17.0"
1280+source = "registry+https://github.com/rust-lang/crates.io-index"
1281+checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
1282+dependencies = [
1283+ "serde",
1284+]
1285+
1286+[[package]]
1287+name = "elliptic-curve"
1288+version = "0.13.8"
1289+source = "registry+https://github.com/rust-lang/crates.io-index"
1290+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
1291+dependencies = [
1292+ "base16ct",
1293+ "crypto-bigint",
1294+ "digest",
1295+ "ff",
1296+ "generic-array",
1297+ "group",
1298+ "hkdf",
1299+ "pem-rfc7468",
1300+ "pkcs8",
1301+ "rand_core 0.6.4",
1302+ "sec1",
1303+ "subtle",
1304+ "zeroize",
1305+]
1306+
1307+[[package]]
1308+name = "encoding_rs"
1309+version = "0.8.35"
1310+source = "registry+https://github.com/rust-lang/crates.io-index"
1311+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
1312+dependencies = [
1313+ "cfg-if",
1314+]
1315+
1316+[[package]]
1317+name = "entities"
1318+version = "1.0.1"
1319+source = "registry+https://github.com/rust-lang/crates.io-index"
1320+checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"
1321+
1322+[[package]]
1323+name = "enum_dispatch"
1324+version = "0.3.13"
1325+source = "registry+https://github.com/rust-lang/crates.io-index"
1326+checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd"
1327+dependencies = [
1328+ "once_cell",
1329+ "proc-macro2",
1330+ "quote",
1331+ "syn 2.0.119",
1332+]
1333+
1334+[[package]]
1335+name = "equivalent"
1336+version = "1.0.2"
1337+source = "registry+https://github.com/rust-lang/crates.io-index"
1338+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
1339+
1340+[[package]]
1341+name = "errno"
1342+version = "0.3.14"
1343+source = "registry+https://github.com/rust-lang/crates.io-index"
1344+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
1345+dependencies = [
1346+ "libc",
1347+ "windows-sys 0.61.2",
1348+]
1349+
1350+[[package]]
1351+name = "etcetera"
1352+version = "0.8.0"
1353+source = "registry+https://github.com/rust-lang/crates.io-index"
1354+checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
1355+dependencies = [
1356+ "cfg-if",
1357+ "home",
1358+ "windows-sys 0.48.0",
1359+]
1360+
1361+[[package]]
1362+name = "event-listener"
1363+version = "5.4.2"
1364+source = "registry+https://github.com/rust-lang/crates.io-index"
1365+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
1366+dependencies = [
1367+ "parking",
1368+ "pin-project-lite",
1369+]
1370+
1371+[[package]]
1372+name = "fancy-regex"
1373+version = "0.16.2"
1374+source = "registry+https://github.com/rust-lang/crates.io-index"
1375+checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"
1376+dependencies = [
1377+ "bit-set",
1378+ "regex-automata",
1379+ "regex-syntax",
1380+]
1381+
1382+[[package]]
1383+name = "faster-hex"
1384+version = "0.9.0"
1385+source = "registry+https://github.com/rust-lang/crates.io-index"
1386+checksum = "a2a2b11eda1d40935b26cf18f6833c526845ae8c41e58d09af6adeb6f0269183"
1387+dependencies = [
1388+ "serde",
1389+]
1390+
1391+[[package]]
1392+name = "fastrand"
1393+version = "2.5.0"
1394+source = "registry+https://github.com/rust-lang/crates.io-index"
1395+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
1396+
1397+[[package]]
1398+name = "ff"
1399+version = "0.13.1"
1400+source = "registry+https://github.com/rust-lang/crates.io-index"
1401+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
1402+dependencies = [
1403+ "rand_core 0.6.4",
1404+ "subtle",
1405+]
1406+
1407+[[package]]
1408+name = "fiat-crypto"
1409+version = "0.2.9"
1410+source = "registry+https://github.com/rust-lang/crates.io-index"
1411+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
1412+
1413+[[package]]
1414+name = "filetime"
1415+version = "0.2.29"
1416+source = "registry+https://github.com/rust-lang/crates.io-index"
1417+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
1418+dependencies = [
1419+ "cfg-if",
1420+ "libc",
1421+]
1422+
1423+[[package]]
1424+name = "find-msvc-tools"
1425+version = "0.1.9"
1426+source = "registry+https://github.com/rust-lang/crates.io-index"
1427+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
1428+
1429+[[package]]
1430+name = "flate2"
1431+version = "1.1.9"
1432+source = "registry+https://github.com/rust-lang/crates.io-index"
1433+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
1434+dependencies = [
1435+ "crc32fast",
1436+ "miniz_oxide",
1437+]
1438+
1439+[[package]]
1440+name = "flume"
1441+version = "0.11.1"
1442+source = "registry+https://github.com/rust-lang/crates.io-index"
1443+checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
1444+dependencies = [
1445+ "futures-core",
1446+ "futures-sink",
1447+ "spin",
1448+]
1449+
1450+[[package]]
1451+name = "fnv"
1452+version = "1.0.7"
1453+source = "registry+https://github.com/rust-lang/crates.io-index"
1454+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
1455+
1456+[[package]]
1457+name = "foldhash"
1458+version = "0.1.5"
1459+source = "registry+https://github.com/rust-lang/crates.io-index"
1460+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
1461+
1462+[[package]]
1463+name = "foldhash"
1464+version = "0.2.0"
1465+source = "registry+https://github.com/rust-lang/crates.io-index"
1466+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
1467+
1468+[[package]]
1469+name = "form_urlencoded"
1470+version = "1.2.2"
1471+source = "registry+https://github.com/rust-lang/crates.io-index"
1472+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
1473+dependencies = [
1474+ "percent-encoding",
1475+]
1476+
1477+[[package]]
1478+name = "fs_extra"
1479+version = "1.3.0"
1480+source = "registry+https://github.com/rust-lang/crates.io-index"
1481+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
1482+
1483+[[package]]
1484+name = "futures"
1485+version = "0.3.33"
1486+source = "registry+https://github.com/rust-lang/crates.io-index"
1487+checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
1488+dependencies = [
1489+ "futures-channel",
1490+ "futures-core",
1491+ "futures-executor",
1492+ "futures-io",
1493+ "futures-sink",
1494+ "futures-task",
1495+ "futures-util",
1496+]
1497+
1498+[[package]]
1499+name = "futures-channel"
1500+version = "0.3.33"
1501+source = "registry+https://github.com/rust-lang/crates.io-index"
1502+checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
1503+dependencies = [
1504+ "futures-core",
1505+ "futures-sink",
1506+]
1507+
1508+[[package]]
1509+name = "futures-core"
1510+version = "0.3.33"
1511+source = "registry+https://github.com/rust-lang/crates.io-index"
1512+checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
1513+
1514+[[package]]
1515+name = "futures-executor"
1516+version = "0.3.33"
1517+source = "registry+https://github.com/rust-lang/crates.io-index"
1518+checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
1519+dependencies = [
1520+ "futures-core",
1521+ "futures-task",
1522+ "futures-util",
1523+]
1524+
1525+[[package]]
1526+name = "futures-intrusive"
1527+version = "0.5.0"
1528+source = "registry+https://github.com/rust-lang/crates.io-index"
1529+checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
1530+dependencies = [
1531+ "futures-core",
1532+ "lock_api",
1533+ "parking_lot",
1534+]
1535+
1536+[[package]]
1537+name = "futures-io"
1538+version = "0.3.33"
1539+source = "registry+https://github.com/rust-lang/crates.io-index"
1540+checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
1541+
1542+[[package]]
1543+name = "futures-macro"
1544+version = "0.3.33"
1545+source = "registry+https://github.com/rust-lang/crates.io-index"
1546+checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
1547+dependencies = [
1548+ "proc-macro2",
1549+ "quote",
1550+ "syn 2.0.119",
1551+]
1552+
1553+[[package]]
1554+name = "futures-sink"
1555+version = "0.3.33"
1556+source = "registry+https://github.com/rust-lang/crates.io-index"
1557+checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
1558+
1559+[[package]]
1560+name = "futures-task"
1561+version = "0.3.33"
1562+source = "registry+https://github.com/rust-lang/crates.io-index"
1563+checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
1564+
1565+[[package]]
1566+name = "futures-util"
1567+version = "0.3.33"
1568+source = "registry+https://github.com/rust-lang/crates.io-index"
1569+checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
1570+dependencies = [
1571+ "futures-channel",
1572+ "futures-core",
1573+ "futures-io",
1574+ "futures-macro",
1575+ "futures-sink",
1576+ "futures-task",
1577+ "memchr",
1578+ "pin-project-lite",
1579+ "slab",
1580+]
1581+
1582+[[package]]
1583+name = "generic-array"
1584+version = "0.14.7"
1585+source = "registry+https://github.com/rust-lang/crates.io-index"
1586+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
1587+dependencies = [
1588+ "typenum",
1589+ "version_check",
1590+ "zeroize",
1591+]
1592+
1593+[[package]]
1594+name = "getrandom"
1595+version = "0.2.17"
1596+source = "registry+https://github.com/rust-lang/crates.io-index"
1597+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
1598+dependencies = [
1599+ "cfg-if",
1600+ "js-sys",
1601+ "libc",
1602+ "wasi",
1603+ "wasm-bindgen",
1604+]
1605+
1606+[[package]]
1607+name = "getrandom"
1608+version = "0.4.3"
1609+source = "registry+https://github.com/rust-lang/crates.io-index"
1610+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
1611+dependencies = [
1612+ "cfg-if",
1613+ "js-sys",
1614+ "libc",
1615+ "r-efi",
1616+ "rand_core 0.10.1",
1617+ "wasm-bindgen",
1618+]
1619+
1620+[[package]]
1621+name = "ghash"
1622+version = "0.5.1"
1623+source = "registry+https://github.com/rust-lang/crates.io-index"
1624+checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
1625+dependencies = [
1626+ "opaque-debug",
1627+ "polyval",
1628+]
1629+
1630+[[package]]
1631+name = "gix"
1632+version = "0.68.0"
1633+source = "registry+https://github.com/rust-lang/crates.io-index"
1634+checksum = "b04c66359b5e17f92395abc433861df0edf48f39f3f590818d1d7217327dd6a1"
1635+dependencies = [
1636+ "gix-actor",
1637+ "gix-attributes",
1638+ "gix-command",
1639+ "gix-commitgraph",
1640+ "gix-config",
1641+ "gix-date",
1642+ "gix-diff",
1643+ "gix-discover",
1644+ "gix-features",
1645+ "gix-filter",
1646+ "gix-fs",
1647+ "gix-glob",
1648+ "gix-hash",
1649+ "gix-hashtable",
1650+ "gix-ignore",
1651+ "gix-index",
1652+ "gix-lock",
1653+ "gix-merge",
1654+ "gix-object",
1655+ "gix-odb",
1656+ "gix-pack",
1657+ "gix-path",
1658+ "gix-pathspec",
1659+ "gix-ref",
1660+ "gix-refspec",
1661+ "gix-revision",
1662+ "gix-revwalk",
1663+ "gix-sec",
1664+ "gix-submodule",
1665+ "gix-tempfile",
1666+ "gix-trace",
1667+ "gix-traverse",
1668+ "gix-url",
1669+ "gix-utils",
1670+ "gix-validate 0.9.4",
1671+ "gix-worktree",
1672+ "once_cell",
1673+ "smallvec",
1674+ "thiserror 2.0.19",
1675+]
1676+
1677+[[package]]
1678+name = "gix-actor"
1679+version = "0.33.2"
1680+source = "registry+https://github.com/rust-lang/crates.io-index"
1681+checksum = "20018a1a6332e065f1fcc8305c1c932c6b8c9985edea2284b3c79dc6fa3ee4b2"
1682+dependencies = [
1683+ "bstr",
1684+ "gix-date",
1685+ "gix-utils",
1686+ "itoa",
1687+ "thiserror 2.0.19",
1688+ "winnow",
1689+]
1690+
1691+[[package]]
1692+name = "gix-attributes"
1693+version = "0.23.1"
1694+source = "registry+https://github.com/rust-lang/crates.io-index"
1695+checksum = "ddf9bf852194c0edfe699a2d36422d2c1f28f73b7c6d446c3f0ccd3ba232cadc"
1696+dependencies = [
1697+ "bstr",
1698+ "gix-glob",
1699+ "gix-path",
1700+ "gix-quote",
1701+ "gix-trace",
1702+ "kstring",
1703+ "smallvec",
1704+ "thiserror 2.0.19",
1705+ "unicode-bom",
1706+]
1707+
1708+[[package]]
1709+name = "gix-bitmap"
1710+version = "0.2.16"
1711+source = "registry+https://github.com/rust-lang/crates.io-index"
1712+checksum = "d982fc7ef0608e669851d0d2a6141dae74c60d5a27e8daa451f2a4857bbf41e2"
1713+dependencies = [
1714+ "thiserror 2.0.19",
1715+]
1716+
1717+[[package]]
1718+name = "gix-chunk"
1719+version = "0.4.12"
1720+source = "registry+https://github.com/rust-lang/crates.io-index"
1721+checksum = "5c356b3825677cb6ff579551bb8311a81821e184453cbd105e2fc5311b288eeb"
1722+dependencies = [
1723+ "thiserror 2.0.19",
1724+]
1725+
1726+[[package]]
1727+name = "gix-command"
1728+version = "0.3.11"
1729+source = "registry+https://github.com/rust-lang/crates.io-index"
1730+checksum = "6d7d6b8f3a64453fd7e8191eb80b351eb7ac0839b40a1237cd2c137d5079fe53"
1731+dependencies = [
1732+ "bstr",
1733+ "gix-path",
1734+ "gix-trace",
1735+ "shell-words",
1736+]
1737+
1738+[[package]]
1739+name = "gix-commitgraph"
1740+version = "0.25.1"
1741+source = "registry+https://github.com/rust-lang/crates.io-index"
1742+checksum = "a8da6591a7868fb2b6dabddea6b09988b0b05e0213f938dbaa11a03dd7a48d85"
1743+dependencies = [
1744+ "bstr",
1745+ "gix-chunk",
1746+ "gix-features",
1747+ "gix-hash",
1748+ "memmap2",
1749+ "thiserror 2.0.19",
1750+]
1751+
1752+[[package]]
1753+name = "gix-config"
1754+version = "0.42.0"
1755+source = "registry+https://github.com/rust-lang/crates.io-index"
1756+checksum = "6649b406ca1f99cb148959cf00468b231f07950f8ec438cc0903cda563606f19"
1757+dependencies = [
1758+ "bstr",
1759+ "gix-config-value",
1760+ "gix-features",
1761+ "gix-glob",
1762+ "gix-path",
1763+ "gix-ref",
1764+ "gix-sec",
1765+ "memchr",
1766+ "once_cell",
1767+ "smallvec",
1768+ "thiserror 2.0.19",
1769+ "unicode-bom",
1770+ "winnow",
1771+]
1772+
1773+[[package]]
1774+name = "gix-config-value"
1775+version = "0.14.12"
1776+source = "registry+https://github.com/rust-lang/crates.io-index"
1777+checksum = "8dc2c844c4cf141884678cabef736fd91dd73068b9146e6f004ba1a0457944b6"
1778+dependencies = [
1779+ "bitflags 2.13.1",
1780+ "bstr",
1781+ "gix-path",
1782+ "libc",
1783+ "thiserror 2.0.19",
1784+]
1785+
1786+[[package]]
1787+name = "gix-date"
1788+version = "0.9.4"
1789+source = "registry+https://github.com/rust-lang/crates.io-index"
1790+checksum = "daa30058ec7d3511fbc229e4f9e696a35abd07ec5b82e635eff864a2726217e4"
1791+dependencies = [
1792+ "bstr",
1793+ "itoa",
1794+ "jiff",
1795+ "thiserror 2.0.19",
1796+]
1797+
1798+[[package]]
1799+name = "gix-diff"
1800+version = "0.48.0"
1801+source = "registry+https://github.com/rust-lang/crates.io-index"
1802+checksum = "a327be31a392144b60ab0b1c863362c32a1c8f7effdfa2141d5d5b6b916ef3bf"
1803+dependencies = [
1804+ "bstr",
1805+ "gix-command",
1806+ "gix-filter",
1807+ "gix-fs",
1808+ "gix-hash",
1809+ "gix-object",
1810+ "gix-path",
1811+ "gix-tempfile",
1812+ "gix-trace",
1813+ "gix-traverse",
1814+ "gix-worktree",
1815+ "imara-diff",
1816+ "thiserror 2.0.19",
1817+]
1818+
1819+[[package]]
1820+name = "gix-discover"
1821+version = "0.37.0"
1822+source = "registry+https://github.com/rust-lang/crates.io-index"
1823+checksum = "83bf6dfa4e266a4a9becb4d18fc801f92c3f7cc6c433dd86fdadbcf315ffb6ef"
1824+dependencies = [
1825+ "bstr",
1826+ "dunce",
1827+ "gix-fs",
1828+ "gix-hash",
1829+ "gix-path",
1830+ "gix-ref",
1831+ "gix-sec",
1832+ "thiserror 2.0.19",
1833+]
1834+
1835+[[package]]
1836+name = "gix-features"
1837+version = "0.39.1"
1838+source = "registry+https://github.com/rust-lang/crates.io-index"
1839+checksum = "7d85d673f2e022a340dba4713bed77ef2cf4cd737d2f3e0f159d45e0935fd81f"
1840+dependencies = [
1841+ "crc32fast",
1842+ "crossbeam-channel",
1843+ "flate2",
1844+ "gix-hash",
1845+ "gix-trace",
1846+ "gix-utils",
1847+ "libc",
1848+ "once_cell",
1849+ "parking_lot",
1850+ "prodash",
1851+ "sha1_smol",
1852+ "thiserror 2.0.19",
1853+ "walkdir",
1854+]
1855+
1856+[[package]]
1857+name = "gix-filter"
1858+version = "0.15.0"
1859+source = "registry+https://github.com/rust-lang/crates.io-index"
1860+checksum = "5108cc58d58b27df10ac4de7f31b2eb96d588a33e5eba23739b865f5d8db7995"
1861+dependencies = [
1862+ "bstr",
1863+ "encoding_rs",
1864+ "gix-attributes",
1865+ "gix-command",
1866+ "gix-hash",
1867+ "gix-object",
1868+ "gix-packetline-blocking",
1869+ "gix-path",
1870+ "gix-quote",
1871+ "gix-trace",
1872+ "gix-utils",
1873+ "smallvec",
1874+ "thiserror 2.0.19",
1875+]
1876+
1877+[[package]]
1878+name = "gix-fs"
1879+version = "0.12.1"
1880+source = "registry+https://github.com/rust-lang/crates.io-index"
1881+checksum = "3b3d4fac505a621f97e5ce2c69fdc425742af00c0920363ca4074f0eb48b1db9"
1882+dependencies = [
1883+ "fastrand",
1884+ "gix-features",
1885+ "gix-utils",
1886+]
1887+
1888+[[package]]
1889+name = "gix-glob"
1890+version = "0.17.1"
1891+source = "registry+https://github.com/rust-lang/crates.io-index"
1892+checksum = "aaf69a6bec0a3581567484bf99a4003afcaf6c469fd4214352517ea355cf3435"
1893+dependencies = [
1894+ "bitflags 2.13.1",
1895+ "bstr",
1896+ "gix-features",
1897+ "gix-path",
1898+]
1899+
1900+[[package]]
1901+name = "gix-hash"
1902+version = "0.15.1"
1903+source = "registry+https://github.com/rust-lang/crates.io-index"
1904+checksum = "0b5eccc17194ed0e67d49285e4853307e4147e95407f91c1c3e4a13ba9f4e4ce"
1905+dependencies = [
1906+ "faster-hex",
1907+ "thiserror 2.0.19",
1908+]
1909+
1910+[[package]]
1911+name = "gix-hashtable"
1912+version = "0.6.0"
1913+source = "registry+https://github.com/rust-lang/crates.io-index"
1914+checksum = "0ef65b256631078ef733bc5530c4e6b1c2e7d5c2830b75d4e9034ab3997d18fe"
1915+dependencies = [
1916+ "gix-hash",
1917+ "hashbrown 0.14.5",
1918+ "parking_lot",
1919+]
1920+
1921+[[package]]
1922+name = "gix-ignore"
1923+version = "0.12.1"
1924+source = "registry+https://github.com/rust-lang/crates.io-index"
1925+checksum = "b6b1fb24d2a4af0aa7438e2771d60c14a80cf2c9bd55c29cf1712b841f05bb8a"
1926+dependencies = [
1927+ "bstr",
1928+ "gix-glob",
1929+ "gix-path",
1930+ "gix-trace",
1931+ "unicode-bom",
1932+]
1933+
1934+[[package]]
1935+name = "gix-index"
1936+version = "0.37.0"
1937+source = "registry+https://github.com/rust-lang/crates.io-index"
1938+checksum = "270645fd20556b64c8ffa1540d921b281e6994413a0ca068596f97e9367a257a"
1939+dependencies = [
1940+ "bitflags 2.13.1",
1941+ "bstr",
1942+ "filetime",
1943+ "fnv",
1944+ "gix-bitmap",
1945+ "gix-features",
1946+ "gix-fs",
1947+ "gix-hash",
1948+ "gix-lock",
1949+ "gix-object",
1950+ "gix-traverse",
1951+ "gix-utils",
1952+ "gix-validate 0.9.4",
1953+ "hashbrown 0.14.5",
1954+ "itoa",
1955+ "libc",
1956+ "memmap2",
1957+ "rustix 0.38.44",
1958+ "smallvec",
1959+ "thiserror 2.0.19",
1960+]
1961+
1962+[[package]]
1963+name = "gix-lock"
1964+version = "15.0.1"
1965+source = "registry+https://github.com/rust-lang/crates.io-index"
1966+checksum = "1cd3ab68a452db63d9f3ebdacb10f30dba1fa0d31ac64f4203d395ed1102d940"
1967+dependencies = [
1968+ "gix-tempfile",
1969+ "gix-utils",
1970+ "thiserror 2.0.19",
1971+]
1972+
1973+[[package]]
1974+name = "gix-merge"
1975+version = "0.1.0"
1976+source = "registry+https://github.com/rust-lang/crates.io-index"
1977+checksum = "08d4d0d11ac3fc1a3082f88b5e4cca706512f6b813154c4541264beed7850e62"
1978+dependencies = [
1979+ "bstr",
1980+ "gix-command",
1981+ "gix-diff",
1982+ "gix-filter",
1983+ "gix-fs",
1984+ "gix-hash",
1985+ "gix-index",
1986+ "gix-object",
1987+ "gix-path",
1988+ "gix-quote",
1989+ "gix-revision",
1990+ "gix-revwalk",
1991+ "gix-tempfile",
1992+ "gix-trace",
1993+ "gix-worktree",
1994+ "imara-diff",
1995+ "thiserror 2.0.19",
1996+]
1997+
1998+[[package]]
1999+name = "gix-object"
2000+version = "0.46.1"
2001+source = "registry+https://github.com/rust-lang/crates.io-index"
2002+checksum = "e42d58010183ef033f31088479b4eb92b44fe341b35b62d39eb8b185573d77ea"
2003+dependencies = [
2004+ "bstr",
2005+ "gix-actor",
2006+ "gix-date",
2007+ "gix-features",
2008+ "gix-hash",
2009+ "gix-hashtable",
2010+ "gix-path",
2011+ "gix-utils",
2012+ "gix-validate 0.9.4",
2013+ "itoa",
2014+ "smallvec",
2015+ "thiserror 2.0.19",
2016+ "winnow",
2017+]
2018+
2019+[[package]]
2020+name = "gix-odb"
2021+version = "0.65.0"
2022+source = "registry+https://github.com/rust-lang/crates.io-index"
2023+checksum = "93bed6e1b577c25a6bb8e6ecbf4df525f29a671ddf5f2221821a56a8dbeec4e3"
2024+dependencies = [
2025+ "arc-swap",
2026+ "gix-date",
2027+ "gix-features",
2028+ "gix-fs",
2029+ "gix-hash",
2030+ "gix-hashtable",
2031+ "gix-object",
2032+ "gix-pack",
2033+ "gix-path",
2034+ "gix-quote",
2035+ "parking_lot",
2036+ "tempfile",
2037+ "thiserror 2.0.19",
2038+]
2039+
2040+[[package]]
2041+name = "gix-pack"
2042+version = "0.55.0"
2043+source = "registry+https://github.com/rust-lang/crates.io-index"
2044+checksum = "9b91fec04d359544fecbb8e85117ec746fbaa9046ebafcefb58cb74f20dc76d4"
2045+dependencies = [
2046+ "clru",
2047+ "gix-chunk",
2048+ "gix-features",
2049+ "gix-hash",
2050+ "gix-hashtable",
2051+ "gix-object",
2052+ "gix-path",
2053+ "memmap2",
2054+ "smallvec",
2055+ "thiserror 2.0.19",
2056+ "uluru",
2057+]
2058+
2059+[[package]]
2060+name = "gix-packetline-blocking"
2061+version = "0.18.3"
2062+source = "registry+https://github.com/rust-lang/crates.io-index"
2063+checksum = "1ecf3ea2e105c7e45587bac04099824301262a6c43357fad5205da36dbb233b3"
2064+dependencies = [
2065+ "bstr",
2066+ "faster-hex",
2067+ "gix-trace",
2068+ "thiserror 2.0.19",
2069+]
2070+
2071+[[package]]
2072+name = "gix-path"
2073+version = "0.10.22"
2074+source = "registry+https://github.com/rust-lang/crates.io-index"
2075+checksum = "7cb06c3e4f8eed6e24fd915fa93145e28a511f4ea0e768bae16673e05ed3f366"
2076+dependencies = [
2077+ "bstr",
2078+ "gix-trace",
2079+ "gix-validate 0.10.1",
2080+ "thiserror 2.0.19",
2081+]
2082+
2083+[[package]]
2084+name = "gix-pathspec"
2085+version = "0.8.1"
2086+source = "registry+https://github.com/rust-lang/crates.io-index"
2087+checksum = "4c472dfbe4a4e96fcf7efddcd4771c9037bb4fdea2faaabf2f4888210c75b81e"
2088+dependencies = [
2089+ "bitflags 2.13.1",
2090+ "bstr",
2091+ "gix-attributes",
2092+ "gix-config-value",
2093+ "gix-glob",
2094+ "gix-path",
2095+ "thiserror 2.0.19",
2096+]
2097+
2098+[[package]]
2099+name = "gix-quote"
2100+version = "0.4.15"
2101+source = "registry+https://github.com/rust-lang/crates.io-index"
2102+checksum = "e49357fccdb0c85c0d3a3292a9f6db32d9b3535959b5471bb9624908f4a066c6"
2103+dependencies = [
2104+ "bstr",
2105+ "gix-utils",
2106+ "thiserror 2.0.19",
2107+]
2108+
2109+[[package]]
2110+name = "gix-ref"
2111+version = "0.49.1"
2112+source = "registry+https://github.com/rust-lang/crates.io-index"
2113+checksum = "a91b61776c839d0f1b7114901179afb0947aa7f4d30793ca1c56d335dfef485f"
2114+dependencies = [
2115+ "gix-actor",
2116+ "gix-features",
2117+ "gix-fs",
2118+ "gix-hash",
2119+ "gix-lock",
2120+ "gix-object",
2121+ "gix-path",
2122+ "gix-tempfile",
2123+ "gix-utils",
2124+ "gix-validate 0.9.4",
2125+ "memmap2",
2126+ "thiserror 2.0.19",
2127+ "winnow",
2128+]
2129+
2130+[[package]]
2131+name = "gix-refspec"
2132+version = "0.27.0"
2133+source = "registry+https://github.com/rust-lang/crates.io-index"
2134+checksum = "00c056bb747868c7eb0aeb352c9f9181ab8ca3d0a2550f16470803500c6c413d"
2135+dependencies = [
2136+ "bstr",
2137+ "gix-hash",
2138+ "gix-revision",
2139+ "gix-validate 0.9.4",
2140+ "smallvec",
2141+ "thiserror 2.0.19",
2142+]
2143+
2144+[[package]]
2145+name = "gix-revision"
2146+version = "0.31.1"
2147+source = "registry+https://github.com/rust-lang/crates.io-index"
2148+checksum = "61e1ddc474405a68d2ce8485705dd72fe6ce959f2f5fe718601ead5da2c8f9e7"
2149+dependencies = [
2150+ "bitflags 2.13.1",
2151+ "bstr",
2152+ "gix-commitgraph",
2153+ "gix-date",
2154+ "gix-hash",
2155+ "gix-hashtable",
2156+ "gix-object",
2157+ "gix-revwalk",
2158+ "gix-trace",
2159+ "thiserror 2.0.19",
2160+]
2161+
2162+[[package]]
2163+name = "gix-revwalk"
2164+version = "0.17.0"
2165+source = "registry+https://github.com/rust-lang/crates.io-index"
2166+checksum = "510026fc32f456f8f067d8f37c34088b97a36b2229d88a6a5023ef179fcb109d"
2167+dependencies = [
2168+ "gix-commitgraph",
2169+ "gix-date",
2170+ "gix-hash",
2171+ "gix-hashtable",
2172+ "gix-object",
2173+ "smallvec",
2174+ "thiserror 2.0.19",
2175+]
2176+
2177+[[package]]
2178+name = "gix-sec"
2179+version = "0.10.12"
2180+source = "registry+https://github.com/rust-lang/crates.io-index"
2181+checksum = "47aeb0f13de9ef2f3033f5ff218de30f44db827ac9f1286f9ef050aacddd5888"
2182+dependencies = [
2183+ "bitflags 2.13.1",
2184+ "gix-path",
2185+ "libc",
2186+ "windows-sys 0.52.0",
2187+]
2188+
2189+[[package]]
2190+name = "gix-submodule"
2191+version = "0.16.0"
2192+source = "registry+https://github.com/rust-lang/crates.io-index"
2193+checksum = "a2455f8c0fcb6ebe2a6e83c8f522d30615d763eb2ef7a23c7d929f9476e89f5c"
2194+dependencies = [
2195+ "bstr",
2196+ "gix-config",
2197+ "gix-path",
2198+ "gix-pathspec",
2199+ "gix-refspec",
2200+ "gix-url",
2201+ "thiserror 2.0.19",
2202+]
2203+
2204+[[package]]
2205+name = "gix-tempfile"
2206+version = "15.0.0"
2207+source = "registry+https://github.com/rust-lang/crates.io-index"
2208+checksum = "2feb86ef094cc77a4a9a5afbfe5de626897351bbbd0de3cb9314baf3049adb82"
2209+dependencies = [
2210+ "dashmap",
2211+ "gix-fs",
2212+ "libc",
2213+ "once_cell",
2214+ "parking_lot",
2215+ "tempfile",
2216+]
2217+
2218+[[package]]
2219+name = "gix-trace"
2220+version = "0.1.21"
2221+source = "registry+https://github.com/rust-lang/crates.io-index"
2222+checksum = "be3eb81d9dc914335923e50d52829c551feefd6a72d176c4130c546b67a60814"
2223+
2224+[[package]]
2225+name = "gix-traverse"
2226+version = "0.43.1"
2227+source = "registry+https://github.com/rust-lang/crates.io-index"
2228+checksum = "6ed47d648619e23e93f971d2bba0d10c1100e54ef95d2981d609907a8cabac89"
2229+dependencies = [
2230+ "bitflags 2.13.1",
2231+ "gix-commitgraph",
2232+ "gix-date",
2233+ "gix-hash",
2234+ "gix-hashtable",
2235+ "gix-object",
2236+ "gix-revwalk",
2237+ "smallvec",
2238+ "thiserror 2.0.19",
2239+]
2240+
2241+[[package]]
2242+name = "gix-url"
2243+version = "0.28.2"
2244+source = "registry+https://github.com/rust-lang/crates.io-index"
2245+checksum = "d096fb733ba6bd3f5403dba8bd72bdd8809fe2b347b57844040b8f49c93492d9"
2246+dependencies = [
2247+ "bstr",
2248+ "gix-features",
2249+ "gix-path",
2250+ "percent-encoding",
2251+ "thiserror 2.0.19",
2252+ "url",
2253+]
2254+
2255+[[package]]
2256+name = "gix-utils"
2257+version = "0.1.14"
2258+source = "registry+https://github.com/rust-lang/crates.io-index"
2259+checksum = "ff08f24e03ac8916c478c8419d7d3c33393da9bb41fa4c24455d5406aeefd35f"
2260+dependencies = [
2261+ "fastrand",
2262+ "unicode-normalization",
2263+]
2264+
2265+[[package]]
2266+name = "gix-validate"
2267+version = "0.9.4"
2268+source = "registry+https://github.com/rust-lang/crates.io-index"
2269+checksum = "34b5f1253109da6c79ed7cf6e1e38437080bb6d704c76af14c93e2f255234084"
2270+dependencies = [
2271+ "bstr",
2272+ "thiserror 2.0.19",
2273+]
2274+
2275+[[package]]
2276+name = "gix-validate"
2277+version = "0.10.1"
2278+source = "registry+https://github.com/rust-lang/crates.io-index"
2279+checksum = "5b1e63a5b516e970a594f870ed4571a8fdcb8a344e7bd407a20db8bd61dbfde4"
2280+dependencies = [
2281+ "bstr",
2282+ "thiserror 2.0.19",
2283+]
2284+
2285+[[package]]
2286+name = "gix-worktree"
2287+version = "0.38.0"
2288+source = "registry+https://github.com/rust-lang/crates.io-index"
2289+checksum = "756dbbe15188fa22540d5eab941f8f9cf511a5364d5aec34c88083c09f4bea13"
2290+dependencies = [
2291+ "bstr",
2292+ "gix-attributes",
2293+ "gix-features",
2294+ "gix-fs",
2295+ "gix-glob",
2296+ "gix-hash",
2297+ "gix-ignore",
2298+ "gix-index",
2299+ "gix-object",
2300+ "gix-path",
2301+ "gix-validate 0.9.4",
2302+]
2303+
2304+[[package]]
2305+name = "group"
2306+version = "0.13.0"
2307+source = "registry+https://github.com/rust-lang/crates.io-index"
2308+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
2309+dependencies = [
2310+ "ff",
2311+ "rand_core 0.6.4",
2312+ "subtle",
2313+]
2314+
2315+[[package]]
2316+name = "h2"
2317+version = "0.4.15"
2318+source = "registry+https://github.com/rust-lang/crates.io-index"
2319+checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
2320+dependencies = [
2321+ "atomic-waker",
2322+ "bytes",
2323+ "fnv",
2324+ "futures-core",
2325+ "futures-sink",
2326+ "http",
2327+ "indexmap 2.14.0",
2328+ "slab",
2329+ "tokio",
2330+ "tokio-util",
2331+ "tracing",
2332+]
2333+
2334+[[package]]
2335+name = "hashbrown"
2336+version = "0.12.3"
2337+source = "registry+https://github.com/rust-lang/crates.io-index"
2338+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
2339+
2340+[[package]]
2341+name = "hashbrown"
2342+version = "0.14.5"
2343+source = "registry+https://github.com/rust-lang/crates.io-index"
2344+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
2345+dependencies = [
2346+ "ahash",
2347+ "allocator-api2",
2348+]
2349+
2350+[[package]]
2351+name = "hashbrown"
2352+version = "0.15.5"
2353+source = "registry+https://github.com/rust-lang/crates.io-index"
2354+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
2355+dependencies = [
2356+ "allocator-api2",
2357+ "equivalent",
2358+ "foldhash 0.1.5",
2359+]
2360+
2361+[[package]]
2362+name = "hashbrown"
2363+version = "0.16.1"
2364+source = "registry+https://github.com/rust-lang/crates.io-index"
2365+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
2366+dependencies = [
2367+ "allocator-api2",
2368+ "equivalent",
2369+ "foldhash 0.2.0",
2370+]
2371+
2372+[[package]]
2373+name = "hashbrown"
2374+version = "0.17.1"
2375+source = "registry+https://github.com/rust-lang/crates.io-index"
2376+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
2377+
2378+[[package]]
2379+name = "hashlink"
2380+version = "0.10.0"
2381+source = "registry+https://github.com/rust-lang/crates.io-index"
2382+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
2383+dependencies = [
2384+ "hashbrown 0.15.5",
2385+]
2386+
2387+[[package]]
2388+name = "headers"
2389+version = "0.4.1"
2390+source = "registry+https://github.com/rust-lang/crates.io-index"
2391+checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
2392+dependencies = [
2393+ "base64 0.22.1",
2394+ "bytes",
2395+ "headers-core",
2396+ "http",
2397+ "httpdate",
2398+ "mime",
2399+ "sha1",
2400+]
2401+
2402+[[package]]
2403+name = "headers-core"
2404+version = "0.3.0"
2405+source = "registry+https://github.com/rust-lang/crates.io-index"
2406+checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
2407+dependencies = [
2408+ "http",
2409+]
2410+
2411+[[package]]
2412+name = "heck"
2413+version = "0.5.0"
2414+source = "registry+https://github.com/rust-lang/crates.io-index"
2415+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
2416+
2417+[[package]]
2418+name = "hex"
2419+version = "0.4.3"
2420+source = "registry+https://github.com/rust-lang/crates.io-index"
2421+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
2422+
2423+[[package]]
2424+name = "hex-literal"
2425+version = "0.4.1"
2426+source = "registry+https://github.com/rust-lang/crates.io-index"
2427+checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46"
2428+
2429+[[package]]
2430+name = "hkdf"
2431+version = "0.12.4"
2432+source = "registry+https://github.com/rust-lang/crates.io-index"
2433+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
2434+dependencies = [
2435+ "hmac",
2436+]
2437+
2438+[[package]]
2439+name = "hmac"
2440+version = "0.12.1"
2441+source = "registry+https://github.com/rust-lang/crates.io-index"
2442+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
2443+dependencies = [
2444+ "digest",
2445+]
2446+
2447+[[package]]
2448+name = "home"
2449+version = "0.5.12"
2450+source = "registry+https://github.com/rust-lang/crates.io-index"
2451+checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
2452+dependencies = [
2453+ "windows-sys 0.61.2",
2454+]
2455+
2456+[[package]]
2457+name = "html5ever"
2458+version = "0.39.0"
2459+source = "registry+https://github.com/rust-lang/crates.io-index"
2460+checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
2461+dependencies = [
2462+ "log",
2463+ "markup5ever",
2464+]
2465+
2466+[[package]]
2467+name = "http"
2468+version = "1.5.0"
2469+source = "registry+https://github.com/rust-lang/crates.io-index"
2470+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
2471+dependencies = [
2472+ "bytes",
2473+ "itoa",
2474+]
2475+
2476+[[package]]
2477+name = "http-body"
2478+version = "1.1.0"
2479+source = "registry+https://github.com/rust-lang/crates.io-index"
2480+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
2481+dependencies = [
2482+ "bytes",
2483+ "http",
2484+]
2485+
2486+[[package]]
2487+name = "http-body-util"
2488+version = "0.1.4"
2489+source = "registry+https://github.com/rust-lang/crates.io-index"
2490+checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
2491+dependencies = [
2492+ "bytes",
2493+ "futures-core",
2494+ "http",
2495+ "http-body",
2496+ "pin-project-lite",
2497+]
2498+
2499+[[package]]
2500+name = "httparse"
2501+version = "1.10.1"
2502+source = "registry+https://github.com/rust-lang/crates.io-index"
2503+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
2504+
2505+[[package]]
2506+name = "httpdate"
2507+version = "1.0.3"
2508+source = "registry+https://github.com/rust-lang/crates.io-index"
2509+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
2510+
2511+[[package]]
2512+name = "hyper"
2513+version = "1.11.0"
2514+source = "registry+https://github.com/rust-lang/crates.io-index"
2515+checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
2516+dependencies = [
2517+ "atomic-waker",
2518+ "bytes",
2519+ "futures-channel",
2520+ "futures-core",
2521+ "h2",
2522+ "http",
2523+ "http-body",
2524+ "httparse",
2525+ "httpdate",
2526+ "itoa",
2527+ "pin-project-lite",
2528+ "smallvec",
2529+ "tokio",
2530+ "want",
2531+]
2532+
2533+[[package]]
2534+name = "hyper-rustls"
2535+version = "0.27.9"
2536+source = "registry+https://github.com/rust-lang/crates.io-index"
2537+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
2538+dependencies = [
2539+ "http",
2540+ "hyper",
2541+ "hyper-util",
2542+ "rustls",
2543+ "tokio",
2544+ "tokio-rustls",
2545+ "tower-service",
2546+ "webpki-roots 1.0.9",
2547+]
2548+
2549+[[package]]
2550+name = "hyper-util"
2551+version = "0.1.20"
2552+source = "registry+https://github.com/rust-lang/crates.io-index"
2553+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
2554+dependencies = [
2555+ "base64 0.22.1",
2556+ "bytes",
2557+ "futures-channel",
2558+ "futures-util",
2559+ "http",
2560+ "http-body",
2561+ "hyper",
2562+ "ipnet",
2563+ "libc",
2564+ "percent-encoding",
2565+ "pin-project-lite",
2566+ "socket2",
2567+ "tokio",
2568+ "tower-service",
2569+ "tracing",
2570+]
2571+
2572+[[package]]
2573+name = "iana-time-zone"
2574+version = "0.1.65"
2575+source = "registry+https://github.com/rust-lang/crates.io-index"
2576+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
2577+dependencies = [
2578+ "android_system_properties",
2579+ "core-foundation-sys",
2580+ "iana-time-zone-haiku",
2581+ "js-sys",
2582+ "log",
2583+ "wasm-bindgen",
2584+ "windows-core 0.62.2",
2585+]
2586+
2587+[[package]]
2588+name = "iana-time-zone-haiku"
2589+version = "0.1.2"
2590+source = "registry+https://github.com/rust-lang/crates.io-index"
2591+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
2592+dependencies = [
2593+ "cc",
2594+]
2595+
2596+[[package]]
2597+name = "icu_collections"
2598+version = "2.2.0"
2599+source = "registry+https://github.com/rust-lang/crates.io-index"
2600+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
2601+dependencies = [
2602+ "displaydoc",
2603+ "potential_utf",
2604+ "utf8_iter",
2605+ "yoke",
2606+ "zerofrom",
2607+ "zerovec",
2608+]
2609+
2610+[[package]]
2611+name = "icu_locale_core"
2612+version = "2.2.0"
2613+source = "registry+https://github.com/rust-lang/crates.io-index"
2614+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
2615+dependencies = [
2616+ "displaydoc",
2617+ "litemap",
2618+ "tinystr",
2619+ "writeable",
2620+ "zerovec",
2621+]
2622+
2623+[[package]]
2624+name = "icu_normalizer"
2625+version = "2.2.0"
2626+source = "registry+https://github.com/rust-lang/crates.io-index"
2627+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
2628+dependencies = [
2629+ "icu_collections",
2630+ "icu_normalizer_data",
2631+ "icu_properties",
2632+ "icu_provider",
2633+ "smallvec",
2634+ "zerovec",
2635+]
2636+
2637+[[package]]
2638+name = "icu_normalizer_data"
2639+version = "2.2.0"
2640+source = "registry+https://github.com/rust-lang/crates.io-index"
2641+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
2642+
2643+[[package]]
2644+name = "icu_properties"
2645+version = "2.2.0"
2646+source = "registry+https://github.com/rust-lang/crates.io-index"
2647+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
2648+dependencies = [
2649+ "icu_collections",
2650+ "icu_locale_core",
2651+ "icu_properties_data",
2652+ "icu_provider",
2653+ "zerotrie",
2654+ "zerovec",
2655+]
2656+
2657+[[package]]
2658+name = "icu_properties_data"
2659+version = "2.2.0"
2660+source = "registry+https://github.com/rust-lang/crates.io-index"
2661+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
2662+
2663+[[package]]
2664+name = "icu_provider"
2665+version = "2.2.0"
2666+source = "registry+https://github.com/rust-lang/crates.io-index"
2667+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
2668+dependencies = [
2669+ "displaydoc",
2670+ "icu_locale_core",
2671+ "writeable",
2672+ "yoke",
2673+ "zerofrom",
2674+ "zerotrie",
2675+ "zerovec",
2676+]
2677+
2678+[[package]]
2679+name = "ident_case"
2680+version = "1.0.1"
2681+source = "registry+https://github.com/rust-lang/crates.io-index"
2682+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
2683+
2684+[[package]]
2685+name = "idna"
2686+version = "1.1.0"
2687+source = "registry+https://github.com/rust-lang/crates.io-index"
2688+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
2689+dependencies = [
2690+ "idna_adapter",
2691+ "smallvec",
2692+ "utf8_iter",
2693+]
2694+
2695+[[package]]
2696+name = "idna_adapter"
2697+version = "1.2.2"
2698+source = "registry+https://github.com/rust-lang/crates.io-index"
2699+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
2700+dependencies = [
2701+ "icu_normalizer",
2702+ "icu_properties",
2703+]
2704+
2705+[[package]]
2706+name = "imara-diff"
2707+version = "0.1.8"
2708+source = "registry+https://github.com/rust-lang/crates.io-index"
2709+checksum = "17d34b7d42178945f775e84bc4c36dde7c1c6cdfea656d3354d009056f2bb3d2"
2710+dependencies = [
2711+ "hashbrown 0.15.5",
2712+]
2713+
2714+[[package]]
2715+name = "indexmap"
2716+version = "1.9.3"
2717+source = "registry+https://github.com/rust-lang/crates.io-index"
2718+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
2719+dependencies = [
2720+ "autocfg",
2721+ "hashbrown 0.12.3",
2722+ "serde",
2723+]
2724+
2725+[[package]]
2726+name = "indexmap"
2727+version = "2.14.0"
2728+source = "registry+https://github.com/rust-lang/crates.io-index"
2729+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
2730+dependencies = [
2731+ "equivalent",
2732+ "hashbrown 0.17.1",
2733+ "serde",
2734+ "serde_core",
2735+]
2736+
2737+[[package]]
2738+name = "inout"
2739+version = "0.1.4"
2740+source = "registry+https://github.com/rust-lang/crates.io-index"
2741+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
2742+dependencies = [
2743+ "block-padding",
2744+ "generic-array",
2745+]
2746+
2747+[[package]]
2748+name = "internal-russh-forked-ssh-key"
2749+version = "0.6.10+upstream-0.6.7"
2750+source = "registry+https://github.com/rust-lang/crates.io-index"
2751+checksum = "33555bd765ace379fe85d97bb6d48b5783054f6048a7d5ec24cd9155e490e266"
2752+dependencies = [
2753+ "argon2",
2754+ "bcrypt-pbkdf",
2755+ "ecdsa",
2756+ "ed25519-dalek",
2757+ "hex",
2758+ "hmac",
2759+ "num-bigint-dig",
2760+ "p256",
2761+ "p384",
2762+ "p521",
2763+ "rand_core 0.6.4",
2764+ "rsa",
2765+ "sec1",
2766+ "sha1",
2767+ "sha2",
2768+ "signature",
2769+ "ssh-cipher",
2770+ "ssh-encoding",
2771+ "subtle",
2772+ "zeroize",
2773+]
2774+
2775+[[package]]
2776+name = "ipnet"
2777+version = "2.12.0"
2778+source = "registry+https://github.com/rust-lang/crates.io-index"
2779+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
2780+
2781+[[package]]
2782+name = "ipnetwork"
2783+version = "0.20.0"
2784+source = "registry+https://github.com/rust-lang/crates.io-index"
2785+checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e"
2786+dependencies = [
2787+ "serde",
2788+]
2789+
2790+[[package]]
2791+name = "is_terminal_polyfill"
2792+version = "1.70.2"
2793+source = "registry+https://github.com/rust-lang/crates.io-index"
2794+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
2795+
2796+[[package]]
2797+name = "itertools"
2798+version = "0.10.5"
2799+source = "registry+https://github.com/rust-lang/crates.io-index"
2800+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
2801+dependencies = [
2802+ "either",
2803+]
2804+
2805+[[package]]
2806+name = "itoa"
2807+version = "1.0.18"
2808+source = "registry+https://github.com/rust-lang/crates.io-index"
2809+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
2810+
2811+[[package]]
2812+name = "jiff"
2813+version = "0.2.35"
2814+source = "registry+https://github.com/rust-lang/crates.io-index"
2815+checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
2816+dependencies = [
2817+ "defmt",
2818+ "jiff-core",
2819+ "jiff-static",
2820+ "jiff-tzdb-platform",
2821+ "log",
2822+ "portable-atomic",
2823+ "portable-atomic-util",
2824+ "serde_core",
2825+ "windows-link",
2826+]
2827+
2828+[[package]]
2829+name = "jiff-core"
2830+version = "0.1.0"
2831+source = "registry+https://github.com/rust-lang/crates.io-index"
2832+checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
2833+dependencies = [
2834+ "defmt",
2835+]
2836+
2837+[[package]]
2838+name = "jiff-static"
2839+version = "0.2.35"
2840+source = "registry+https://github.com/rust-lang/crates.io-index"
2841+checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
2842+dependencies = [
2843+ "jiff-core",
2844+ "proc-macro2",
2845+ "quote",
2846+ "syn 2.0.119",
2847+]
2848+
2849+[[package]]
2850+name = "jiff-tzdb"
2851+version = "0.1.8"
2852+source = "registry+https://github.com/rust-lang/crates.io-index"
2853+checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
2854+
2855+[[package]]
2856+name = "jiff-tzdb-platform"
2857+version = "0.1.3"
2858+source = "registry+https://github.com/rust-lang/crates.io-index"
2859+checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
2860+dependencies = [
2861+ "jiff-tzdb",
2862+]
2863+
2864+[[package]]
2865+name = "jobserver"
2866+version = "0.1.35"
2867+source = "registry+https://github.com/rust-lang/crates.io-index"
2868+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
2869+dependencies = [
2870+ "getrandom 0.4.3",
2871+ "libc",
2872+]
2873+
2874+[[package]]
2875+name = "js-sys"
2876+version = "0.3.103"
2877+source = "registry+https://github.com/rust-lang/crates.io-index"
2878+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
2879+dependencies = [
2880+ "cfg-if",
2881+ "futures-util",
2882+ "wasm-bindgen",
2883+]
2884+
2885+[[package]]
2886+name = "kstring"
2887+version = "2.0.4"
2888+source = "registry+https://github.com/rust-lang/crates.io-index"
2889+checksum = "b609e7ca5ea38f093c20a4a102335b247221c9643b7a6bc3510f196f99499a9e"
2890+dependencies = [
2891+ "static_assertions",
2892+]
2893+
2894+[[package]]
2895+name = "lazy_static"
2896+version = "1.5.0"
2897+source = "registry+https://github.com/rust-lang/crates.io-index"
2898+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
2899+dependencies = [
2900+ "spin",
2901+]
2902+
2903+[[package]]
2904+name = "libc"
2905+version = "0.2.189"
2906+source = "registry+https://github.com/rust-lang/crates.io-index"
2907+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
2908+
2909+[[package]]
2910+name = "libm"
2911+version = "0.2.16"
2912+source = "registry+https://github.com/rust-lang/crates.io-index"
2913+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
2914+
2915+[[package]]
2916+name = "libredox"
2917+version = "0.1.18"
2918+source = "registry+https://github.com/rust-lang/crates.io-index"
2919+checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
2920+dependencies = [
2921+ "bitflags 2.13.1",
2922+ "libc",
2923+ "plain",
2924+ "redox_syscall 0.9.1",
2925+]
2926+
2927+[[package]]
2928+name = "libsqlite3-sys"
2929+version = "0.30.1"
2930+source = "registry+https://github.com/rust-lang/crates.io-index"
2931+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
2932+dependencies = [
2933+ "pkg-config",
2934+ "vcpkg",
2935+]
2936+
2937+[[package]]
2938+name = "linked-hash-map"
2939+version = "0.5.6"
2940+source = "registry+https://github.com/rust-lang/crates.io-index"
2941+checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
2942+
2943+[[package]]
2944+name = "linux-raw-sys"
2945+version = "0.4.15"
2946+source = "registry+https://github.com/rust-lang/crates.io-index"
2947+checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
2948+
2949+[[package]]
2950+name = "linux-raw-sys"
2951+version = "0.12.1"
2952+source = "registry+https://github.com/rust-lang/crates.io-index"
2953+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
2954+
2955+[[package]]
2956+name = "litemap"
2957+version = "0.8.2"
2958+source = "registry+https://github.com/rust-lang/crates.io-index"
2959+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
2960+
2961+[[package]]
2962+name = "lock_api"
2963+version = "0.4.14"
2964+source = "registry+https://github.com/rust-lang/crates.io-index"
2965+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
2966+dependencies = [
2967+ "scopeguard",
2968+]
2969+
2970+[[package]]
2971+name = "log"
2972+version = "0.4.33"
2973+source = "registry+https://github.com/rust-lang/crates.io-index"
2974+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
2975+
2976+[[package]]
2977+name = "lru"
2978+version = "0.12.5"
2979+source = "registry+https://github.com/rust-lang/crates.io-index"
2980+checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
2981+dependencies = [
2982+ "hashbrown 0.15.5",
2983+]
2984+
2985+[[package]]
2986+name = "lru-slab"
2987+version = "0.1.2"
2988+source = "registry+https://github.com/rust-lang/crates.io-index"
2989+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
2990+
2991+[[package]]
2992+name = "maplit"
2993+version = "1.0.2"
2994+source = "registry+https://github.com/rust-lang/crates.io-index"
2995+checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
2996+
2997+[[package]]
2998+name = "markup5ever"
2999+version = "0.39.0"
3000+source = "registry+https://github.com/rust-lang/crates.io-index"
3001+checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
3002+dependencies = [
3003+ "log",
3004+ "tendril",
3005+ "web_atoms",
3006+]
3007+
3008+[[package]]
3009+name = "matchers"
3010+version = "0.2.0"
3011+source = "registry+https://github.com/rust-lang/crates.io-index"
3012+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
3013+dependencies = [
3014+ "regex-automata",
3015+]
3016+
3017+[[package]]
3018+name = "matchit"
3019+version = "0.8.4"
3020+source = "registry+https://github.com/rust-lang/crates.io-index"
3021+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
3022+
3023+[[package]]
3024+name = "maud"
3025+version = "0.27.0"
3026+source = "registry+https://github.com/rust-lang/crates.io-index"
3027+checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e"
3028+dependencies = [
3029+ "axum-core",
3030+ "http",
3031+ "itoa",
3032+ "maud_macros",
3033+]
3034+
3035+[[package]]
3036+name = "maud_macros"
3037+version = "0.27.0"
3038+source = "registry+https://github.com/rust-lang/crates.io-index"
3039+checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca"
3040+dependencies = [
3041+ "proc-macro2",
3042+ "proc-macro2-diagnostics",
3043+ "quote",
3044+ "syn 2.0.119",
3045+]
3046+
3047+[[package]]
3048+name = "md-5"
3049+version = "0.10.6"
3050+source = "registry+https://github.com/rust-lang/crates.io-index"
3051+checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
3052+dependencies = [
3053+ "cfg-if",
3054+ "digest",
3055+]
3056+
3057+[[package]]
3058+name = "md5"
3059+version = "0.7.0"
3060+source = "registry+https://github.com/rust-lang/crates.io-index"
3061+checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
3062+
3063+[[package]]
3064+name = "memchr"
3065+version = "2.8.3"
3066+source = "registry+https://github.com/rust-lang/crates.io-index"
3067+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
3068+
3069+[[package]]
3070+name = "memmap2"
3071+version = "0.9.11"
3072+source = "registry+https://github.com/rust-lang/crates.io-index"
3073+checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
3074+dependencies = [
3075+ "libc",
3076+]
3077+
3078+[[package]]
3079+name = "mime"
3080+version = "0.3.17"
3081+source = "registry+https://github.com/rust-lang/crates.io-index"
3082+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
3083+
3084+[[package]]
3085+name = "miniz_oxide"
3086+version = "0.8.9"
3087+source = "registry+https://github.com/rust-lang/crates.io-index"
3088+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
3089+dependencies = [
3090+ "adler2",
3091+ "simd-adler32",
3092+]
3093+
3094+[[package]]
3095+name = "mio"
3096+version = "1.2.2"
3097+source = "registry+https://github.com/rust-lang/crates.io-index"
3098+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
3099+dependencies = [
3100+ "libc",
3101+ "wasi",
3102+ "windows-sys 0.61.2",
3103+]
3104+
3105+[[package]]
3106+name = "new_debug_unreachable"
3107+version = "1.0.6"
3108+source = "registry+https://github.com/rust-lang/crates.io-index"
3109+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
3110+
3111+[[package]]
3112+name = "nix"
3113+version = "0.29.0"
3114+source = "registry+https://github.com/rust-lang/crates.io-index"
3115+checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
3116+dependencies = [
3117+ "bitflags 2.13.1",
3118+ "cfg-if",
3119+ "cfg_aliases",
3120+ "libc",
3121+]
3122+
3123+[[package]]
3124+name = "nu-ansi-term"
3125+version = "0.50.3"
3126+source = "registry+https://github.com/rust-lang/crates.io-index"
3127+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
3128+dependencies = [
3129+ "windows-sys 0.61.2",
3130+]
3131+
3132+[[package]]
3133+name = "num-bigint"
3134+version = "0.4.8"
3135+source = "registry+https://github.com/rust-lang/crates.io-index"
3136+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
3137+dependencies = [
3138+ "num-integer",
3139+ "num-traits",
3140+ "rand 0.8.7",
3141+]
3142+
3143+[[package]]
3144+name = "num-bigint-dig"
3145+version = "0.8.6"
3146+source = "registry+https://github.com/rust-lang/crates.io-index"
3147+checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
3148+dependencies = [
3149+ "lazy_static",
3150+ "libm",
3151+ "num-integer",
3152+ "num-iter",
3153+ "num-traits",
3154+ "rand 0.8.7",
3155+ "smallvec",
3156+ "zeroize",
3157+]
3158+
3159+[[package]]
3160+name = "num-conv"
3161+version = "0.2.2"
3162+source = "registry+https://github.com/rust-lang/crates.io-index"
3163+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
3164+
3165+[[package]]
3166+name = "num-integer"
3167+version = "0.1.46"
3168+source = "registry+https://github.com/rust-lang/crates.io-index"
3169+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
3170+dependencies = [
3171+ "num-traits",
3172+]
3173+
3174+[[package]]
3175+name = "num-iter"
3176+version = "0.1.46"
3177+source = "registry+https://github.com/rust-lang/crates.io-index"
3178+checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
3179+dependencies = [
3180+ "num-integer",
3181+ "num-traits",
3182+]
3183+
3184+[[package]]
3185+name = "num-traits"
3186+version = "0.2.19"
3187+source = "registry+https://github.com/rust-lang/crates.io-index"
3188+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
3189+dependencies = [
3190+ "autocfg",
3191+ "libm",
3192+]
3193+
3194+[[package]]
3195+name = "oauth2"
3196+version = "5.0.0"
3197+source = "registry+https://github.com/rust-lang/crates.io-index"
3198+checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
3199+dependencies = [
3200+ "base64 0.22.1",
3201+ "chrono",
3202+ "getrandom 0.2.17",
3203+ "http",
3204+ "rand 0.8.7",
3205+ "reqwest",
3206+ "serde",
3207+ "serde_json",
3208+ "serde_path_to_error",
3209+ "sha2",
3210+ "thiserror 1.0.69",
3211+ "url",
3212+]
3213+
3214+[[package]]
3215+name = "once_cell"
3216+version = "1.21.4"
3217+source = "registry+https://github.com/rust-lang/crates.io-index"
3218+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
3219+
3220+[[package]]
3221+name = "once_cell_polyfill"
3222+version = "1.70.2"
3223+source = "registry+https://github.com/rust-lang/crates.io-index"
3224+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
3225+
3226+[[package]]
3227+name = "onig"
3228+version = "6.5.3"
3229+source = "registry+https://github.com/rust-lang/crates.io-index"
3230+checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2"
3231+dependencies = [
3232+ "bitflags 2.13.1",
3233+ "libc",
3234+ "once_cell",
3235+ "onig_sys",
3236+]
3237+
3238+[[package]]
3239+name = "onig_sys"
3240+version = "69.9.3"
3241+source = "registry+https://github.com/rust-lang/crates.io-index"
3242+checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7"
3243+dependencies = [
3244+ "cc",
3245+ "pkg-config",
3246+]
3247+
3248+[[package]]
3249+name = "opaque-debug"
3250+version = "0.3.1"
3251+source = "registry+https://github.com/rust-lang/crates.io-index"
3252+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
3253+
3254+[[package]]
3255+name = "openidconnect"
3256+version = "4.0.1"
3257+source = "registry+https://github.com/rust-lang/crates.io-index"
3258+checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2"
3259+dependencies = [
3260+ "base64 0.21.7",
3261+ "chrono",
3262+ "dyn-clone",
3263+ "ed25519-dalek",
3264+ "hmac",
3265+ "http",
3266+ "itertools",
3267+ "log",
3268+ "oauth2",
3269+ "p256",
3270+ "p384",
3271+ "rand 0.8.7",
3272+ "rsa",
3273+ "serde",
3274+ "serde-value",
3275+ "serde_json",
3276+ "serde_path_to_error",
3277+ "serde_plain",
3278+ "serde_with",
3279+ "sha2",
3280+ "subtle",
3281+ "thiserror 1.0.69",
3282+ "url",
3283+]
3284+
3285+[[package]]
3286+name = "ordered-float"
3287+version = "2.10.1"
3288+source = "registry+https://github.com/rust-lang/crates.io-index"
3289+checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
3290+dependencies = [
3291+ "num-traits",
3292+]
3293+
3294+[[package]]
3295+name = "p256"
3296+version = "0.13.2"
3297+source = "registry+https://github.com/rust-lang/crates.io-index"
3298+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
3299+dependencies = [
3300+ "ecdsa",
3301+ "elliptic-curve",
3302+ "primeorder",
3303+ "sha2",
3304+]
3305+
3306+[[package]]
3307+name = "p384"
3308+version = "0.13.1"
3309+source = "registry+https://github.com/rust-lang/crates.io-index"
3310+checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
3311+dependencies = [
3312+ "ecdsa",
3313+ "elliptic-curve",
3314+ "primeorder",
3315+ "sha2",
3316+]
3317+
3318+[[package]]
3319+name = "p521"
3320+version = "0.13.3"
3321+source = "registry+https://github.com/rust-lang/crates.io-index"
3322+checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2"
3323+dependencies = [
3324+ "base16ct",
3325+ "ecdsa",
3326+ "elliptic-curve",
3327+ "primeorder",
3328+ "rand_core 0.6.4",
3329+ "sha2",
3330+]
3331+
3332+[[package]]
3333+name = "pageant"
3334+version = "0.0.3"
3335+source = "registry+https://github.com/rust-lang/crates.io-index"
3336+checksum = "bdd27df01428302f915ea74737fe88170dd1bab4cbd00ff9548ca85618fcd4e4"
3337+dependencies = [
3338+ "bytes",
3339+ "delegate",
3340+ "futures",
3341+ "log",
3342+ "rand 0.8.7",
3343+ "thiserror 1.0.69",
3344+ "tokio",
3345+ "windows",
3346+]
3347+
3348+[[package]]
3349+name = "parking"
3350+version = "2.2.1"
3351+source = "registry+https://github.com/rust-lang/crates.io-index"
3352+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
3353+
3354+[[package]]
3355+name = "parking_lot"
3356+version = "0.12.5"
3357+source = "registry+https://github.com/rust-lang/crates.io-index"
3358+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
3359+dependencies = [
3360+ "lock_api",
3361+ "parking_lot_core",
3362+]
3363+
3364+[[package]]
3365+name = "parking_lot_core"
3366+version = "0.9.12"
3367+source = "registry+https://github.com/rust-lang/crates.io-index"
3368+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
3369+dependencies = [
3370+ "cfg-if",
3371+ "libc",
3372+ "redox_syscall 0.5.18",
3373+ "smallvec",
3374+ "windows-link",
3375+]
3376+
3377+[[package]]
3378+name = "password-hash"
3379+version = "0.5.0"
3380+source = "registry+https://github.com/rust-lang/crates.io-index"
3381+checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
3382+dependencies = [
3383+ "base64ct",
3384+ "rand_core 0.6.4",
3385+ "subtle",
3386+]
3387+
3388+[[package]]
3389+name = "pbkdf2"
3390+version = "0.12.2"
3391+source = "registry+https://github.com/rust-lang/crates.io-index"
3392+checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
3393+dependencies = [
3394+ "digest",
3395+ "hmac",
3396+]
3397+
3398+[[package]]
3399+name = "pem-rfc7468"
3400+version = "0.7.0"
3401+source = "registry+https://github.com/rust-lang/crates.io-index"
3402+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
3403+dependencies = [
3404+ "base64ct",
3405+]
3406+
3407+[[package]]
3408+name = "percent-encoding"
3409+version = "2.3.2"
3410+source = "registry+https://github.com/rust-lang/crates.io-index"
3411+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
3412+
3413+[[package]]
3414+name = "phf"
3415+version = "0.13.1"
3416+source = "registry+https://github.com/rust-lang/crates.io-index"
3417+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
3418+dependencies = [
3419+ "phf_shared",
3420+ "serde",
3421+]
3422+
3423+[[package]]
3424+name = "phf_codegen"
3425+version = "0.13.1"
3426+source = "registry+https://github.com/rust-lang/crates.io-index"
3427+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
3428+dependencies = [
3429+ "phf_generator",
3430+ "phf_shared",
3431+]
3432+
3433+[[package]]
3434+name = "phf_generator"
3435+version = "0.13.1"
3436+source = "registry+https://github.com/rust-lang/crates.io-index"
3437+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
3438+dependencies = [
3439+ "fastrand",
3440+ "phf_shared",
3441+]
3442+
3443+[[package]]
3444+name = "phf_shared"
3445+version = "0.13.1"
3446+source = "registry+https://github.com/rust-lang/crates.io-index"
3447+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
3448+dependencies = [
3449+ "siphasher",
3450+]
3451+
3452+[[package]]
3453+name = "pin-project-lite"
3454+version = "0.2.17"
3455+source = "registry+https://github.com/rust-lang/crates.io-index"
3456+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
3457+
3458+[[package]]
3459+name = "pkcs1"
3460+version = "0.7.5"
3461+source = "registry+https://github.com/rust-lang/crates.io-index"
3462+checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
3463+dependencies = [
3464+ "der",
3465+ "pkcs8",
3466+ "spki",
3467+]
3468+
3469+[[package]]
3470+name = "pkcs5"
3471+version = "0.7.1"
3472+source = "registry+https://github.com/rust-lang/crates.io-index"
3473+checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6"
3474+dependencies = [
3475+ "aes",
3476+ "cbc",
3477+ "der",
3478+ "pbkdf2",
3479+ "scrypt",
3480+ "sha2",
3481+ "spki",
3482+]
3483+
3484+[[package]]
3485+name = "pkcs8"
3486+version = "0.10.2"
3487+source = "registry+https://github.com/rust-lang/crates.io-index"
3488+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
3489+dependencies = [
3490+ "der",
3491+ "pkcs5",
3492+ "rand_core 0.6.4",
3493+ "spki",
3494+]
3495+
3496+[[package]]
3497+name = "pkg-config"
3498+version = "0.3.33"
3499+source = "registry+https://github.com/rust-lang/crates.io-index"
3500+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
3501+
3502+[[package]]
3503+name = "plain"
3504+version = "0.2.3"
3505+source = "registry+https://github.com/rust-lang/crates.io-index"
3506+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
3507+
3508+[[package]]
3509+name = "plist"
3510+version = "1.10.0"
3511+source = "registry+https://github.com/rust-lang/crates.io-index"
3512+checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
3513+dependencies = [
3514+ "base64 0.22.1",
3515+ "indexmap 2.14.0",
3516+ "quick-xml",
3517+ "serde",
3518+ "time",
3519+]
3520+
3521+[[package]]
3522+name = "poly1305"
3523+version = "0.8.0"
3524+source = "registry+https://github.com/rust-lang/crates.io-index"
3525+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
3526+dependencies = [
3527+ "cpufeatures 0.2.17",
3528+ "opaque-debug",
3529+ "universal-hash",
3530+]
3531+
3532+[[package]]
3533+name = "polyval"
3534+version = "0.6.2"
3535+source = "registry+https://github.com/rust-lang/crates.io-index"
3536+checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
3537+dependencies = [
3538+ "cfg-if",
3539+ "cpufeatures 0.2.17",
3540+ "opaque-debug",
3541+ "universal-hash",
3542+]
3543+
3544+[[package]]
3545+name = "portable-atomic"
3546+version = "1.14.0"
3547+source = "registry+https://github.com/rust-lang/crates.io-index"
3548+checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
3549+
3550+[[package]]
3551+name = "portable-atomic-util"
3552+version = "0.2.7"
3553+source = "registry+https://github.com/rust-lang/crates.io-index"
3554+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
3555+dependencies = [
3556+ "portable-atomic",
3557+]
3558+
3559+[[package]]
3560+name = "potential_utf"
3561+version = "0.1.5"
3562+source = "registry+https://github.com/rust-lang/crates.io-index"
3563+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
3564+dependencies = [
3565+ "zerovec",
3566+]
3567+
3568+[[package]]
3569+name = "powerfmt"
3570+version = "0.2.0"
3571+source = "registry+https://github.com/rust-lang/crates.io-index"
3572+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
3573+
3574+[[package]]
3575+name = "ppv-lite86"
3576+version = "0.2.21"
3577+source = "registry+https://github.com/rust-lang/crates.io-index"
3578+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
3579+dependencies = [
3580+ "zerocopy",
3581+]
3582+
3583+[[package]]
3584+name = "precomputed-hash"
3585+version = "0.1.1"
3586+source = "registry+https://github.com/rust-lang/crates.io-index"
3587+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
3588+
3589+[[package]]
3590+name = "prettyplease"
3591+version = "0.2.37"
3592+source = "registry+https://github.com/rust-lang/crates.io-index"
3593+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
3594+dependencies = [
3595+ "proc-macro2",
3596+ "syn 2.0.119",
3597+]
3598+
3599+[[package]]
3600+name = "primeorder"
3601+version = "0.13.6"
3602+source = "registry+https://github.com/rust-lang/crates.io-index"
3603+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
3604+dependencies = [
3605+ "elliptic-curve",
3606+]
3607+
3608+[[package]]
3609+name = "proc-macro2"
3610+version = "1.0.107"
3611+source = "registry+https://github.com/rust-lang/crates.io-index"
3612+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
3613+dependencies = [
3614+ "unicode-ident",
3615+]
3616+
3617+[[package]]
3618+name = "proc-macro2-diagnostics"
3619+version = "0.10.1"
3620+source = "registry+https://github.com/rust-lang/crates.io-index"
3621+checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
3622+dependencies = [
3623+ "proc-macro2",
3624+ "quote",
3625+ "syn 2.0.119",
3626+ "version_check",
3627+]
3628+
3629+[[package]]
3630+name = "prodash"
3631+version = "29.0.2"
3632+source = "registry+https://github.com/rust-lang/crates.io-index"
3633+checksum = "f04bb108f648884c23b98a0e940ebc2c93c0c3b89f04dbaf7eb8256ce617d1bc"
3634+dependencies = [
3635+ "log",
3636+ "parking_lot",
3637+]
3638+
3639+[[package]]
3640+name = "quick-xml"
3641+version = "0.41.0"
3642+source = "registry+https://github.com/rust-lang/crates.io-index"
3643+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
3644+dependencies = [
3645+ "memchr",
3646+]
3647+
3648+[[package]]
3649+name = "quinn"
3650+version = "0.11.11"
3651+source = "registry+https://github.com/rust-lang/crates.io-index"
3652+checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
3653+dependencies = [
3654+ "bytes",
3655+ "cfg_aliases",
3656+ "pin-project-lite",
3657+ "quinn-proto",
3658+ "quinn-udp",
3659+ "rustc-hash",
3660+ "rustls",
3661+ "socket2",
3662+ "thiserror 2.0.19",
3663+ "tokio",
3664+ "tracing",
3665+ "web-time",
3666+]
3667+
3668+[[package]]
3669+name = "quinn-proto"
3670+version = "0.11.16"
3671+source = "registry+https://github.com/rust-lang/crates.io-index"
3672+checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
3673+dependencies = [
3674+ "bytes",
3675+ "getrandom 0.4.3",
3676+ "lru-slab",
3677+ "rand 0.10.2",
3678+ "rand_pcg",
3679+ "ring",
3680+ "rustc-hash",
3681+ "rustls",
3682+ "rustls-pki-types",
3683+ "slab",
3684+ "thiserror 2.0.19",
3685+ "tinyvec",
3686+ "tracing",
3687+ "web-time",
3688+]
3689+
3690+[[package]]
3691+name = "quinn-udp"
3692+version = "0.5.15"
3693+source = "registry+https://github.com/rust-lang/crates.io-index"
3694+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
3695+dependencies = [
3696+ "cfg_aliases",
3697+ "libc",
3698+ "once_cell",
3699+ "socket2",
3700+ "tracing",
3701+ "windows-sys 0.61.2",
3702+]
3703+
3704+[[package]]
3705+name = "quote"
3706+version = "1.0.47"
3707+source = "registry+https://github.com/rust-lang/crates.io-index"
3708+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
3709+dependencies = [
3710+ "proc-macro2",
3711+]
3712+
3713+[[package]]
3714+name = "r-efi"
3715+version = "6.0.0"
3716+source = "registry+https://github.com/rust-lang/crates.io-index"
3717+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
3718+
3719+[[package]]
3720+name = "rand"
3721+version = "0.8.7"
3722+source = "registry+https://github.com/rust-lang/crates.io-index"
3723+checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
3724+dependencies = [
3725+ "libc",
3726+ "rand_chacha",
3727+ "rand_core 0.6.4",
3728+]
3729+
3730+[[package]]
3731+name = "rand"
3732+version = "0.10.2"
3733+source = "registry+https://github.com/rust-lang/crates.io-index"
3734+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
3735+dependencies = [
3736+ "chacha20 0.10.1",
3737+ "getrandom 0.4.3",
3738+ "rand_core 0.10.1",
3739+]
3740+
3741+[[package]]
3742+name = "rand_chacha"
3743+version = "0.3.1"
3744+source = "registry+https://github.com/rust-lang/crates.io-index"
3745+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
3746+dependencies = [
3747+ "ppv-lite86",
3748+ "rand_core 0.6.4",
3749+]
3750+
3751+[[package]]
3752+name = "rand_core"
3753+version = "0.6.4"
3754+source = "registry+https://github.com/rust-lang/crates.io-index"
3755+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
3756+dependencies = [
3757+ "getrandom 0.2.17",
3758+]
3759+
3760+[[package]]
3761+name = "rand_core"
3762+version = "0.10.1"
3763+source = "registry+https://github.com/rust-lang/crates.io-index"
3764+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
3765+
3766+[[package]]
3767+name = "rand_pcg"
3768+version = "0.10.2"
3769+source = "registry+https://github.com/rust-lang/crates.io-index"
3770+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
3771+dependencies = [
3772+ "rand_core 0.10.1",
3773+]
3774+
3775+[[package]]
3776+name = "redox_syscall"
3777+version = "0.5.18"
3778+source = "registry+https://github.com/rust-lang/crates.io-index"
3779+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
3780+dependencies = [
3781+ "bitflags 2.13.1",
3782+]
3783+
3784+[[package]]
3785+name = "redox_syscall"
3786+version = "0.9.1"
3787+source = "registry+https://github.com/rust-lang/crates.io-index"
3788+checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e"
3789+dependencies = [
3790+ "bitflags 2.13.1",
3791+]
3792+
3793+[[package]]
3794+name = "ref-cast"
3795+version = "1.0.26"
3796+source = "registry+https://github.com/rust-lang/crates.io-index"
3797+checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d"
3798+dependencies = [
3799+ "ref-cast-impl",
3800+]
3801+
3802+[[package]]
3803+name = "ref-cast-impl"
3804+version = "1.0.26"
3805+source = "registry+https://github.com/rust-lang/crates.io-index"
3806+checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
3807+dependencies = [
3808+ "proc-macro2",
3809+ "quote",
3810+ "syn 3.0.3",
3811+]
3812+
3813+[[package]]
3814+name = "regex"
3815+version = "1.13.1"
3816+source = "registry+https://github.com/rust-lang/crates.io-index"
3817+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
3818+dependencies = [
3819+ "aho-corasick",
3820+ "memchr",
3821+ "regex-automata",
3822+ "regex-syntax",
3823+]
3824+
3825+[[package]]
3826+name = "regex-automata"
3827+version = "0.4.16"
3828+source = "registry+https://github.com/rust-lang/crates.io-index"
3829+checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
3830+dependencies = [
3831+ "aho-corasick",
3832+ "memchr",
3833+ "regex-syntax",
3834+]
3835+
3836+[[package]]
3837+name = "regex-syntax"
3838+version = "0.8.11"
3839+source = "registry+https://github.com/rust-lang/crates.io-index"
3840+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
3841+
3842+[[package]]
3843+name = "reqwest"
3844+version = "0.12.28"
3845+source = "registry+https://github.com/rust-lang/crates.io-index"
3846+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
3847+dependencies = [
3848+ "base64 0.22.1",
3849+ "bytes",
3850+ "futures-core",
3851+ "http",
3852+ "http-body",
3853+ "http-body-util",
3854+ "hyper",
3855+ "hyper-rustls",
3856+ "hyper-util",
3857+ "js-sys",
3858+ "log",
3859+ "percent-encoding",
3860+ "pin-project-lite",
3861+ "quinn",
3862+ "rustls",
3863+ "rustls-pki-types",
3864+ "serde",
3865+ "serde_json",
3866+ "serde_urlencoded",
3867+ "sync_wrapper",
3868+ "tokio",
3869+ "tokio-rustls",
3870+ "tower",
3871+ "tower-http",
3872+ "tower-service",
3873+ "url",
3874+ "wasm-bindgen",
3875+ "wasm-bindgen-futures",
3876+ "web-sys",
3877+ "webpki-roots 1.0.9",
3878+]
3879+
3880+[[package]]
3881+name = "rfc6979"
3882+version = "0.4.0"
3883+source = "registry+https://github.com/rust-lang/crates.io-index"
3884+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
3885+dependencies = [
3886+ "hmac",
3887+ "subtle",
3888+]
3889+
3890+[[package]]
3891+name = "ring"
3892+version = "0.17.14"
3893+source = "registry+https://github.com/rust-lang/crates.io-index"
3894+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
3895+dependencies = [
3896+ "cc",
3897+ "cfg-if",
3898+ "getrandom 0.2.17",
3899+ "libc",
3900+ "untrusted",
3901+ "windows-sys 0.52.0",
3902+]
3903+
3904+[[package]]
3905+name = "rsa"
3906+version = "0.9.10"
3907+source = "registry+https://github.com/rust-lang/crates.io-index"
3908+checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
3909+dependencies = [
3910+ "const-oid",
3911+ "digest",
3912+ "num-bigint-dig",
3913+ "num-integer",
3914+ "num-traits",
3915+ "pkcs1",
3916+ "pkcs8",
3917+ "rand_core 0.6.4",
3918+ "sha2",
3919+ "signature",
3920+ "spki",
3921+ "subtle",
3922+ "zeroize",
3923+]
3924+
3925+[[package]]
3926+name = "russh"
3927+version = "0.51.1"
3928+source = "registry+https://github.com/rust-lang/crates.io-index"
3929+checksum = "c4816b748109d26daa30e72d231a3a6e42f9e2fffe6c08cbfed63113db0ce884"
3930+dependencies = [
3931+ "aes",
3932+ "aes-gcm",
3933+ "bitflags 2.13.1",
3934+ "block-padding",
3935+ "byteorder",
3936+ "bytes",
3937+ "cbc",
3938+ "chacha20 0.9.1",
3939+ "ctr",
3940+ "curve25519-dalek",
3941+ "data-encoding",
3942+ "delegate",
3943+ "der",
3944+ "digest",
3945+ "ecdsa",
3946+ "ed25519-dalek",
3947+ "elliptic-curve",
3948+ "enum_dispatch",
3949+ "flate2",
3950+ "futures",
3951+ "generic-array",
3952+ "getrandom 0.2.17",
3953+ "hex-literal",
3954+ "hmac",
3955+ "home",
3956+ "inout",
3957+ "internal-russh-forked-ssh-key",
3958+ "log",
3959+ "md5",
3960+ "num-bigint",
3961+ "once_cell",
3962+ "p256",
3963+ "p384",
3964+ "p521",
3965+ "pageant",
3966+ "pbkdf2",
3967+ "pkcs1",
3968+ "pkcs5",
3969+ "pkcs8",
3970+ "poly1305",
3971+ "rand 0.8.7",
3972+ "rand_core 0.6.4",
3973+ "rsa",
3974+ "russh-cryptovec",
3975+ "russh-util",
3976+ "sec1",
3977+ "sha1",
3978+ "sha2",
3979+ "signature",
3980+ "spki",
3981+ "ssh-encoding",
3982+ "subtle",
3983+ "thiserror 1.0.69",
3984+ "tokio",
3985+ "typenum",
3986+ "zeroize",
3987+]
3988+
3989+[[package]]
3990+name = "russh-cryptovec"
3991+version = "0.51.1"
3992+source = "registry+https://github.com/rust-lang/crates.io-index"
3993+checksum = "5b722480e7117893a8a14ba22298dca3387b24fa1c40eb06f3105b82875c7497"
3994+dependencies = [
3995+ "libc",
3996+ "log",
3997+ "nix",
3998+ "ssh-encoding",
3999+ "winapi",
4000+]
4001+
4002+[[package]]
4003+name = "russh-util"
4004+version = "0.51.0"
4005+source = "registry+https://github.com/rust-lang/crates.io-index"
4006+checksum = "c0c3f1ba8f8ccbe1af83c1a5dc325ecb0b6e176d1444c241c1d684ab216d1b24"
4007+dependencies = [
4008+ "chrono",
4009+ "tokio",
4010+ "wasm-bindgen",
4011+ "wasm-bindgen-futures",
4012+]
4013+
4014+[[package]]
4015+name = "rustc-hash"
4016+version = "2.1.3"
4017+source = "registry+https://github.com/rust-lang/crates.io-index"
4018+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
4019+
4020+[[package]]
4021+name = "rustc_version"
4022+version = "0.4.1"
4023+source = "registry+https://github.com/rust-lang/crates.io-index"
4024+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
4025+dependencies = [
4026+ "semver",
4027+]
4028+
4029+[[package]]
4030+name = "rustix"
4031+version = "0.38.44"
4032+source = "registry+https://github.com/rust-lang/crates.io-index"
4033+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
4034+dependencies = [
4035+ "bitflags 2.13.1",
4036+ "errno",
4037+ "libc",
4038+ "linux-raw-sys 0.4.15",
4039+ "windows-sys 0.52.0",
4040+]
4041+
4042+[[package]]
4043+name = "rustix"
4044+version = "1.1.4"
4045+source = "registry+https://github.com/rust-lang/crates.io-index"
4046+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
4047+dependencies = [
4048+ "bitflags 2.13.1",
4049+ "errno",
4050+ "libc",
4051+ "linux-raw-sys 0.12.1",
4052+ "windows-sys 0.61.2",
4053+]
4054+
4055+[[package]]
4056+name = "rustls"
4057+version = "0.23.43"
4058+source = "registry+https://github.com/rust-lang/crates.io-index"
4059+checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
4060+dependencies = [
4061+ "aws-lc-rs",
4062+ "once_cell",
4063+ "ring",
4064+ "rustls-pki-types",
4065+ "rustls-webpki",
4066+ "subtle",
4067+ "zeroize",
4068+]
4069+
4070+[[package]]
4071+name = "rustls-pki-types"
4072+version = "1.15.1"
4073+source = "registry+https://github.com/rust-lang/crates.io-index"
4074+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
4075+dependencies = [
4076+ "web-time",
4077+ "zeroize",
4078+]
4079+
4080+[[package]]
4081+name = "rustls-webpki"
4082+version = "0.103.13"
4083+source = "registry+https://github.com/rust-lang/crates.io-index"
4084+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
4085+dependencies = [
4086+ "aws-lc-rs",
4087+ "ring",
4088+ "rustls-pki-types",
4089+ "untrusted",
4090+]
4091+
4092+[[package]]
4093+name = "rustversion"
4094+version = "1.0.23"
4095+source = "registry+https://github.com/rust-lang/crates.io-index"
4096+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
4097+
4098+[[package]]
4099+name = "ryu"
4100+version = "1.0.23"
4101+source = "registry+https://github.com/rust-lang/crates.io-index"
4102+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
4103+
4104+[[package]]
4105+name = "salsa20"
4106+version = "0.10.2"
4107+source = "registry+https://github.com/rust-lang/crates.io-index"
4108+checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
4109+dependencies = [
4110+ "cipher",
4111+]
4112+
4113+[[package]]
4114+name = "same-file"
4115+version = "1.0.6"
4116+source = "registry+https://github.com/rust-lang/crates.io-index"
4117+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
4118+dependencies = [
4119+ "winapi-util",
4120+]
4121+
4122+[[package]]
4123+name = "schemars"
4124+version = "0.9.0"
4125+source = "registry+https://github.com/rust-lang/crates.io-index"
4126+checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
4127+dependencies = [
4128+ "dyn-clone",
4129+ "ref-cast",
4130+ "serde",
4131+ "serde_json",
4132+]
4133+
4134+[[package]]
4135+name = "schemars"
4136+version = "1.2.2"
4137+source = "registry+https://github.com/rust-lang/crates.io-index"
4138+checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
4139+dependencies = [
4140+ "dyn-clone",
4141+ "ref-cast",
4142+ "serde",
4143+ "serde_json",
4144+]
4145+
4146+[[package]]
4147+name = "scopeguard"
4148+version = "1.2.0"
4149+source = "registry+https://github.com/rust-lang/crates.io-index"
4150+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
4151+
4152+[[package]]
4153+name = "scrypt"
4154+version = "0.11.0"
4155+source = "registry+https://github.com/rust-lang/crates.io-index"
4156+checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
4157+dependencies = [
4158+ "pbkdf2",
4159+ "salsa20",
4160+ "sha2",
4161+]
4162+
4163+[[package]]
4164+name = "sec1"
4165+version = "0.7.3"
4166+source = "registry+https://github.com/rust-lang/crates.io-index"
4167+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
4168+dependencies = [
4169+ "base16ct",
4170+ "der",
4171+ "generic-array",
4172+ "pkcs8",
4173+ "subtle",
4174+ "zeroize",
4175+]
4176+
4177+[[package]]
4178+name = "semver"
4179+version = "1.0.28"
4180+source = "registry+https://github.com/rust-lang/crates.io-index"
4181+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
4182+
4183+[[package]]
4184+name = "serde"
4185+version = "1.0.229"
4186+source = "registry+https://github.com/rust-lang/crates.io-index"
4187+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
4188+dependencies = [
4189+ "serde_core",
4190+ "serde_derive",
4191+]
4192+
4193+[[package]]
4194+name = "serde-value"
4195+version = "0.7.0"
4196+source = "registry+https://github.com/rust-lang/crates.io-index"
4197+checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
4198+dependencies = [
4199+ "ordered-float",
4200+ "serde",
4201+]
4202+
4203+[[package]]
4204+name = "serde_core"
4205+version = "1.0.229"
4206+source = "registry+https://github.com/rust-lang/crates.io-index"
4207+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
4208+dependencies = [
4209+ "serde_derive",
4210+]
4211+
4212+[[package]]
4213+name = "serde_derive"
4214+version = "1.0.229"
4215+source = "registry+https://github.com/rust-lang/crates.io-index"
4216+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
4217+dependencies = [
4218+ "proc-macro2",
4219+ "quote",
4220+ "syn 3.0.3",
4221+]
4222+
4223+[[package]]
4224+name = "serde_json"
4225+version = "1.0.151"
4226+source = "registry+https://github.com/rust-lang/crates.io-index"
4227+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
4228+dependencies = [
4229+ "indexmap 2.14.0",
4230+ "itoa",
4231+ "memchr",
4232+ "serde",
4233+ "serde_core",
4234+ "zmij",
4235+]
4236+
4237+[[package]]
4238+name = "serde_path_to_error"
4239+version = "0.1.20"
4240+source = "registry+https://github.com/rust-lang/crates.io-index"
4241+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
4242+dependencies = [
4243+ "itoa",
4244+ "serde",
4245+ "serde_core",
4246+]
4247+
4248+[[package]]
4249+name = "serde_plain"
4250+version = "1.0.2"
4251+source = "registry+https://github.com/rust-lang/crates.io-index"
4252+checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
4253+dependencies = [
4254+ "serde",
4255+]
4256+
4257+[[package]]
4258+name = "serde_urlencoded"
4259+version = "0.7.1"
4260+source = "registry+https://github.com/rust-lang/crates.io-index"
4261+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
4262+dependencies = [
4263+ "form_urlencoded",
4264+ "itoa",
4265+ "ryu",
4266+ "serde",
4267+]
4268+
4269+[[package]]
4270+name = "serde_with"
4271+version = "3.21.0"
4272+source = "registry+https://github.com/rust-lang/crates.io-index"
4273+checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
4274+dependencies = [
4275+ "base64 0.22.1",
4276+ "bs58",
4277+ "chrono",
4278+ "hex",
4279+ "indexmap 1.9.3",
4280+ "indexmap 2.14.0",
4281+ "schemars 0.9.0",
4282+ "schemars 1.2.2",
4283+ "serde_core",
4284+ "serde_json",
4285+ "serde_with_macros",
4286+ "time",
4287+]
4288+
4289+[[package]]
4290+name = "serde_with_macros"
4291+version = "3.21.0"
4292+source = "registry+https://github.com/rust-lang/crates.io-index"
4293+checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
4294+dependencies = [
4295+ "darling",
4296+ "proc-macro2",
4297+ "quote",
4298+ "syn 2.0.119",
4299+]
4300+
4301+[[package]]
4302+name = "sha1"
4303+version = "0.10.7"
4304+source = "registry+https://github.com/rust-lang/crates.io-index"
4305+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
4306+dependencies = [
4307+ "cfg-if",
4308+ "cpufeatures 0.2.17",
4309+ "digest",
4310+]
4311+
4312+[[package]]
4313+name = "sha1_smol"
4314+version = "1.0.1"
4315+source = "registry+https://github.com/rust-lang/crates.io-index"
4316+checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
4317+
4318+[[package]]
4319+name = "sha2"
4320+version = "0.10.9"
4321+source = "registry+https://github.com/rust-lang/crates.io-index"
4322+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
4323+dependencies = [
4324+ "cfg-if",
4325+ "cpufeatures 0.2.17",
4326+ "digest",
4327+]
4328+
4329+[[package]]
4330+name = "sharded-slab"
4331+version = "0.1.7"
4332+source = "registry+https://github.com/rust-lang/crates.io-index"
4333+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
4334+dependencies = [
4335+ "lazy_static",
4336+]
4337+
4338+[[package]]
4339+name = "shell-words"
4340+version = "1.1.1"
4341+source = "registry+https://github.com/rust-lang/crates.io-index"
4342+checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
4343+
4344+[[package]]
4345+name = "shlex"
4346+version = "2.0.1"
4347+source = "registry+https://github.com/rust-lang/crates.io-index"
4348+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
4349+
4350+[[package]]
4351+name = "signal-hook-registry"
4352+version = "1.4.8"
4353+source = "registry+https://github.com/rust-lang/crates.io-index"
4354+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
4355+dependencies = [
4356+ "errno",
4357+ "libc",
4358+]
4359+
4360+[[package]]
4361+name = "signature"
4362+version = "2.2.0"
4363+source = "registry+https://github.com/rust-lang/crates.io-index"
4364+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
4365+dependencies = [
4366+ "digest",
4367+ "rand_core 0.6.4",
4368+]
4369+
4370+[[package]]
4371+name = "simd-adler32"
4372+version = "0.3.10"
4373+source = "registry+https://github.com/rust-lang/crates.io-index"
4374+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
4375+
4376+[[package]]
4377+name = "similar"
4378+version = "2.7.0"
4379+source = "registry+https://github.com/rust-lang/crates.io-index"
4380+checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
4381+
4382+[[package]]
4383+name = "siphasher"
4384+version = "1.0.3"
4385+source = "registry+https://github.com/rust-lang/crates.io-index"
4386+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
4387+
4388+[[package]]
4389+name = "slab"
4390+version = "0.4.12"
4391+source = "registry+https://github.com/rust-lang/crates.io-index"
4392+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
4393+
4394+[[package]]
4395+name = "slug"
4396+version = "0.1.6"
4397+source = "registry+https://github.com/rust-lang/crates.io-index"
4398+checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724"
4399+dependencies = [
4400+ "deunicode",
4401+ "wasm-bindgen",
4402+]
4403+
4404+[[package]]
4405+name = "smallvec"
4406+version = "1.15.2"
4407+source = "registry+https://github.com/rust-lang/crates.io-index"
4408+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
4409+dependencies = [
4410+ "serde",
4411+]
4412+
4413+[[package]]
4414+name = "socket2"
4415+version = "0.6.5"
4416+source = "registry+https://github.com/rust-lang/crates.io-index"
4417+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
4418+dependencies = [
4419+ "libc",
4420+ "windows-sys 0.61.2",
4421+]
4422+
4423+[[package]]
4424+name = "spin"
4425+version = "0.9.9"
4426+source = "registry+https://github.com/rust-lang/crates.io-index"
4427+checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
4428+dependencies = [
4429+ "lock_api",
4430+]
4431+
4432+[[package]]
4433+name = "spki"
4434+version = "0.7.3"
4435+source = "registry+https://github.com/rust-lang/crates.io-index"
4436+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
4437+dependencies = [
4438+ "base64ct",
4439+ "der",
4440+]
4441+
4442+[[package]]
4443+name = "sqlx"
4444+version = "0.8.6"
4445+source = "registry+https://github.com/rust-lang/crates.io-index"
4446+checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
4447+dependencies = [
4448+ "sqlx-core",
4449+ "sqlx-macros",
4450+ "sqlx-mysql",
4451+ "sqlx-postgres",
4452+ "sqlx-sqlite",
4453+]
4454+
4455+[[package]]
4456+name = "sqlx-core"
4457+version = "0.8.6"
4458+source = "registry+https://github.com/rust-lang/crates.io-index"
4459+checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
4460+dependencies = [
4461+ "base64 0.22.1",
4462+ "bytes",
4463+ "chrono",
4464+ "crc",
4465+ "crossbeam-queue",
4466+ "either",
4467+ "event-listener",
4468+ "futures-core",
4469+ "futures-intrusive",
4470+ "futures-io",
4471+ "futures-util",
4472+ "hashbrown 0.15.5",
4473+ "hashlink",
4474+ "indexmap 2.14.0",
4475+ "ipnetwork",
4476+ "log",
4477+ "memchr",
4478+ "once_cell",
4479+ "percent-encoding",
4480+ "rustls",
4481+ "serde",
4482+ "serde_json",
4483+ "sha2",
4484+ "smallvec",
4485+ "thiserror 2.0.19",
4486+ "tokio",
4487+ "tokio-stream",
4488+ "tracing",
4489+ "url",
4490+ "uuid",
4491+ "webpki-roots 0.26.11",
4492+]
4493+
4494+[[package]]
4495+name = "sqlx-macros"
4496+version = "0.8.6"
4497+source = "registry+https://github.com/rust-lang/crates.io-index"
4498+checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
4499+dependencies = [
4500+ "proc-macro2",
4501+ "quote",
4502+ "sqlx-core",
4503+ "sqlx-macros-core",
4504+ "syn 2.0.119",
4505+]
4506+
4507+[[package]]
4508+name = "sqlx-macros-core"
4509+version = "0.8.6"
4510+source = "registry+https://github.com/rust-lang/crates.io-index"
4511+checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
4512+dependencies = [
4513+ "dotenvy",
4514+ "either",
4515+ "heck",
4516+ "hex",
4517+ "once_cell",
4518+ "proc-macro2",
4519+ "quote",
4520+ "serde",
4521+ "serde_json",
4522+ "sha2",
4523+ "sqlx-core",
4524+ "sqlx-mysql",
4525+ "sqlx-postgres",
4526+ "sqlx-sqlite",
4527+ "syn 2.0.119",
4528+ "tokio",
4529+ "url",
4530+]
4531+
4532+[[package]]
4533+name = "sqlx-mysql"
4534+version = "0.8.6"
4535+source = "registry+https://github.com/rust-lang/crates.io-index"
4536+checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
4537+dependencies = [
4538+ "atoi",
4539+ "base64 0.22.1",
4540+ "bitflags 2.13.1",
4541+ "byteorder",
4542+ "bytes",
4543+ "chrono",
4544+ "crc",
4545+ "digest",
4546+ "dotenvy",
4547+ "either",
4548+ "futures-channel",
4549+ "futures-core",
4550+ "futures-io",
4551+ "futures-util",
4552+ "generic-array",
4553+ "hex",
4554+ "hkdf",
4555+ "hmac",
4556+ "itoa",
4557+ "log",
4558+ "md-5",
4559+ "memchr",
4560+ "once_cell",
4561+ "percent-encoding",
4562+ "rand 0.8.7",
4563+ "rsa",
4564+ "serde",
4565+ "sha1",
4566+ "sha2",
4567+ "smallvec",
4568+ "sqlx-core",
4569+ "stringprep",
4570+ "thiserror 2.0.19",
4571+ "tracing",
4572+ "uuid",
4573+ "whoami",
4574+]
4575+
4576+[[package]]
4577+name = "sqlx-postgres"
4578+version = "0.8.6"
4579+source = "registry+https://github.com/rust-lang/crates.io-index"
4580+checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
4581+dependencies = [
4582+ "atoi",
4583+ "base64 0.22.1",
4584+ "bitflags 2.13.1",
4585+ "byteorder",
4586+ "chrono",
4587+ "crc",
4588+ "dotenvy",
4589+ "etcetera",
4590+ "futures-channel",
4591+ "futures-core",
4592+ "futures-util",
4593+ "hex",
4594+ "hkdf",
4595+ "hmac",
4596+ "home",
4597+ "ipnetwork",
4598+ "itoa",
4599+ "log",
4600+ "md-5",
4601+ "memchr",
4602+ "once_cell",
4603+ "rand 0.8.7",
4604+ "serde",
4605+ "serde_json",
4606+ "sha2",
4607+ "smallvec",
4608+ "sqlx-core",
4609+ "stringprep",
4610+ "thiserror 2.0.19",
4611+ "tracing",
4612+ "uuid",
4613+ "whoami",
4614+]
4615+
4616+[[package]]
4617+name = "sqlx-sqlite"
4618+version = "0.8.6"
4619+source = "registry+https://github.com/rust-lang/crates.io-index"
4620+checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
4621+dependencies = [
4622+ "atoi",
4623+ "chrono",
4624+ "flume",
4625+ "futures-channel",
4626+ "futures-core",
4627+ "futures-executor",
4628+ "futures-intrusive",
4629+ "futures-util",
4630+ "libsqlite3-sys",
4631+ "log",
4632+ "percent-encoding",
4633+ "serde",
4634+ "serde_urlencoded",
4635+ "sqlx-core",
4636+ "thiserror 2.0.19",
4637+ "tracing",
4638+ "url",
4639+ "uuid",
4640+]
4641+
4642+[[package]]
4643+name = "ssh-cipher"
4644+version = "0.2.0"
4645+source = "registry+https://github.com/rust-lang/crates.io-index"
4646+checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f"
4647+dependencies = [
4648+ "aes",
4649+ "aes-gcm",
4650+ "cbc",
4651+ "chacha20 0.9.1",
4652+ "cipher",
4653+ "ctr",
4654+ "poly1305",
4655+ "ssh-encoding",
4656+ "subtle",
4657+]
4658+
4659+[[package]]
4660+name = "ssh-encoding"
4661+version = "0.2.0"
4662+source = "registry+https://github.com/rust-lang/crates.io-index"
4663+checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15"
4664+dependencies = [
4665+ "base64ct",
4666+ "bytes",
4667+ "pem-rfc7468",
4668+ "sha2",
4669+]
4670+
4671+[[package]]
4672+name = "ssh-key"
4673+version = "0.6.7"
4674+source = "registry+https://github.com/rust-lang/crates.io-index"
4675+checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
4676+dependencies = [
4677+ "ed25519-dalek",
4678+ "num-bigint-dig",
4679+ "p256",
4680+ "p384",
4681+ "p521",
4682+ "rand_core 0.6.4",
4683+ "rsa",
4684+ "sec1",
4685+ "sha2",
4686+ "signature",
4687+ "ssh-cipher",
4688+ "ssh-encoding",
4689+ "subtle",
4690+ "zeroize",
4691+]
4692+
4693+[[package]]
4694+name = "stable_deref_trait"
4695+version = "1.2.1"
4696+source = "registry+https://github.com/rust-lang/crates.io-index"
4697+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
4698+
4699+[[package]]
4700+name = "static_assertions"
4701+version = "1.1.0"
4702+source = "registry+https://github.com/rust-lang/crates.io-index"
4703+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
4704+
4705+[[package]]
4706+name = "streaming-iterator"
4707+version = "0.1.9"
4708+source = "registry+https://github.com/rust-lang/crates.io-index"
4709+checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
4710+
4711+[[package]]
4712+name = "string_cache"
4713+version = "0.9.0"
4714+source = "registry+https://github.com/rust-lang/crates.io-index"
4715+checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
4716+dependencies = [
4717+ "new_debug_unreachable",
4718+ "parking_lot",
4719+ "phf_shared",
4720+ "precomputed-hash",
4721+]
4722+
4723+[[package]]
4724+name = "string_cache_codegen"
4725+version = "0.6.1"
4726+source = "registry+https://github.com/rust-lang/crates.io-index"
4727+checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
4728+dependencies = [
4729+ "phf_generator",
4730+ "phf_shared",
4731+ "proc-macro2",
4732+ "quote",
4733+]
4734+
4735+[[package]]
4736+name = "stringprep"
4737+version = "0.1.5"
4738+source = "registry+https://github.com/rust-lang/crates.io-index"
4739+checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
4740+dependencies = [
4741+ "unicode-bidi",
4742+ "unicode-normalization",
4743+ "unicode-properties",
4744+]
4745+
4746+[[package]]
4747+name = "strsim"
4748+version = "0.11.1"
4749+source = "registry+https://github.com/rust-lang/crates.io-index"
4750+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
4751+
4752+[[package]]
4753+name = "subtle"
4754+version = "2.6.1"
4755+source = "registry+https://github.com/rust-lang/crates.io-index"
4756+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
4757+
4758+[[package]]
4759+name = "syn"
4760+version = "2.0.119"
4761+source = "registry+https://github.com/rust-lang/crates.io-index"
4762+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
4763+dependencies = [
4764+ "proc-macro2",
4765+ "quote",
4766+ "unicode-ident",
4767+]
4768+
4769+[[package]]
4770+name = "syn"
4771+version = "3.0.3"
4772+source = "registry+https://github.com/rust-lang/crates.io-index"
4773+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
4774+dependencies = [
4775+ "proc-macro2",
4776+ "quote",
4777+ "unicode-ident",
4778+]
4779+
4780+[[package]]
4781+name = "sync_wrapper"
4782+version = "1.0.2"
4783+source = "registry+https://github.com/rust-lang/crates.io-index"
4784+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
4785+dependencies = [
4786+ "futures-core",
4787+]
4788+
4789+[[package]]
4790+name = "synstructure"
4791+version = "0.13.2"
4792+source = "registry+https://github.com/rust-lang/crates.io-index"
4793+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
4794+dependencies = [
4795+ "proc-macro2",
4796+ "quote",
4797+ "syn 2.0.119",
4798+]
4799+
4800+[[package]]
4801+name = "syntect"
4802+version = "5.3.0"
4803+source = "registry+https://github.com/rust-lang/crates.io-index"
4804+checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
4805+dependencies = [
4806+ "bincode",
4807+ "fancy-regex",
4808+ "flate2",
4809+ "fnv",
4810+ "once_cell",
4811+ "onig",
4812+ "plist",
4813+ "regex-syntax",
4814+ "serde",
4815+ "serde_derive",
4816+ "serde_json",
4817+ "thiserror 2.0.19",
4818+ "walkdir",
4819+ "yaml-rust",
4820+]
4821+
4822+[[package]]
4823+name = "tempfile"
4824+version = "3.27.0"
4825+source = "registry+https://github.com/rust-lang/crates.io-index"
4826+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
4827+dependencies = [
4828+ "fastrand",
4829+ "getrandom 0.4.3",
4830+ "once_cell",
4831+ "rustix 1.1.4",
4832+ "windows-sys 0.61.2",
4833+]
4834+
4835+[[package]]
4836+name = "tendril"
4837+version = "0.5.1"
4838+source = "registry+https://github.com/rust-lang/crates.io-index"
4839+checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
4840+dependencies = [
4841+ "new_debug_unreachable",
4842+]
4843+
4844+[[package]]
4845+name = "terminal_size"
4846+version = "0.4.4"
4847+source = "registry+https://github.com/rust-lang/crates.io-index"
4848+checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
4849+dependencies = [
4850+ "rustix 1.1.4",
4851+ "windows-sys 0.61.2",
4852+]
4853+
4854+[[package]]
4855+name = "thiserror"
4856+version = "1.0.69"
4857+source = "registry+https://github.com/rust-lang/crates.io-index"
4858+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
4859+dependencies = [
4860+ "thiserror-impl 1.0.69",
4861+]
4862+
4863+[[package]]
4864+name = "thiserror"
4865+version = "2.0.19"
4866+source = "registry+https://github.com/rust-lang/crates.io-index"
4867+checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
4868+dependencies = [
4869+ "thiserror-impl 2.0.19",
4870+]
4871+
4872+[[package]]
4873+name = "thiserror-impl"
4874+version = "1.0.69"
4875+source = "registry+https://github.com/rust-lang/crates.io-index"
4876+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
4877+dependencies = [
4878+ "proc-macro2",
4879+ "quote",
4880+ "syn 2.0.119",
4881+]
4882+
4883+[[package]]
4884+name = "thiserror-impl"
4885+version = "2.0.19"
4886+source = "registry+https://github.com/rust-lang/crates.io-index"
4887+checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
4888+dependencies = [
4889+ "proc-macro2",
4890+ "quote",
4891+ "syn 3.0.3",
4892+]
4893+
4894+[[package]]
4895+name = "thread_local"
4896+version = "1.1.10"
4897+source = "registry+https://github.com/rust-lang/crates.io-index"
4898+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
4899+dependencies = [
4900+ "cfg-if",
4901+]
4902+
4903+[[package]]
4904+name = "time"
4905+version = "0.3.54"
4906+source = "registry+https://github.com/rust-lang/crates.io-index"
4907+checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
4908+dependencies = [
4909+ "deranged",
4910+ "num-conv",
4911+ "powerfmt",
4912+ "serde_core",
4913+ "time-core",
4914+ "time-macros",
4915+]
4916+
4917+[[package]]
4918+name = "time-core"
4919+version = "0.1.9"
4920+source = "registry+https://github.com/rust-lang/crates.io-index"
4921+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
4922+
4923+[[package]]
4924+name = "time-macros"
4925+version = "0.2.32"
4926+source = "registry+https://github.com/rust-lang/crates.io-index"
4927+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
4928+dependencies = [
4929+ "num-conv",
4930+ "time-core",
4931+]
4932+
4933+[[package]]
4934+name = "tinystr"
4935+version = "0.8.3"
4936+source = "registry+https://github.com/rust-lang/crates.io-index"
4937+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
4938+dependencies = [
4939+ "displaydoc",
4940+ "zerovec",
4941+]
4942+
4943+[[package]]
4944+name = "tinyvec"
4945+version = "1.12.0"
4946+source = "registry+https://github.com/rust-lang/crates.io-index"
4947+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
4948+dependencies = [
4949+ "tinyvec_macros",
4950+]
4951+
4952+[[package]]
4953+name = "tinyvec_macros"
4954+version = "0.1.1"
4955+source = "registry+https://github.com/rust-lang/crates.io-index"
4956+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
4957+
4958+[[package]]
4959+name = "tokio"
4960+version = "1.53.1"
4961+source = "registry+https://github.com/rust-lang/crates.io-index"
4962+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
4963+dependencies = [
4964+ "bytes",
4965+ "libc",
4966+ "mio",
4967+ "pin-project-lite",
4968+ "signal-hook-registry",
4969+ "socket2",
4970+ "tokio-macros",
4971+ "windows-sys 0.61.2",
4972+]
4973+
4974+[[package]]
4975+name = "tokio-macros"
4976+version = "2.7.2"
4977+source = "registry+https://github.com/rust-lang/crates.io-index"
4978+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
4979+dependencies = [
4980+ "proc-macro2",
4981+ "quote",
4982+ "syn 3.0.3",
4983+]
4984+
4985+[[package]]
4986+name = "tokio-rustls"
4987+version = "0.26.4"
4988+source = "registry+https://github.com/rust-lang/crates.io-index"
4989+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
4990+dependencies = [
4991+ "rustls",
4992+ "tokio",
4993+]
4994+
4995+[[package]]
4996+name = "tokio-stream"
4997+version = "0.1.19"
4998+source = "registry+https://github.com/rust-lang/crates.io-index"
4999+checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
5000+dependencies = [
5001+ "futures-core",
5002+ "pin-project-lite",
5003+ "tokio",
5004+]
5005+
5006+[[package]]
5007+name = "tokio-util"
5008+version = "0.7.19"
5009+source = "registry+https://github.com/rust-lang/crates.io-index"
5010+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
5011+dependencies = [
5012+ "bytes",
5013+ "futures-core",
5014+ "futures-sink",
5015+ "libc",
5016+ "pin-project-lite",
5017+ "tokio",
5018+]
5019+
5020+[[package]]
5021+name = "tower"
5022+version = "0.5.3"
5023+source = "registry+https://github.com/rust-lang/crates.io-index"
5024+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
5025+dependencies = [
5026+ "futures-core",
5027+ "futures-util",
5028+ "pin-project-lite",
5029+ "sync_wrapper",
5030+ "tokio",
5031+ "tokio-util",
5032+ "tower-layer",
5033+ "tower-service",
5034+ "tracing",
5035+]
5036+
5037+[[package]]
5038+name = "tower-http"
5039+version = "0.6.11"
5040+source = "registry+https://github.com/rust-lang/crates.io-index"
5041+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
5042+dependencies = [
5043+ "async-compression",
5044+ "bitflags 2.13.1",
5045+ "bytes",
5046+ "futures-core",
5047+ "futures-util",
5048+ "http",
5049+ "http-body",
5050+ "http-body-util",
5051+ "pin-project-lite",
5052+ "tokio",
5053+ "tokio-util",
5054+ "tower",
5055+ "tower-layer",
5056+ "tower-service",
5057+ "tracing",
5058+ "url",
5059+]
5060+
5061+[[package]]
5062+name = "tower-layer"
5063+version = "0.3.3"
5064+source = "registry+https://github.com/rust-lang/crates.io-index"
5065+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
5066+
5067+[[package]]
5068+name = "tower-service"
5069+version = "0.3.3"
5070+source = "registry+https://github.com/rust-lang/crates.io-index"
5071+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
5072+
5073+[[package]]
5074+name = "tracing"
5075+version = "0.1.44"
5076+source = "registry+https://github.com/rust-lang/crates.io-index"
5077+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
5078+dependencies = [
5079+ "log",
5080+ "pin-project-lite",
5081+ "tracing-attributes",
5082+ "tracing-core",
5083+]
5084+
5085+[[package]]
5086+name = "tracing-attributes"
5087+version = "0.1.31"
5088+source = "registry+https://github.com/rust-lang/crates.io-index"
5089+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
5090+dependencies = [
5091+ "proc-macro2",
5092+ "quote",
5093+ "syn 2.0.119",
5094+]
5095+
5096+[[package]]
5097+name = "tracing-core"
5098+version = "0.1.36"
5099+source = "registry+https://github.com/rust-lang/crates.io-index"
5100+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
5101+dependencies = [
5102+ "once_cell",
5103+ "valuable",
5104+]
5105+
5106+[[package]]
5107+name = "tracing-log"
5108+version = "0.2.0"
5109+source = "registry+https://github.com/rust-lang/crates.io-index"
5110+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
5111+dependencies = [
5112+ "log",
5113+ "once_cell",
5114+ "tracing-core",
5115+]
5116+
5117+[[package]]
5118+name = "tracing-serde"
5119+version = "0.2.0"
5120+source = "registry+https://github.com/rust-lang/crates.io-index"
5121+checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
5122+dependencies = [
5123+ "serde",
5124+ "tracing-core",
5125+]
5126+
5127+[[package]]
5128+name = "tracing-subscriber"
5129+version = "0.3.23"
5130+source = "registry+https://github.com/rust-lang/crates.io-index"
5131+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
5132+dependencies = [
5133+ "matchers",
5134+ "nu-ansi-term",
5135+ "once_cell",
5136+ "regex-automata",
5137+ "serde",
5138+ "serde_json",
5139+ "sharded-slab",
5140+ "smallvec",
5141+ "thread_local",
5142+ "tracing",
5143+ "tracing-core",
5144+ "tracing-log",
5145+ "tracing-serde",
5146+]
5147+
5148+[[package]]
5149+name = "tree-sitter"
5150+version = "0.25.10"
5151+source = "registry+https://github.com/rust-lang/crates.io-index"
5152+checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87"
5153+dependencies = [
5154+ "cc",
5155+ "regex",
5156+ "regex-syntax",
5157+ "serde_json",
5158+ "streaming-iterator",
5159+ "tree-sitter-language",
5160+]
5161+
5162+[[package]]
5163+name = "tree-sitter-bash"
5164+version = "0.23.3"
5165+source = "registry+https://github.com/rust-lang/crates.io-index"
5166+checksum = "329a4d48623ac337d42b1df84e81a1c9dbb2946907c102ca72db158c1964a52e"
5167+dependencies = [
5168+ "cc",
5169+ "tree-sitter-language",
5170+]
5171+
5172+[[package]]
5173+name = "tree-sitter-c"
5174+version = "0.23.4"
5175+source = "registry+https://github.com/rust-lang/crates.io-index"
5176+checksum = "afd2b1bf1585dc2ef6d69e87d01db8adb059006649dd5f96f31aa789ee6e9c71"
5177+dependencies = [
5178+ "cc",
5179+ "tree-sitter-language",
5180+]
5181+
5182+[[package]]
5183+name = "tree-sitter-c-sharp"
5184+version = "0.23.5"
5185+source = "registry+https://github.com/rust-lang/crates.io-index"
5186+checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c"
5187+dependencies = [
5188+ "cc",
5189+ "tree-sitter-language",
5190+]
5191+
5192+[[package]]
5193+name = "tree-sitter-cpp"
5194+version = "0.23.4"
5195+source = "registry+https://github.com/rust-lang/crates.io-index"
5196+checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743"
5197+dependencies = [
5198+ "cc",
5199+ "tree-sitter-language",
5200+]
5201+
5202+[[package]]
5203+name = "tree-sitter-css"
5204+version = "0.23.2"
5205+source = "registry+https://github.com/rust-lang/crates.io-index"
5206+checksum = "5ad6489794d41350d12a7fbe520e5199f688618f43aace5443980d1ddcf1b29e"
5207+dependencies = [
5208+ "cc",
5209+ "tree-sitter-language",
5210+]
5211+
5212+[[package]]
5213+name = "tree-sitter-elixir"
5214+version = "0.3.5"
5215+source = "registry+https://github.com/rust-lang/crates.io-index"
5216+checksum = "66dd064a762ed95bfc29857fa3cb7403bb1e5cb88112de0f6341b7e47284ba40"
5217+dependencies = [
5218+ "cc",
5219+ "tree-sitter-language",
5220+]
5221+
5222+[[package]]
5223+name = "tree-sitter-go"
5224+version = "0.23.4"
5225+source = "registry+https://github.com/rust-lang/crates.io-index"
5226+checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431"
5227+dependencies = [
5228+ "cc",
5229+ "tree-sitter-language",
5230+]
5231+
5232+[[package]]
5233+name = "tree-sitter-haskell"
5234+version = "0.23.1"
5235+source = "registry+https://github.com/rust-lang/crates.io-index"
5236+checksum = "977c51e504548cba13fc27cb5a2edab2124cf6716a1934915d07ab99523b05a4"
5237+dependencies = [
5238+ "cc",
5239+ "tree-sitter-language",
5240+]
5241+
5242+[[package]]
5243+name = "tree-sitter-highlight"
5244+version = "0.25.10"
5245+source = "registry+https://github.com/rust-lang/crates.io-index"
5246+checksum = "adc5f880ad8d8f94e88cb81c3557024cf1a8b75e3b504c50481ed4f5a6006ff3"
5247+dependencies = [
5248+ "regex",
5249+ "streaming-iterator",
5250+ "thiserror 2.0.19",
5251+ "tree-sitter",
5252+]
5253+
5254+[[package]]
5255+name = "tree-sitter-html"
5256+version = "0.23.2"
5257+source = "registry+https://github.com/rust-lang/crates.io-index"
5258+checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee"
5259+dependencies = [
5260+ "cc",
5261+ "tree-sitter-language",
5262+]
5263+
5264+[[package]]
5265+name = "tree-sitter-java"
5266+version = "0.23.5"
5267+source = "registry+https://github.com/rust-lang/crates.io-index"
5268+checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6"
5269+dependencies = [
5270+ "cc",
5271+ "tree-sitter-language",
5272+]
5273+
5274+[[package]]
5275+name = "tree-sitter-javascript"
5276+version = "0.23.1"
5277+source = "registry+https://github.com/rust-lang/crates.io-index"
5278+checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1"
5279+dependencies = [
5280+ "cc",
5281+ "tree-sitter-language",
5282+]
5283+
5284+[[package]]
5285+name = "tree-sitter-json"
5286+version = "0.24.8"
5287+source = "registry+https://github.com/rust-lang/crates.io-index"
5288+checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471"
5289+dependencies = [
5290+ "cc",
5291+ "tree-sitter-language",
5292+]
5293+
5294+[[package]]
5295+name = "tree-sitter-language"
5296+version = "0.1.7"
5297+source = "registry+https://github.com/rust-lang/crates.io-index"
5298+checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
5299+
5300+[[package]]
5301+name = "tree-sitter-lua"
5302+version = "0.2.0"
5303+source = "registry+https://github.com/rust-lang/crates.io-index"
5304+checksum = "5cdb9adf0965fec58e7660cbb3a059dbb12ebeec9459e6dcbae3db004739641e"
5305+dependencies = [
5306+ "cc",
5307+ "tree-sitter-language",
5308+]
5309+
5310+[[package]]
5311+name = "tree-sitter-md"
5312+version = "0.3.2"
5313+source = "registry+https://github.com/rust-lang/crates.io-index"
5314+checksum = "17f968c22a01010b83fc960455ae729db08dbeb6388617d9113897cb9204b030"
5315+dependencies = [
5316+ "cc",
5317+ "tree-sitter-language",
5318+]
5319+
5320+[[package]]
5321+name = "tree-sitter-nix"
5322+version = "0.3.0"
5323+source = "registry+https://github.com/rust-lang/crates.io-index"
5324+checksum = "4952a9733f3a98f6683a0ccd1035d84ab7a52f7e84eeed58548d86765ad92de3"
5325+dependencies = [
5326+ "cc",
5327+ "tree-sitter-language",
5328+]
5329+
5330+[[package]]
5331+name = "tree-sitter-php"
5332+version = "0.23.11"
5333+source = "registry+https://github.com/rust-lang/crates.io-index"
5334+checksum = "f066e94e9272cfe4f1dcb07a1c50c66097eca648f2d7233d299c8ae9ed8c130c"
5335+dependencies = [
5336+ "cc",
5337+ "tree-sitter-language",
5338+]
5339+
5340+[[package]]
5341+name = "tree-sitter-python"
5342+version = "0.23.6"
5343+source = "registry+https://github.com/rust-lang/crates.io-index"
5344+checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04"
5345+dependencies = [
5346+ "cc",
5347+ "tree-sitter-language",
5348+]
5349+
5350+[[package]]
5351+name = "tree-sitter-ruby"
5352+version = "0.23.1"
5353+source = "registry+https://github.com/rust-lang/crates.io-index"
5354+checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95"
5355+dependencies = [
5356+ "cc",
5357+ "tree-sitter-language",
5358+]
5359+
5360+[[package]]
5361+name = "tree-sitter-rust"
5362+version = "0.24.2"
5363+source = "registry+https://github.com/rust-lang/crates.io-index"
5364+checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45"
5365+dependencies = [
5366+ "cc",
5367+ "tree-sitter-language",
5368+]
5369+
5370+[[package]]
5371+name = "tree-sitter-scala"
5372+version = "0.23.4"
5373+source = "registry+https://github.com/rust-lang/crates.io-index"
5374+checksum = "efde5e68b4736e9eac17bfa296c6f104a26bffab363b365eb898c40a63c15d2f"
5375+dependencies = [
5376+ "cc",
5377+ "tree-sitter-language",
5378+]
5379+
5380+[[package]]
5381+name = "tree-sitter-sequel"
5382+version = "0.3.11"
5383+source = "registry+https://github.com/rust-lang/crates.io-index"
5384+checksum = "9d198ad3c319c02e43c21efa1ec796b837afcb96ffaef1a40c1978fbdcec7d17"
5385+dependencies = [
5386+ "cc",
5387+ "tree-sitter-language",
5388+]
5389+
5390+[[package]]
5391+name = "tree-sitter-swift"
5392+version = "0.7.3"
5393+source = "registry+https://github.com/rust-lang/crates.io-index"
5394+checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c"
5395+dependencies = [
5396+ "cc",
5397+ "tree-sitter-language",
5398+]
5399+
5400+[[package]]
5401+name = "tree-sitter-toml-ng"
5402+version = "0.7.0"
5403+source = "registry+https://github.com/rust-lang/crates.io-index"
5404+checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1"
5405+dependencies = [
5406+ "cc",
5407+ "tree-sitter-language",
5408+]
5409+
5410+[[package]]
5411+name = "tree-sitter-typescript"
5412+version = "0.23.2"
5413+source = "registry+https://github.com/rust-lang/crates.io-index"
5414+checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff"
5415+dependencies = [
5416+ "cc",
5417+ "tree-sitter-language",
5418+]
5419+
5420+[[package]]
5421+name = "tree-sitter-yaml"
5422+version = "0.7.2"
5423+source = "registry+https://github.com/rust-lang/crates.io-index"
5424+checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97"
5425+dependencies = [
5426+ "cc",
5427+ "tree-sitter-language",
5428+]
5429+
5430+[[package]]
5431+name = "tree-sitter-zig"
5432+version = "1.1.2"
5433+source = "registry+https://github.com/rust-lang/crates.io-index"
5434+checksum = "ab11fc124851b0db4dd5e55983bbd9631192e93238389dcd44521715e5d53e28"
5435+dependencies = [
5436+ "cc",
5437+ "tree-sitter-language",
5438+]
5439+
5440+[[package]]
5441+name = "try-lock"
5442+version = "0.2.5"
5443+source = "registry+https://github.com/rust-lang/crates.io-index"
5444+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
5445+
5446+[[package]]
5447+name = "typed-arena"
5448+version = "2.0.2"
5449+source = "registry+https://github.com/rust-lang/crates.io-index"
5450+checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
5451+
5452+[[package]]
5453+name = "typenum"
5454+version = "1.20.1"
5455+source = "registry+https://github.com/rust-lang/crates.io-index"
5456+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
5457+
5458+[[package]]
5459+name = "uluru"
5460+version = "3.1.0"
5461+source = "registry+https://github.com/rust-lang/crates.io-index"
5462+checksum = "7c8a2469e56e6e5095c82ccd3afb98dad95f7af7929aab6d8ba8d6e0f73657da"
5463+dependencies = [
5464+ "arrayvec",
5465+]
5466+
5467+[[package]]
5468+name = "unicode-bidi"
5469+version = "0.3.18"
5470+source = "registry+https://github.com/rust-lang/crates.io-index"
5471+checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
5472+
5473+[[package]]
5474+name = "unicode-bom"
5475+version = "2.0.3"
5476+source = "registry+https://github.com/rust-lang/crates.io-index"
5477+checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217"
5478+
5479+[[package]]
5480+name = "unicode-ident"
5481+version = "1.0.24"
5482+source = "registry+https://github.com/rust-lang/crates.io-index"
5483+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
5484+
5485+[[package]]
5486+name = "unicode-normalization"
5487+version = "0.1.25"
5488+source = "registry+https://github.com/rust-lang/crates.io-index"
5489+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
5490+dependencies = [
5491+ "tinyvec",
5492+]
5493+
5494+[[package]]
5495+name = "unicode-properties"
5496+version = "0.1.4"
5497+source = "registry+https://github.com/rust-lang/crates.io-index"
5498+checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
5499+
5500+[[package]]
5501+name = "unicode_categories"
5502+version = "0.1.1"
5503+source = "registry+https://github.com/rust-lang/crates.io-index"
5504+checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
5505+
5506+[[package]]
5507+name = "universal-hash"
5508+version = "0.5.1"
5509+source = "registry+https://github.com/rust-lang/crates.io-index"
5510+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
5511+dependencies = [
5512+ "crypto-common",
5513+ "subtle",
5514+]
5515+
5516+[[package]]
5517+name = "untrusted"
5518+version = "0.9.0"
5519+source = "registry+https://github.com/rust-lang/crates.io-index"
5520+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
5521+
5522+[[package]]
5523+name = "url"
5524+version = "2.5.8"
5525+source = "registry+https://github.com/rust-lang/crates.io-index"
5526+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
5527+dependencies = [
5528+ "form_urlencoded",
5529+ "idna",
5530+ "percent-encoding",
5531+ "serde",
5532+ "serde_derive",
5533+]
5534+
5535+[[package]]
5536+name = "utf8_iter"
5537+version = "1.0.4"
5538+source = "registry+https://github.com/rust-lang/crates.io-index"
5539+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
5540+
5541+[[package]]
5542+name = "utf8parse"
5543+version = "0.2.2"
5544+source = "registry+https://github.com/rust-lang/crates.io-index"
5545+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
5546+
5547+[[package]]
5548+name = "uuid"
5549+version = "1.24.0"
5550+source = "registry+https://github.com/rust-lang/crates.io-index"
5551+checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
5552+dependencies = [
5553+ "getrandom 0.4.3",
5554+ "js-sys",
5555+ "serde_core",
5556+ "wasm-bindgen",
5557+]
5558+
5559+[[package]]
5560+name = "valuable"
5561+version = "0.1.1"
5562+source = "registry+https://github.com/rust-lang/crates.io-index"
5563+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
5564+
5565+[[package]]
5566+name = "vcpkg"
5567+version = "0.2.15"
5568+source = "registry+https://github.com/rust-lang/crates.io-index"
5569+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
5570+
5571+[[package]]
5572+name = "version_check"
5573+version = "0.9.5"
5574+source = "registry+https://github.com/rust-lang/crates.io-index"
5575+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
5576+
5577+[[package]]
5578+name = "walkdir"
5579+version = "2.5.0"
5580+source = "registry+https://github.com/rust-lang/crates.io-index"
5581+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
5582+dependencies = [
5583+ "same-file",
5584+ "winapi-util",
5585+]
5586+
5587+[[package]]
5588+name = "want"
5589+version = "0.3.1"
5590+source = "registry+https://github.com/rust-lang/crates.io-index"
5591+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
5592+dependencies = [
5593+ "try-lock",
5594+]
5595+
5596+[[package]]
5597+name = "wasi"
5598+version = "0.11.1+wasi-snapshot-preview1"
5599+source = "registry+https://github.com/rust-lang/crates.io-index"
5600+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
5601+
5602+[[package]]
5603+name = "wasite"
5604+version = "0.1.0"
5605+source = "registry+https://github.com/rust-lang/crates.io-index"
5606+checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
5607+
5608+[[package]]
5609+name = "wasm-bindgen"
5610+version = "0.2.126"
5611+source = "registry+https://github.com/rust-lang/crates.io-index"
5612+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
5613+dependencies = [
5614+ "cfg-if",
5615+ "once_cell",
5616+ "rustversion",
5617+ "wasm-bindgen-macro",
5618+ "wasm-bindgen-shared",
5619+]
5620+
5621+[[package]]
5622+name = "wasm-bindgen-futures"
5623+version = "0.4.76"
5624+source = "registry+https://github.com/rust-lang/crates.io-index"
5625+checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
5626+dependencies = [
5627+ "js-sys",
5628+ "wasm-bindgen",
5629+]
5630+
5631+[[package]]
5632+name = "wasm-bindgen-macro"
5633+version = "0.2.126"
5634+source = "registry+https://github.com/rust-lang/crates.io-index"
5635+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
5636+dependencies = [
5637+ "quote",
5638+ "wasm-bindgen-macro-support",
5639+]
5640+
5641+[[package]]
5642+name = "wasm-bindgen-macro-support"
5643+version = "0.2.126"
5644+source = "registry+https://github.com/rust-lang/crates.io-index"
5645+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
5646+dependencies = [
5647+ "bumpalo",
5648+ "proc-macro2",
5649+ "quote",
5650+ "syn 2.0.119",
5651+ "wasm-bindgen-shared",
5652+]
5653+
5654+[[package]]
5655+name = "wasm-bindgen-shared"
5656+version = "0.2.126"
5657+source = "registry+https://github.com/rust-lang/crates.io-index"
5658+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
5659+dependencies = [
5660+ "unicode-ident",
5661+]
5662+
5663+[[package]]
5664+name = "web-sys"
5665+version = "0.3.103"
5666+source = "registry+https://github.com/rust-lang/crates.io-index"
5667+checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
5668+dependencies = [
5669+ "js-sys",
5670+ "wasm-bindgen",
5671+]
5672+
5673+[[package]]
5674+name = "web-time"
5675+version = "1.1.0"
5676+source = "registry+https://github.com/rust-lang/crates.io-index"
5677+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
5678+dependencies = [
5679+ "js-sys",
5680+ "wasm-bindgen",
5681+]
5682+
5683+[[package]]
5684+name = "web_atoms"
5685+version = "0.2.5"
5686+source = "registry+https://github.com/rust-lang/crates.io-index"
5687+checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297"
5688+dependencies = [
5689+ "phf",
5690+ "phf_codegen",
5691+ "string_cache",
5692+ "string_cache_codegen",
5693+]
5694+
5695+[[package]]
5696+name = "webpki-roots"
5697+version = "0.26.11"
5698+source = "registry+https://github.com/rust-lang/crates.io-index"
5699+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
5700+dependencies = [
5701+ "webpki-roots 1.0.9",
5702+]
5703+
5704+[[package]]
5705+name = "webpki-roots"
5706+version = "1.0.9"
5707+source = "registry+https://github.com/rust-lang/crates.io-index"
5708+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
5709+dependencies = [
5710+ "rustls-pki-types",
5711+]
5712+
5713+[[package]]
5714+name = "whoami"
5715+version = "1.6.1"
5716+source = "registry+https://github.com/rust-lang/crates.io-index"
5717+checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
5718+dependencies = [
5719+ "libredox",
5720+ "wasite",
5721+]
5722+
5723+[[package]]
5724+name = "winapi"
5725+version = "0.3.9"
5726+source = "registry+https://github.com/rust-lang/crates.io-index"
5727+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
5728+dependencies = [
5729+ "winapi-i686-pc-windows-gnu",
5730+ "winapi-x86_64-pc-windows-gnu",
5731+]
5732+
5733+[[package]]
5734+name = "winapi-i686-pc-windows-gnu"
5735+version = "0.4.0"
5736+source = "registry+https://github.com/rust-lang/crates.io-index"
5737+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
5738+
5739+[[package]]
5740+name = "winapi-util"
5741+version = "0.1.11"
5742+source = "registry+https://github.com/rust-lang/crates.io-index"
5743+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
5744+dependencies = [
5745+ "windows-sys 0.61.2",
5746+]
5747+
5748+[[package]]
5749+name = "winapi-x86_64-pc-windows-gnu"
5750+version = "0.4.0"
5751+source = "registry+https://github.com/rust-lang/crates.io-index"
5752+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
5753+
5754+[[package]]
5755+name = "windows"
5756+version = "0.58.0"
5757+source = "registry+https://github.com/rust-lang/crates.io-index"
5758+checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
5759+dependencies = [
5760+ "windows-core 0.58.0",
5761+ "windows-targets 0.52.6",
5762+]
5763+
5764+[[package]]
5765+name = "windows-core"
5766+version = "0.58.0"
5767+source = "registry+https://github.com/rust-lang/crates.io-index"
5768+checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
5769+dependencies = [
5770+ "windows-implement 0.58.0",
5771+ "windows-interface 0.58.0",
5772+ "windows-result 0.2.0",
5773+ "windows-strings 0.1.0",
5774+ "windows-targets 0.52.6",
5775+]
5776+
5777+[[package]]
5778+name = "windows-core"
5779+version = "0.62.2"
5780+source = "registry+https://github.com/rust-lang/crates.io-index"
5781+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
5782+dependencies = [
5783+ "windows-implement 0.60.2",
5784+ "windows-interface 0.59.3",
5785+ "windows-link",
5786+ "windows-result 0.4.1",
5787+ "windows-strings 0.5.1",
5788+]
5789+
5790+[[package]]
5791+name = "windows-implement"
5792+version = "0.58.0"
5793+source = "registry+https://github.com/rust-lang/crates.io-index"
5794+checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
5795+dependencies = [
5796+ "proc-macro2",
5797+ "quote",
5798+ "syn 2.0.119",
5799+]
5800+
5801+[[package]]
5802+name = "windows-implement"
5803+version = "0.60.2"
5804+source = "registry+https://github.com/rust-lang/crates.io-index"
5805+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
5806+dependencies = [
5807+ "proc-macro2",
5808+ "quote",
5809+ "syn 2.0.119",
5810+]
5811+
5812+[[package]]
5813+name = "windows-interface"
5814+version = "0.58.0"
5815+source = "registry+https://github.com/rust-lang/crates.io-index"
5816+checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
5817+dependencies = [
5818+ "proc-macro2",
5819+ "quote",
5820+ "syn 2.0.119",
5821+]
5822+
5823+[[package]]
5824+name = "windows-interface"
5825+version = "0.59.3"
5826+source = "registry+https://github.com/rust-lang/crates.io-index"
5827+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
5828+dependencies = [
5829+ "proc-macro2",
5830+ "quote",
5831+ "syn 2.0.119",
5832+]
5833+
5834+[[package]]
5835+name = "windows-link"
5836+version = "0.2.1"
5837+source = "registry+https://github.com/rust-lang/crates.io-index"
5838+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
5839+
5840+[[package]]
5841+name = "windows-result"
5842+version = "0.2.0"
5843+source = "registry+https://github.com/rust-lang/crates.io-index"
5844+checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
5845+dependencies = [
5846+ "windows-targets 0.52.6",
5847+]
5848+
5849+[[package]]
5850+name = "windows-result"
5851+version = "0.4.1"
5852+source = "registry+https://github.com/rust-lang/crates.io-index"
5853+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
5854+dependencies = [
5855+ "windows-link",
5856+]
5857+
5858+[[package]]
5859+name = "windows-strings"
5860+version = "0.1.0"
5861+source = "registry+https://github.com/rust-lang/crates.io-index"
5862+checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
5863+dependencies = [
5864+ "windows-result 0.2.0",
5865+ "windows-targets 0.52.6",
5866+]
5867+
5868+[[package]]
5869+name = "windows-strings"
5870+version = "0.5.1"
5871+source = "registry+https://github.com/rust-lang/crates.io-index"
5872+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
5873+dependencies = [
5874+ "windows-link",
5875+]
5876+
5877+[[package]]
5878+name = "windows-sys"
5879+version = "0.48.0"
5880+source = "registry+https://github.com/rust-lang/crates.io-index"
5881+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
5882+dependencies = [
5883+ "windows-targets 0.48.5",
5884+]
5885+
5886+[[package]]
5887+name = "windows-sys"
5888+version = "0.52.0"
5889+source = "registry+https://github.com/rust-lang/crates.io-index"
5890+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
5891+dependencies = [
5892+ "windows-targets 0.52.6",
5893+]
5894+
5895+[[package]]
5896+name = "windows-sys"
5897+version = "0.61.2"
5898+source = "registry+https://github.com/rust-lang/crates.io-index"
5899+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
5900+dependencies = [
5901+ "windows-link",
5902+]
5903+
5904+[[package]]
5905+name = "windows-targets"
5906+version = "0.48.5"
5907+source = "registry+https://github.com/rust-lang/crates.io-index"
5908+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
5909+dependencies = [
5910+ "windows_aarch64_gnullvm 0.48.5",
5911+ "windows_aarch64_msvc 0.48.5",
5912+ "windows_i686_gnu 0.48.5",
5913+ "windows_i686_msvc 0.48.5",
5914+ "windows_x86_64_gnu 0.48.5",
5915+ "windows_x86_64_gnullvm 0.48.5",
5916+ "windows_x86_64_msvc 0.48.5",
5917+]
5918+
5919+[[package]]
5920+name = "windows-targets"
5921+version = "0.52.6"
5922+source = "registry+https://github.com/rust-lang/crates.io-index"
5923+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
5924+dependencies = [
5925+ "windows_aarch64_gnullvm 0.52.6",
5926+ "windows_aarch64_msvc 0.52.6",
5927+ "windows_i686_gnu 0.52.6",
5928+ "windows_i686_gnullvm",
5929+ "windows_i686_msvc 0.52.6",
5930+ "windows_x86_64_gnu 0.52.6",
5931+ "windows_x86_64_gnullvm 0.52.6",
5932+ "windows_x86_64_msvc 0.52.6",
5933+]
5934+
5935+[[package]]
5936+name = "windows_aarch64_gnullvm"
5937+version = "0.48.5"
5938+source = "registry+https://github.com/rust-lang/crates.io-index"
5939+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
5940+
5941+[[package]]
5942+name = "windows_aarch64_gnullvm"
5943+version = "0.52.6"
5944+source = "registry+https://github.com/rust-lang/crates.io-index"
5945+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
5946+
5947+[[package]]
5948+name = "windows_aarch64_msvc"
5949+version = "0.48.5"
5950+source = "registry+https://github.com/rust-lang/crates.io-index"
5951+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
5952+
5953+[[package]]
5954+name = "windows_aarch64_msvc"
5955+version = "0.52.6"
5956+source = "registry+https://github.com/rust-lang/crates.io-index"
5957+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
5958+
5959+[[package]]
5960+name = "windows_i686_gnu"
5961+version = "0.48.5"
5962+source = "registry+https://github.com/rust-lang/crates.io-index"
5963+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
5964+
5965+[[package]]
5966+name = "windows_i686_gnu"
5967+version = "0.52.6"
5968+source = "registry+https://github.com/rust-lang/crates.io-index"
5969+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
5970+
5971+[[package]]
5972+name = "windows_i686_gnullvm"
5973+version = "0.52.6"
5974+source = "registry+https://github.com/rust-lang/crates.io-index"
5975+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
5976+
5977+[[package]]
5978+name = "windows_i686_msvc"
5979+version = "0.48.5"
5980+source = "registry+https://github.com/rust-lang/crates.io-index"
5981+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
5982+
5983+[[package]]
5984+name = "windows_i686_msvc"
5985+version = "0.52.6"
5986+source = "registry+https://github.com/rust-lang/crates.io-index"
5987+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
5988+
5989+[[package]]
5990+name = "windows_x86_64_gnu"
5991+version = "0.48.5"
5992+source = "registry+https://github.com/rust-lang/crates.io-index"
5993+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
5994+
5995+[[package]]
5996+name = "windows_x86_64_gnu"
5997+version = "0.52.6"
5998+source = "registry+https://github.com/rust-lang/crates.io-index"
5999+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
6000+
6001+[[package]]
6002+name = "windows_x86_64_gnullvm"
6003+version = "0.48.5"
6004+source = "registry+https://github.com/rust-lang/crates.io-index"
6005+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
6006+
6007+[[package]]
6008+name = "windows_x86_64_gnullvm"
6009+version = "0.52.6"
6010+source = "registry+https://github.com/rust-lang/crates.io-index"
6011+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
6012+
6013+[[package]]
6014+name = "windows_x86_64_msvc"
6015+version = "0.48.5"
6016+source = "registry+https://github.com/rust-lang/crates.io-index"
6017+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
6018+
6019+[[package]]
6020+name = "windows_x86_64_msvc"
6021+version = "0.52.6"
6022+source = "registry+https://github.com/rust-lang/crates.io-index"
6023+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
6024+
6025+[[package]]
6026+name = "winnow"
6027+version = "0.6.26"
6028+source = "registry+https://github.com/rust-lang/crates.io-index"
6029+checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28"
6030+dependencies = [
6031+ "memchr",
6032+]
6033+
6034+[[package]]
6035+name = "writeable"
6036+version = "0.6.3"
6037+source = "registry+https://github.com/rust-lang/crates.io-index"
6038+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
6039+
6040+[[package]]
6041+name = "xdg"
6042+version = "2.5.2"
6043+source = "registry+https://github.com/rust-lang/crates.io-index"
6044+checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546"
6045+
6046+[[package]]
6047+name = "yaml-rust"
6048+version = "0.4.5"
6049+source = "registry+https://github.com/rust-lang/crates.io-index"
6050+checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
6051+dependencies = [
6052+ "linked-hash-map",
6053+]
6054+
6055+[[package]]
6056+name = "yoke"
6057+version = "0.8.3"
6058+source = "registry+https://github.com/rust-lang/crates.io-index"
6059+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
6060+dependencies = [
6061+ "stable_deref_trait",
6062+ "yoke-derive",
6063+ "zerofrom",
6064+]
6065+
6066+[[package]]
6067+name = "yoke-derive"
6068+version = "0.8.2"
6069+source = "registry+https://github.com/rust-lang/crates.io-index"
6070+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
6071+dependencies = [
6072+ "proc-macro2",
6073+ "quote",
6074+ "syn 2.0.119",
6075+ "synstructure",
6076+]
6077+
6078+[[package]]
6079+name = "zerocopy"
6080+version = "0.8.55"
6081+source = "registry+https://github.com/rust-lang/crates.io-index"
6082+checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
6083+dependencies = [
6084+ "zerocopy-derive",
6085+]
6086+
6087+[[package]]
6088+name = "zerocopy-derive"
6089+version = "0.8.55"
6090+source = "registry+https://github.com/rust-lang/crates.io-index"
6091+checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
6092+dependencies = [
6093+ "proc-macro2",
6094+ "quote",
6095+ "syn 2.0.119",
6096+]
6097+
6098+[[package]]
6099+name = "zerofrom"
6100+version = "0.1.8"
6101+source = "registry+https://github.com/rust-lang/crates.io-index"
6102+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
6103+dependencies = [
6104+ "zerofrom-derive",
6105+]
6106+
6107+[[package]]
6108+name = "zerofrom-derive"
6109+version = "0.1.7"
6110+source = "registry+https://github.com/rust-lang/crates.io-index"
6111+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
6112+dependencies = [
6113+ "proc-macro2",
6114+ "quote",
6115+ "syn 2.0.119",
6116+ "synstructure",
6117+]
6118+
6119+[[package]]
6120+name = "zeroize"
6121+version = "1.9.0"
6122+source = "registry+https://github.com/rust-lang/crates.io-index"
6123+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
6124+
6125+[[package]]
6126+name = "zerotrie"
6127+version = "0.2.4"
6128+source = "registry+https://github.com/rust-lang/crates.io-index"
6129+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
6130+dependencies = [
6131+ "displaydoc",
6132+ "yoke",
6133+ "zerofrom",
6134+]
6135+
6136+[[package]]
6137+name = "zerovec"
6138+version = "0.11.6"
6139+source = "registry+https://github.com/rust-lang/crates.io-index"
6140+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
6141+dependencies = [
6142+ "yoke",
6143+ "zerofrom",
6144+ "zerovec-derive",
6145+]
6146+
6147+[[package]]
6148+name = "zerovec-derive"
6149+version = "0.11.3"
6150+source = "registry+https://github.com/rust-lang/crates.io-index"
6151+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
6152+dependencies = [
6153+ "proc-macro2",
6154+ "quote",
6155+ "syn 2.0.119",
6156+]
6157+
6158+[[package]]
6159+name = "zmij"
6160+version = "1.0.23"
6161+source = "registry+https://github.com/rust-lang/crates.io-index"
6162+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
ACargo.toml+107−0
@@ −0,0 +1,107 @@
1+[workspace]
2+resolver = "2"
3+# Crates are added here as they land, so `cargo test` at the workspace root is
4+# green at every commit rather than only at the end.
5+members = [
6+ "crates/df-index",
7+ "crates/df-db",
8+ "crates/df-auth",
9+ "crates/df-store",
10+ "crates/df-render",
11+ "crates/df-worker",
12+ "crates/df-admin",
13+ "crates/df-hook",
14+ "crates/df-ssh",
15+ "crates/df-web",
16+]
17+
18+[workspace.package]
19+version = "0.1.0"
20+edition = "2021"
21+rust-version = "1.85"
22+license = "Apache-2.0"
23+
24+[workspace.dependencies]
25+# ─── async runtime ───────────────────────────────────────────────────────────
26+tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "signal", "process", "time", "fs", "sync"] }
27+async-trait = "0.1"
28+futures = "0.3"
29+
30+# ─── errors, logging ─────────────────────────────────────────────────────────
31+anyhow = "1"
32+thiserror = "2"
33+tracing = "0.1"
34+tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
35+
36+# ─── database ────────────────────────────────────────────────────────────────
37+# `postgres` + rustls: the provided instance requires sslmode=require.
38+sqlx = { version = "0.8", default-features = false, features = [
39+ "runtime-tokio", "tls-rustls-aws-lc-rs", "postgres", "uuid", "chrono",
40+ "json", "macros", "migrate", "ipnetwork",
41+] }
42+uuid = { version = "1", features = ["v7", "serde"] }
43+chrono = { version = "0.4", default-features = false, features = ["std", "serde", "clock"] }
44+
45+# ─── git plumbing ────────────────────────────────────────────────────────────
46+# CONSTRAINT (spec §3 rule 1): `gix` may appear ONLY in df-store's dependency
47+# list. This is enforced by `scripts/check-store-boundary.sh` in CI.
48+gix = { version = "0.68", default-features = false, features = [
49+ "max-performance-safe", "blob-diff", "revision", "attributes",
50+ # Landing a change server-side (decided §13.2: fast-forward + merge commit).
51+ # `merge` brings the three-way tree merge; `tree-editor` writes its result.
52+ "merge", "tree-editor",
53+] }
54+
55+# ─── web ─────────────────────────────────────────────────────────────────────
56+axum = { version = "0.8", features = ["macros", "http2"] }
57+axum-extra = { version = "0.10", features = ["cookie", "typed-header"] }
58+tower = { version = "0.5", features = ["util", "timeout", "limit"] }
59+tower-http = { version = "0.6", features = ["trace", "compression-gzip", "set-header", "catch-panic", "limit"] }
60+maud = { version = "0.27", features = ["axum"] }
61+http = "1"
62+hyper = "1"
63+mime_guess = "2"
64+
65+# ─── auth ────────────────────────────────────────────────────────────────────
66+openidconnect = "4"
67+argon2 = "0.5"
68+rand = "0.8"
69+base64 = "0.22"
70+hmac = "0.12"
71+sha2 = "0.10"
72+subtle = "2"
73+
74+# ─── ssh ─────────────────────────────────────────────────────────────────────
75+russh = "0.51"
76+russh-keys = "0.49"
77+# Parsing user-pasted public keys in df-auth. Upstream `ssh-key` rather than
78+# russh's vendored fork: the SHA256 fingerprint format is standardised, so both
79+# sides agree, and df-auth does not need to pull in an SSH server.
80+ssh-key = { version = "0.6", features = ["ed25519", "ecdsa", "rsa"] }
81+
82+# ─── rendering ───────────────────────────────────────────────────────────────
83+comrak = "0.39"
84+ammonia = "4"
85+tree-sitter = "0.25"
86+tree-sitter-highlight = "0.25"
87+similar = { version = "2", features = ["text", "inline"] }
88+
89+# ─── misc ────────────────────────────────────────────────────────────────────
90+serde = { version = "1", features = ["derive"] }
91+serde_json = "1"
92+reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
93+lru = "0.12"
94+url = "2"
95+percent-encoding = "2"
96+clap = { version = "4", features = ["derive", "env"] }
97+dotenvy = "0.15"
98+hex = "0.4"
99+
100+[profile.release]
101+# The indexer walks large object graphs; LTO and a single codegen unit are worth
102+# the build time. `panic = "abort"` is deliberately NOT set — df-web catches
103+# panics per-request via tower-http's CatchPanic so one bad diff cannot take the
104+# process down (spec §8).
105+lto = "thin"
106+codegen-units = 1
107+strip = "debuginfo"
Acrates/df-admin/Cargo.toml+23−0
@@ −0,0 +1,23 @@
1+[package]
2+name = "df-admin"
3+version.workspace = true
4+edition.workspace = true
5+rust-version.workspace = true
6+license.workspace = true
7+
8+[[bin]]
9+name = "dogfood-admin"
10+path = "src/main.rs"
11+
12+[dependencies]
13+df-db = { path = "../df-db" }
14+df-auth = { path = "../df-auth" }
15+
16+anyhow.workspace = true
17+chrono.workspace = true
18+clap.workspace = true
19+dotenvy.workspace = true
20+serde_json.workspace = true
21+sqlx.workspace = true
22+tokio.workspace = true
23+uuid.workspace = true
Acrates/df-admin/src/main.rs+218−0
@@ −0,0 +1,218 @@
1+//! `dogfood-admin` — operational CLI.
2+//!
3+//! Spec §4 requires `dogfood-admin reindex --repo <id>`, safe to run against a
4+//! live repository, "because you will need it every time indexing logic
5+//! changes". Spec §10 adds `reindex --all` as the recovery path when the
6+//! database and the repository volume drift apart after a restore.
7+
8+use anyhow::{Context, Result};
9+use clap::{Parser, Subcommand};
10+use df_db::ids::new_id;
11+use uuid::Uuid;
12+
13+#[derive(Parser)]
14+#[command(name = "dogfood-admin", about = "Dogfood operational commands")]
15+struct Cli {
16+ #[arg(long, env = "DATABASE_URL")]
17+ database_url: String,
18+
19+ #[command(subcommand)]
20+ command: Command,
21+}
22+
23+#[derive(Subcommand)]
24+enum Command {
25+ /// Re-run indexing. Safe against a live repository.
26+ Reindex {
27+ /// Repository UUID. Omit with --all.
28+ #[arg(long)]
29+ repo: Option<Uuid>,
30+ /// Reindex every repository.
31+ #[arg(long)]
32+ all: bool,
33+ },
34+ /// Manage personal access tokens.
35+ Token {
36+ #[command(subcommand)]
37+ command: TokenCommand,
38+ },
39+ /// Manage users.
40+ User {
41+ #[command(subcommand)]
42+ command: UserCommand,
43+ },
44+ /// Invite an email address to a closed instance.
45+ Invite {
46+ #[arg(long)]
47+ email: String,
48+ },
49+}
50+
51+#[derive(Subcommand)]
52+enum TokenCommand {
53+ /// Mint a token. The plaintext is printed once and never stored.
54+ Create {
55+ #[arg(long)]
56+ handle: String,
57+ #[arg(long, default_value = "admin-cli")]
58+ name: String,
59+ },
60+ List {
61+ #[arg(long)]
62+ handle: String,
63+ },
64+}
65+
66+#[derive(Subcommand)]
67+enum UserCommand {
68+ List,
69+ /// Grant or revoke site admin.
70+ Admin {
71+ #[arg(long)]
72+ handle: String,
73+ #[arg(long)]
74+ revoke: bool,
75+ },
76+}
77+
78+#[tokio::main]
79+async fn main() -> Result<()> {
80+ let _ = dotenvy::dotenv();
81+ let cli = Cli::parse();
82+
83+ let db = df_db::connect(&cli.database_url, 4)
84+ .await
85+ .context("connecting to the database")?;
86+
87+ match cli.command {
88+ Command::Reindex { repo, all } => reindex(&db, repo, all).await,
89+ Command::Token { command } => match command {
90+ TokenCommand::Create { handle, name } => create_token(&db, &handle, &name).await,
91+ TokenCommand::List { handle } => list_tokens(&db, &handle).await,
92+ },
93+ Command::User { command } => match command {
94+ UserCommand::List => list_users(&db).await,
95+ UserCommand::Admin { handle, revoke } => set_admin(&db, &handle, !revoke).await,
96+ },
97+ Command::Invite { email } => invite(&db, &email).await,
98+ }
99+}
100+
101+/// Enqueue indexing rather than doing it inline.
102+///
103+/// The worker owns indexing, so routing through the queue means the CLI and a
104+/// push take exactly the same code path — there is no second implementation to
105+/// drift.
106+async fn reindex(db: &df_db::PgPool, repo: Option<Uuid>, all: bool) -> Result<()> {
107+ let repos: Vec<(Uuid,)> = if all {
108+ sqlx::query_as("SELECT id FROM repos ORDER BY created_at")
109+ .fetch_all(db)
110+ .await?
111+ } else {
112+ let id = repo.context("pass --repo <uuid> or --all")?;
113+ sqlx::query_as("SELECT id FROM repos WHERE id = $1")
114+ .bind(id)
115+ .fetch_all(db)
116+ .await?
117+ };
118+
119+ if repos.is_empty() {
120+ println!("no matching repositories");
121+ return Ok(());
122+ }
123+
124+ for (id,) in &repos {
125+ sqlx::query("INSERT INTO jobs (id, kind, payload) VALUES ($1, 'index_push', $2)")
126+ .bind(new_id())
127+ .bind(serde_json::json!({ "repo_id": id }))
128+ .execute(db)
129+ .await?;
130+ println!("queued reindex for {id}");
131+ }
132+
133+ println!("\n{} job(s) queued; the worker will pick them up.", repos.len());
134+ Ok(())
135+}
136+
137+async fn create_token(db: &df_db::PgPool, handle: &str, name: &str) -> Result<()> {
138+ let (user_id,): (Uuid,) = sqlx::query_as("SELECT id FROM users WHERE handle = $1")
139+ .bind(handle)
140+ .fetch_optional(db)
141+ .await?
142+ .with_context(|| format!("no user with handle {handle}"))?;
143+
144+ let token = df_auth::tokens::create(db, user_id, name, &[], None).await?;
145+
146+ println!("Token created for {handle}.");
147+ println!("\n {}\n", token.plaintext);
148+ println!("This is shown once — only an Argon2id hash is stored.");
149+ println!("Use it as the password for git over HTTPS, with {handle} as the username.");
150+ Ok(())
151+}
152+
153+async fn list_tokens(db: &df_db::PgPool, handle: &str) -> Result<()> {
154+ let rows: Vec<(String, String, Option<chrono::DateTime<chrono::Utc>>)> = sqlx::query_as(
155+ "SELECT t.name, t.prefix, t.last_used_at
156+ FROM access_tokens t JOIN users u ON u.id = t.user_id
157+ WHERE u.handle = $1 ORDER BY t.created_at DESC",
158+ )
159+ .bind(handle)
160+ .fetch_all(db)
161+ .await?;
162+
163+ if rows.is_empty() {
164+ println!("no tokens for {handle}");
165+ return Ok(());
166+ }
167+ for (name, prefix, used) in rows {
168+ println!(
169+ "{prefix}… {name:<24} last used: {}",
170+ used.map(|t| t.to_rfc3339()).unwrap_or_else(|| "never".into())
171+ );
172+ }
173+ Ok(())
174+}
175+
176+async fn list_users(db: &df_db::PgPool) -> Result<()> {
177+ let rows: Vec<(String, Option<String>, bool)> =
178+ sqlx::query_as("SELECT handle::text, email::text, is_admin FROM users ORDER BY created_at")
179+ .fetch_all(db)
180+ .await?;
181+ for (handle, email, admin) in rows {
182+ println!(
183+ "{handle:<20} {:<32} {}",
184+ email.unwrap_or_else(|| "(no email)".into()),
185+ if admin { "admin" } else { "" }
186+ );
187+ }
188+ Ok(())
189+}
190+
191+async fn set_admin(db: &df_db::PgPool, handle: &str, grant: bool) -> Result<()> {
192+ let r = sqlx::query("UPDATE users SET is_admin = $2 WHERE handle = $1")
193+ .bind(handle)
194+ .bind(grant)
195+ .execute(db)
196+ .await?;
197+ if r.rows_affected() == 0 {
198+ anyhow::bail!("no user with handle {handle}");
199+ }
200+ println!(
201+ "{handle} is {} a site administrator",
202+ if grant { "now" } else { "no longer" }
203+ );
204+ Ok(())
205+}
206+
207+async fn invite(db: &df_db::PgPool, email: &str) -> Result<()> {
208+ sqlx::query(
209+ "INSERT INTO invitations (id, email) VALUES ($1, $2)
210+ ON CONFLICT (email) DO NOTHING",
211+ )
212+ .bind(new_id())
213+ .bind(email)
214+ .execute(db)
215+ .await?;
216+ println!("{email} may now sign in.");
217+ Ok(())
218+}
Acrates/df-auth/Cargo.toml+26−0
@@ −0,0 +1,26 @@
1+[package]
2+name = "df-auth"
3+version.workspace = true
4+edition.workspace = true
5+rust-version.workspace = true
6+license.workspace = true
7+
8+[dependencies]
9+df-db = { path = "../df-db" }
10+
11+anyhow.workspace = true
12+argon2.workspace = true
13+chrono.workspace = true
14+hex.workspace = true
15+hmac.workspace = true
16+openidconnect.workspace = true
17+rand.workspace = true
18+serde.workspace = true
19+serde_json.workspace = true
20+sha2.workspace = true
21+sqlx.workspace = true
22+ssh-key.workspace = true
23+thiserror.workspace = true
24+subtle.workspace = true
25+tracing.workspace = true
26+uuid.workspace = true
Acrates/df-auth/src/csrf.rs+134−0
@@ −0,0 +1,134 @@
1+//! CSRF protection: double-submit cookie (spec §7).
2+//!
3+//! "CSRF: double-submit cookie, validated on every non-GET. Add the token via
4+//! `hx-headers` on the body element."
5+//!
6+//! The cookie value is `<random>.<hmac>` so the server can tell its own token
7+//! from one an attacker planted via a cookie-injection bug on a sibling
8+//! subdomain. Cookies do not respect origin boundaries the way headers do, so a
9+//! plain unauthenticated random value is weaker than it looks.
10+
11+use hmac::{Hmac, Mac};
12+use rand::RngCore;
13+use sha2::Sha256;
14+use subtle::ConstantTimeEq;
15+
16+pub const COOKIE_NAME: &str = "dogfood_csrf";
17+pub const HEADER_NAME: &str = "x-csrf-token";
18+pub const FORM_FIELD: &str = "_csrf";
19+
20+type HmacSha256 = Hmac<Sha256>;
21+
22+/// Mint a signed CSRF token.
23+pub fn issue(secret: &[u8]) -> String {
24+ let mut nonce = [0u8; 16];
25+ rand::thread_rng().fill_bytes(&mut nonce);
26+ let nonce_hex = hex::encode(nonce);
27+ let tag = sign(secret, &nonce_hex);
28+ format!("{nonce_hex}.{tag}")
29+}
30+
31+fn sign(secret: &[u8], nonce: &str) -> String {
32+ let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
33+ mac.update(nonce.as_bytes());
34+ hex::encode(mac.finalize().into_bytes())
35+}
36+
37+/// Whether a token is well-formed and carries our signature.
38+pub fn is_valid(secret: &[u8], token: &str) -> bool {
39+ let Some((nonce, tag)) = token.split_once('.') else {
40+ return false;
41+ };
42+ if nonce.len() != 32 || tag.len() != 64 {
43+ return false;
44+ }
45+ let expected = sign(secret, nonce);
46+ expected.as_bytes().ct_eq(tag.as_bytes()).into()
47+}
48+
49+/// Validate a request: the cookie and the submitted value must both be present,
50+/// both be authentic, and match each other.
51+///
52+/// Comparison is constant-time. The equality check matters as much as the
53+/// signature: an attacker who can plant a cookie but cannot read it still must
54+/// not be able to submit a matching header.
55+pub fn verify(secret: &[u8], cookie_value: Option<&str>, submitted: Option<&str>) -> bool {
56+ let (Some(cookie), Some(submitted)) = (cookie_value, submitted) else {
57+ return false;
58+ };
59+ if !is_valid(secret, cookie) {
60+ return false;
61+ }
62+ let eq: bool = cookie.as_bytes().ct_eq(submitted.as_bytes()).into();
63+ eq
64+}
65+
66+#[cfg(test)]
67+mod tests {
68+ use super::*;
69+
70+ const SECRET: &[u8] = b"test-secret-value-for-csrf-tokens";
71+
72+ #[test]
73+ fn issued_tokens_validate() {
74+ let t = issue(SECRET);
75+ assert!(is_valid(SECRET, &t));
76+ assert!(verify(SECRET, Some(&t), Some(&t)));
77+ }
78+
79+ #[test]
80+ fn tokens_are_unique_per_issue() {
81+ assert_ne!(issue(SECRET), issue(SECRET));
82+ }
83+
84+ #[test]
85+ fn rejects_a_token_signed_with_another_secret() {
86+ // The attack a plain random double-submit cookie does not stop: an
87+ // attacker who can set cookies on the domain plants a value they know.
88+ let planted = issue(b"attacker-secret");
89+ assert!(!is_valid(SECRET, &planted));
90+ assert!(!verify(SECRET, Some(&planted), Some(&planted)));
91+ }
92+
93+ #[test]
94+ fn rejects_mismatched_cookie_and_submission() {
95+ let a = issue(SECRET);
96+ let b = issue(SECRET);
97+ assert!(
98+ !verify(SECRET, Some(&a), Some(&b)),
99+ "two individually-valid tokens must still have to match each other"
100+ );
101+ }
102+
103+ #[test]
104+ fn rejects_missing_parts() {
105+ let t = issue(SECRET);
106+ assert!(!verify(SECRET, None, Some(&t)));
107+ assert!(!verify(SECRET, Some(&t), None));
108+ assert!(!verify(SECRET, None, None));
109+ }
110+
111+ #[test]
112+ fn rejects_malformed_tokens() {
113+ for bad in [
114+ "",
115+ "nodot",
116+ ".",
117+ "short.short",
118+ &format!("{}.{}", "a".repeat(32), "b".repeat(64)), // right shape, wrong tag
119+ &format!("{}.{}", "a".repeat(31), "b".repeat(64)),
120+ ] {
121+ assert!(!is_valid(SECRET, bad), "must reject {bad:?}");
122+ }
123+ }
124+
125+ #[test]
126+ fn a_tampered_nonce_invalidates_the_signature() {
127+ let t = issue(SECRET);
128+ let (nonce, tag) = t.split_once('.').unwrap();
129+ let mut n: Vec<char> = nonce.chars().collect();
130+ n[0] = if n[0] == 'a' { 'b' } else { 'a' };
131+ let tampered = format!("{}.{}", n.into_iter().collect::<String>(), tag);
132+ assert!(!is_valid(SECRET, &tampered));
133+ }
134+}
Acrates/df-auth/src/lib.rs+17−0
@@ −0,0 +1,17 @@
1+//! `df-auth` — authentication and authorization.
2+//!
3+//! Humans authenticate through OIDC against Ory Hydra. Machines authenticate
4+//! with personal access tokens (HTTP) or SSH public keys, because Git clients
5+//! cannot perform a browser flow (spec §6).
6+
7+pub mod csrf;
8+pub mod oidc;
9+pub mod permissions;
10+pub mod provisioning;
11+pub mod session;
12+pub mod ssh_keys;
13+pub mod tokens;
14+
15+pub use oidc::{safe_redirect, Identity, Oidc};
16+pub use permissions::{resolve, AccessInputs, RepoAccess, Viewer};
17+pub use provisioning::Denied;
Acrates/df-auth/src/oidc.rs473 lines+473−0
@@ −0,0 +1,473 @@
1+//! OIDC relying party against Ory Hydra (spec §6).
2+//!
3+//! Authorization code flow with PKCE. The PKCE verifier, nonce, and post-login
4+//! redirect are held server-side in `auth_flows` and keyed by an opaque state
5+//! parameter, so nothing sensitive round-trips through the browser.
6+//!
7+//! Users are keyed on the `sub` claim, never on email — "emails change and are
8+//! reassigned" (spec §6). On this deployment `sub` is the Kratos identity UUID,
9+//! minted by the Account Experience consent handler at sso.dogfood.sh.
10+
11+use anyhow::{anyhow, Context, Result};
12+use chrono::{Duration, Utc};
13+use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata};
14+use openidconnect::reqwest;
15+use openidconnect::{
16+ AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, OAuth2TokenResponse,
17+ PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, TokenResponse,
18+};
19+use sqlx::PgPool;
20+
21+/// Claims Dogfood needs from a completed login.
22+#[derive(Debug, Clone)]
23+pub struct Identity {
24+ /// The OIDC `sub`. The stable key for `users.subject`.
25+ pub subject: String,
26+ pub email: Option<String>,
27+ pub name: Option<String>,
28+ pub preferred_username: Option<String>,
29+ /// The raw ID token JWT, kept only to send as `id_token_hint` on RP-
30+ /// Initiated Logout. `None` when the identity was reconstructed from the
31+ /// pending-handle cookie rather than a fresh token exchange.
32+ pub id_token: Option<String>,
33+}
34+
35+impl Identity {
36+ /// Derive a candidate handle.
37+ ///
38+ /// Returns `None` when nothing clean can be derived, in which case the user
39+ /// is prompted to choose one (spec §6). Deliberately conservative: a bad
40+ /// automatic handle is worse than asking.
41+ pub fn suggested_handle(&self) -> Option<String> {
42+ let raw = self
43+ .preferred_username
44+ .as_deref()
45+ .or_else(|| self.email.as_deref().and_then(|e| e.split('@').next()))?;
46+
47+ let cleaned: String = raw
48+ .to_lowercase()
49+ .chars()
50+ .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
51+ .collect();
52+
53+ // Collapse runs of hyphens and trim them from both ends, then apply the
54+ // same rules the database CHECK enforces.
55+ let mut out = String::with_capacity(cleaned.len());
56+ let mut last_hyphen = false;
57+ for c in cleaned.chars() {
58+ if c == '-' {
59+ if !last_hyphen && !out.is_empty() {
60+ out.push('-');
61+ }
62+ last_hyphen = true;
63+ } else {
64+ out.push(c);
65+ last_hyphen = false;
66+ }
67+ }
68+ let out = out.trim_end_matches('-').to_string();
69+
70+ let valid = !out.is_empty()
71+ && out.len() <= 39
72+ && out.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit());
73+
74+ valid.then_some(out)
75+ }
76+}
77+
78+/// A configured relying party.
79+pub struct Oidc {
80+ client: CoreClient<
81+ openidconnect::EndpointSet,
82+ openidconnect::EndpointNotSet,
83+ openidconnect::EndpointNotSet,
84+ openidconnect::EndpointNotSet,
85+ openidconnect::EndpointMaybeSet,
86+ openidconnect::EndpointMaybeSet,
87+ >,
88+ http: reqwest::Client,
89+ scopes: Vec<String>,
90+ end_session_endpoint: Option<String>,
91+}
92+
93+impl Oidc {
94+ /// Discover provider metadata and build the client.
95+ ///
96+ /// Called once at startup; a failure here is fatal, because an instance that
97+ /// cannot authenticate anyone is not usefully running.
98+ pub async fn discover(
99+ issuer: &str,
100+ client_id: &str,
101+ client_secret: &str,
102+ redirect_url: &str,
103+ scopes: &str,
104+ ) -> Result<Self> {
105+ let http = reqwest::ClientBuilder::new()
106+ // SSRF hardening: the discovery document and JWKS are the only
107+ // outbound requests this process makes, and neither should ever
108+ // redirect.
109+ .redirect(reqwest::redirect::Policy::none())
110+ .timeout(std::time::Duration::from_secs(15))
111+ .build()
112+ .context("building OIDC http client")?;
113+
114+ let issuer_url = IssuerUrl::new(issuer.to_string())
115+ .with_context(|| format!("invalid OIDC_ISSUER: {issuer}"))?;
116+
117+ let metadata = CoreProviderMetadata::discover_async(issuer_url.clone(), &http)
118+ .await
119+ .with_context(|| format!("OIDC discovery failed against {issuer}"))?;
120+
121+ // `end_session_endpoint` is an RP-Initiated Logout field, not part of
122+ // core OIDC discovery, so `openidconnect` puts it behind a typed
123+ // additional-metadata parameter. Re-reading the document for one
124+ // optional string is simpler than threading that type through, and it
125+ // costs one request at startup. Absence is not an error — single logout
126+ // is then unavailable and we fall back to clearing our own session.
127+ let end_session_endpoint = fetch_end_session_endpoint(&http, &issuer_url).await;
128+
129+ let client = CoreClient::from_provider_metadata(
130+ metadata,
131+ ClientId::new(client_id.to_string()),
132+ Some(ClientSecret::new(client_secret.to_string())),
133+ )
134+ .set_redirect_uri(
135+ RedirectUrl::new(redirect_url.to_string())
136+ .with_context(|| format!("invalid OIDC_REDIRECT_URL: {redirect_url}"))?,
137+ );
138+
139+ Ok(Oidc {
140+ client,
141+ http,
142+ scopes: scopes.split_whitespace().map(str::to_string).collect(),
143+ end_session_endpoint,
144+ })
145+ }
146+
147+ /// Build a client from provider metadata supplied directly, with no
148+ /// discovery request.
149+ ///
150+ /// Exists so tests can construct an `AppState` without a live identity
151+ /// provider. It is `#[doc(hidden)]` and takes the metadata JSON rather than
152+ /// an issuer URL, so it cannot be mistaken for a supported way to configure
153+ /// a real instance — production always discovers.
154+ #[doc(hidden)]
155+ pub fn from_metadata_json(
156+ metadata_json: &str,
157+ client_id: &str,
158+ client_secret: &str,
159+ redirect_url: &str,
160+ scopes: &str,
161+ ) -> Result<Self> {
162+ let metadata: CoreProviderMetadata =
163+ serde_json::from_str(metadata_json).context("parsing provider metadata")?;
164+
165+ let client = CoreClient::from_provider_metadata(
166+ metadata,
167+ ClientId::new(client_id.to_string()),
168+ Some(ClientSecret::new(client_secret.to_string())),
169+ )
170+ .set_redirect_uri(RedirectUrl::new(redirect_url.to_string()).context("redirect url")?);
171+
172+ Ok(Oidc {
173+ client,
174+ http: reqwest::Client::new(),
175+ scopes: scopes.split_whitespace().map(str::to_string).collect(),
176+ end_session_endpoint: None,
177+ })
178+ }
179+
180+ pub fn end_session_endpoint(&self) -> Option<&str> {
181+ self.end_session_endpoint.as_deref()
182+ }
183+
184+ /// Begin a login: persist the flow and return the URL to redirect to.
185+ ///
186+ /// `redirect_after` is where to send the user once login completes. It must
187+ /// already have been validated as a local path by the caller — an open
188+ /// redirect here would be handed to every user who clicks a login link.
189+ pub async fn begin(&self, db: &PgPool, redirect_after: Option<&str>) -> Result<String> {
190+ let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
191+
192+ let (auth_url, csrf_state, nonce) = {
193+ let mut req = self.client.authorize_url(
194+ CoreAuthenticationFlow::AuthorizationCode,
195+ CsrfToken::new_random,
196+ Nonce::new_random,
197+ );
198+ // `authorize_url` already includes `openid`; adding it again
199+ // produces a duplicated scope in the request.
200+ for s in self.scopes.iter().filter(|s| s.as_str() != "openid") {
201+ req = req.add_scope(Scope::new(s.clone()));
202+ }
203+ req.set_pkce_challenge(pkce_challenge).url()
204+ };
205+
206+ sqlx::query(
207+ "INSERT INTO auth_flows (state, pkce_verifier, nonce, redirect_after, expires_at)
208+ VALUES ($1, $2, $3, $4, $5)",
209+ )
210+ .bind(csrf_state.secret())
211+ .bind(pkce_verifier.secret())
212+ .bind(nonce.secret())
213+ .bind(redirect_after)
214+ // Ten minutes is generous for a login round-trip and short enough that
215+ // abandoned flows do not accumulate.
216+ .bind(Utc::now() + Duration::minutes(10))
217+ .execute(db)
218+ .await
219+ .context("persisting auth flow")?;
220+
221+ Ok(auth_url.to_string())
222+ }
223+
224+ /// Complete a login. Consumes the stored flow, exchanges the code, and
225+ /// verifies the ID token.
226+ ///
227+ /// Returns the identity and the validated post-login redirect path.
228+ pub async fn complete(
229+ &self,
230+ db: &PgPool,
231+ state: &str,
232+ code: &str,
233+ ) -> Result<(Identity, Option<String>)> {
234+ // Single-use: DELETE … RETURNING means a replayed callback finds nothing
235+ // and is rejected, rather than being processed twice.
236+ let row: Option<(String, String, Option<String>, chrono::DateTime<Utc>)> = sqlx::query_as(
237+ "DELETE FROM auth_flows WHERE state = $1
238+ RETURNING pkce_verifier, nonce, redirect_after, expires_at",
239+ )
240+ .bind(state)
241+ .fetch_optional(db)
242+ .await
243+ .context("loading auth flow")?;
244+
245+ let (verifier, nonce, redirect_after, expires_at) =
246+ row.ok_or_else(|| anyhow!("unknown or already-used login state"))?;
247+
248+ if expires_at < Utc::now() {
249+ return Err(anyhow!("login took too long; please try again"));
250+ }
251+
252+ let token_response = self
253+ .client
254+ .exchange_code(AuthorizationCode::new(code.to_string()))
255+ .map_err(|e| anyhow!("configuring code exchange: {e}"))?
256+ .set_pkce_verifier(PkceCodeVerifier::new(verifier))
257+ .request_async(&self.http)
258+ .await
259+ .context("exchanging authorization code")?;
260+
261+ let id_token = token_response
262+ .id_token()
263+ .ok_or_else(|| anyhow!("provider returned no ID token"))?;
264+
265+ // Verifies signature, issuer, audience, expiry, and the nonce binding.
266+ let claims = id_token
267+ .claims(&self.client.id_token_verifier(), &Nonce::new(nonce))
268+ .context("verifying ID token")?;
269+
270+ // Belt and braces: the access token hash binding, when the provider
271+ // supplies it.
272+ if let Some(expected) = claims.access_token_hash() {
273+ let actual = openidconnect::AccessTokenHash::from_token(
274+ token_response.access_token(),
275+ id_token.signing_alg().map_err(|e| anyhow!("{e}"))?,
276+ id_token.signing_key(&self.client.id_token_verifier()).map_err(|e| anyhow!("{e}"))?,
277+ )
278+ .map_err(|e| anyhow!("computing access token hash: {e}"))?;
279+ if &actual != expected {
280+ return Err(anyhow!("access token hash mismatch"));
281+ }
282+ }
283+
284+ let identity = Identity {
285+ subject: claims.subject().to_string(),
286+ email: claims.email().map(|e| e.to_string()),
287+ name: claims
288+ .name()
289+ .and_then(|n| n.get(None))
290+ .map(|n| n.to_string()),
291+ preferred_username: claims.preferred_username().map(|u| u.to_string()),
292+ id_token: Some(id_token.to_string()),
293+ };
294+
295+ Ok((identity, redirect_after))
296+ }
297+}
298+
299+/// Read `end_session_endpoint` out of the discovery document.
300+///
301+/// Best-effort: any failure logs and yields `None` rather than aborting
302+/// startup, since the endpoint is optional and only affects single logout.
303+async fn fetch_end_session_endpoint(
304+ http: &reqwest::Client,
305+ issuer: &IssuerUrl,
306+) -> Option<String> {
307+ let url = format!(
308+ "{}/.well-known/openid-configuration",
309+ issuer.as_str().trim_end_matches('/')
310+ );
311+ // `.text()` rather than `.json()`: the reqwest re-exported by
312+ // `openidconnect` is built without the `json` feature.
313+ let body = match http.get(&url).send().await {
314+ Ok(r) => match r.text().await {
315+ Ok(b) => b,
316+ Err(e) => {
317+ tracing::warn!("reading discovery document failed: {e}");
318+ return None;
319+ }
320+ },
321+ Err(e) => {
322+ tracing::warn!("re-reading discovery document failed: {e}");
323+ return None;
324+ }
325+ };
326+ let doc: serde_json::Value = match serde_json::from_str(&body) {
327+ Ok(v) => v,
328+ Err(e) => {
329+ tracing::warn!("discovery document was not JSON: {e}");
330+ return None;
331+ }
332+ };
333+
334+ let endpoint = doc
335+ .get("end_session_endpoint")
336+ .and_then(|v| v.as_str())
337+ .map(str::to_string);
338+
339+ match &endpoint {
340+ Some(e) => tracing::info!("single logout available at {e}"),
341+ None => tracing::info!("provider advertises no end_session_endpoint; single logout disabled"),
342+ }
343+ endpoint
344+}
345+
346+/// Remove expired login flows. Run periodically from the worker.
347+pub async fn sweep_expired_flows(db: &PgPool) -> Result<u64> {
348+ let r = sqlx::query("DELETE FROM auth_flows WHERE expires_at < now()")
349+ .execute(db)
350+ .await?;
351+ Ok(r.rows_affected())
352+}
353+
354+/// Validate a post-login redirect target.
355+///
356+/// Only site-local absolute paths are allowed. Anything else — an absolute URL,
357+/// a protocol-relative `//evil.example`, or a backslash variant some browsers
358+/// normalise to `//` — is discarded rather than corrected.
359+pub fn safe_redirect(candidate: &str) -> Option<String> {
360+ if !candidate.starts_with('/') {
361+ return None;
362+ }
363+ // `//host` and `/\host` are both treated as scheme-relative by some
364+ // browsers, which would make this an open redirect.
365+ let rest = &candidate.as_bytes()[1..];
366+ if matches!(rest.first(), Some(b'/') | Some(b'\\')) {
367+ return None;
368+ }
369+ if candidate.contains(['\r', '\n', '\0']) {
370+ return None;
371+ }
372+ Some(candidate.to_string())
373+}
374+
375+#[cfg(test)]
376+mod tests {
377+ use super::*;
378+
379+ fn ident(user: Option<&str>, email: Option<&str>) -> Identity {
380+ Identity {
381+ subject: "sub".into(),
382+ email: email.map(str::to_string),
383+ name: None,
384+ preferred_username: user.map(str::to_string),
385+ id_token: None,
386+ }
387+ }
388+
389+ #[test]
390+ fn handle_prefers_preferred_username() {
391+ assert_eq!(
392+ ident(Some("alice"), Some("bob@example.com")).suggested_handle(),
393+ Some("alice".into())
394+ );
395+ }
396+
397+ #[test]
398+ fn handle_falls_back_to_email_local_part() {
399+ assert_eq!(
400+ ident(None, Some("bob@example.com")).suggested_handle(),
401+ Some("bob".into())
402+ );
403+ }
404+
405+ #[test]
406+ fn handle_is_normalised_to_the_database_constraint() {
407+ // Must satisfy: ^[a-z0-9][a-z0-9-]{0,38}$
408+ assert_eq!(
409+ ident(None, Some("Bob.Smith+tag@example.com")).suggested_handle(),
410+ Some("bob-smith-tag".into())
411+ );
412+ assert_eq!(
413+ ident(Some("Foo__Bar"), None).suggested_handle(),
414+ Some("foo-bar".into()),
415+ "runs of invalid characters collapse to a single hyphen"
416+ );
417+ }
418+
419+ #[test]
420+ fn handle_is_none_when_nothing_clean_can_be_derived() {
421+ assert_eq!(ident(None, None).suggested_handle(), None);
422+ assert_eq!(ident(Some("___"), None).suggested_handle(), None);
423+ // Must not start with a hyphen or be empty after cleaning.
424+ assert_eq!(ident(Some("-"), None).suggested_handle(), None);
425+ }
426+
427+ #[test]
428+ fn suggested_handles_always_satisfy_the_db_constraint() {
429+ let re = regex_lite();
430+ for raw in [
431+ "alice", "Bob.Smith+tag@x.com", "UPPER", "a", "9lives", "x--y",
432+ "trailing-", "-leading", "with space", "üñí", "a".repeat(80).as_str(),
433+ ] {
434+ if let Some(h) = ident(Some(raw), None).suggested_handle() {
435+ assert!(re(&h), "derived handle {h:?} from {raw:?} violates the constraint");
436+ }
437+ }
438+ }
439+
440+ /// Mirror of the SQL CHECK, so the test does not need a regex crate.
441+ fn regex_lite() -> impl Fn(&str) -> bool {
442+ |h: &str| {
443+ !h.is_empty()
444+ && h.len() <= 39
445+ && h.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
446+ && h.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
447+ }
448+ }
449+
450+ // ─── open redirect (spec §9) ─────────────────────────────────────────────
451+
452+ #[test]
453+ fn safe_redirect_accepts_local_paths() {
454+ assert_eq!(safe_redirect("/dogfood/repo"), Some("/dogfood/repo".into()));
455+ assert_eq!(safe_redirect("/"), Some("/".into()));
456+ }
457+
458+ #[test]
459+ fn safe_redirect_rejects_off_site_targets() {
460+ for bad in [
461+ "https://evil.example",
462+ "//evil.example",
463+ "/\\evil.example",
464+ "http://evil.example",
465+ "evil.example",
466+ "",
467+ "/path\r\nSet-Cookie: x=y",
468+ "/path\0",
469+ ] {
470+ assert_eq!(safe_redirect(bad), None, "must reject redirect target {bad:?}");
471+ }
472+ }
473+}
Acrates/df-auth/src/permissions.rs+299−0
@@ −0,0 +1,299 @@
1+//! Authorization (spec §6).
2+//!
3+//! ```text
4+//! Role read comment push manage-changes settings delete
5+//! read ✓ ✓ · · · ·
6+//! write ✓ ✓ ✓ · · ·
7+//! maintain ✓ ✓ ✓ ✓ ✓ ·
8+//! admin ✓ ✓ ✓ ✓ ✓ ✓
9+//! ```
10+//!
11+//! The whole point of this module is that permission logic exists in exactly one
12+//! place. Spec §6: "Do not scatter permission checks through handlers — one
13+//! function, one call site per request, and a default-deny fallthrough."
14+//!
15+//! [`resolve`] is pure — it takes facts already loaded from the database and
16+//! returns a decision. That makes the interesting cases testable without a
17+//! database, which matters because a mistake here is the private-repo leakage
18+//! class of bug that §9 calls "the most common forge vulnerability".
19+
20+use df_db::models::{OrgRole, RepoRole, Visibility};
21+
22+/// What a request is allowed to do with a repository.
23+///
24+/// Constructed only by [`resolve`]. There is deliberately no way to build one
25+/// with arbitrary permissions outside this module.
26+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27+pub struct RepoAccess {
28+ role: Option<RepoRole>,
29+}
30+
31+impl RepoAccess {
32+ /// No access at all. The repo must be rendered as if it does not exist —
33+ /// see the note on 404s below.
34+ pub const DENIED: RepoAccess = RepoAccess { role: None };
35+
36+ pub fn role(&self) -> Option<RepoRole> {
37+ self.role
38+ }
39+
40+ /// Whether the repo may be seen at all.
41+ ///
42+ /// When this is false the handler must return the *same* 404 a nonexistent
43+ /// repo returns (spec §9), never a 403 — a 403 confirms the repo exists,
44+ /// which is exactly the leak.
45+ pub fn can_read(&self) -> bool {
46+ self.role.is_some()
47+ }
48+
49+ pub fn can_comment(&self) -> bool {
50+ self.at_least(RepoRole::Read)
51+ }
52+
53+ pub fn can_push(&self) -> bool {
54+ self.at_least(RepoRole::Write)
55+ }
56+
57+ /// Merge, abandon, retarget, edit others' changes.
58+ pub fn can_manage_changes(&self) -> bool {
59+ self.at_least(RepoRole::Maintain)
60+ }
61+
62+ pub fn can_change_settings(&self) -> bool {
63+ self.at_least(RepoRole::Maintain)
64+ }
65+
66+ pub fn can_delete(&self) -> bool {
67+ self.at_least(RepoRole::Admin)
68+ }
69+
70+ fn at_least(&self, needed: RepoRole) -> bool {
71+ matches!(self.role, Some(r) if r >= needed)
72+ }
73+}
74+
75+/// Everything [`resolve`] needs to know, loaded once per request.
76+#[derive(Debug, Clone, Copy)]
77+pub struct AccessInputs {
78+ pub visibility: Visibility,
79+ /// Set when the viewer is signed in.
80+ pub viewer: Option<Viewer>,
81+ /// The repo's owning user, when owned by a user.
82+ pub owner_user_id: Option<uuid::Uuid>,
83+ /// Direct collaborator role, if any.
84+ pub collaborator_role: Option<RepoRole>,
85+ /// The viewer's role in the owning org, when the repo is org-owned.
86+ pub org_role: Option<OrgRole>,
87+}
88+
89+#[derive(Debug, Clone, Copy)]
90+pub struct Viewer {
91+ pub user_id: uuid::Uuid,
92+ pub is_site_admin: bool,
93+}
94+
95+/// Resolve effective access.
96+///
97+/// Effective role is the maximum of: direct collaborator role, org role (org
98+/// admins get `admin` on org repos), repo ownership, and site admin. Public
99+/// repos additionally grant implicit `read` to everyone including anonymous
100+/// users; private repos grant nothing implicitly.
101+pub fn resolve(input: AccessInputs) -> RepoAccess {
102+ // Default deny. Every grant below is additive, and anything that falls
103+ // through this function unmatched ends up denied.
104+ let mut role: Option<RepoRole> = None;
105+
106+ /// Raise `role` to at least `candidate`.
107+ fn raise(role: &mut Option<RepoRole>, candidate: RepoRole) {
108+ *role = Some(match *role {
109+ Some(existing) => existing.max(candidate),
110+ None => candidate,
111+ });
112+ }
113+
114+ // Public repos are readable by anyone, including anonymous requests.
115+ if matches!(input.visibility, Visibility::Public) {
116+ raise(&mut role, RepoRole::Read);
117+ }
118+
119+ if let Some(viewer) = input.viewer {
120+ // Site admins have full access everywhere.
121+ if viewer.is_site_admin {
122+ raise(&mut role, RepoRole::Admin);
123+ }
124+
125+ // Owning a repo grants admin on it.
126+ if input.owner_user_id == Some(viewer.user_id) {
127+ raise(&mut role, RepoRole::Admin);
128+ }
129+
130+ if let Some(direct) = input.collaborator_role {
131+ raise(&mut role, direct);
132+ }
133+
134+ // Org membership. Org admins get repo admin; plain members get read,
135+ // which is what makes private org repos visible to the org.
136+ match input.org_role {
137+ Some(OrgRole::Admin) => raise(&mut role, RepoRole::Admin),
138+ Some(OrgRole::Member) => raise(&mut role, RepoRole::Read),
139+ None => {}
140+ }
141+ }
142+
143+ RepoAccess { role }
144+}
145+
146+#[cfg(test)]
147+mod tests {
148+ use super::*;
149+ use uuid::Uuid;
150+
151+ fn viewer() -> Viewer {
152+ Viewer { user_id: Uuid::from_u128(1), is_site_admin: false }
153+ }
154+
155+ fn base() -> AccessInputs {
156+ AccessInputs {
157+ visibility: Visibility::Private,
158+ viewer: None,
159+ owner_user_id: None,
160+ collaborator_role: None,
161+ org_role: None,
162+ }
163+ }
164+
165+ // ─── the leakage cases (spec §9) ─────────────────────────────────────────
166+
167+ #[test]
168+ fn anonymous_cannot_read_a_private_repo() {
169+ let a = resolve(base());
170+ assert!(!a.can_read(), "anonymous access to a private repo must be denied");
171+ assert_eq!(a.role(), None);
172+ }
173+
174+ #[test]
175+ fn a_signed_in_stranger_cannot_read_a_private_repo() {
176+ let a = resolve(AccessInputs { viewer: Some(viewer()), ..base() });
177+ assert!(
178+ !a.can_read(),
179+ "being authenticated is not authorization — this is the private-repo leak"
180+ );
181+ }
182+
183+ #[test]
184+ fn org_membership_alone_does_not_grant_access_to_a_non_org_repo() {
185+ // org_role is only ever populated for org-owned repos, but if a bug ever
186+ // populated it for a user-owned repo the grant would still be read-only,
187+ // never write.
188+ let a = resolve(AccessInputs {
189+ viewer: Some(viewer()),
190+ org_role: Some(OrgRole::Member),
191+ ..base()
192+ });
193+ assert!(a.can_read());
194+ assert!(!a.can_push(), "org membership must never imply push");
195+ }
196+
197+ // ─── public repos ────────────────────────────────────────────────────────
198+
199+ #[test]
200+ fn anonymous_can_read_but_not_write_a_public_repo() {
201+ let a = resolve(AccessInputs { visibility: Visibility::Public, ..base() });
202+ assert!(a.can_read());
203+ assert!(a.can_comment(), "read implies comment per the §6 table");
204+ assert!(!a.can_push());
205+ assert!(!a.can_change_settings());
206+ assert!(!a.can_delete());
207+ }
208+
209+ // ─── the role table, exactly as specified in §6 ──────────────────────────
210+
211+ #[test]
212+ fn role_table_matches_the_spec() {
213+ let cases = [
214+ // read, comment, push, manage, settings, delete
215+ (RepoRole::Read, true, true, false, false, false, false),
216+ (RepoRole::Write, true, true, true, false, false, false),
217+ (RepoRole::Maintain, true, true, true, true, true, false),
218+ (RepoRole::Admin, true, true, true, true, true, true),
219+ ];
220+ for (role, read, comment, push, manage, settings, delete) in cases {
221+ let a = resolve(AccessInputs {
222+ viewer: Some(viewer()),
223+ collaborator_role: Some(role),
224+ ..base()
225+ });
226+ assert_eq!(a.can_read(), read, "{role:?} read");
227+ assert_eq!(a.can_comment(), comment, "{role:?} comment");
228+ assert_eq!(a.can_push(), push, "{role:?} push");
229+ assert_eq!(a.can_manage_changes(), manage, "{role:?} manage-changes");
230+ assert_eq!(a.can_change_settings(), settings, "{role:?} settings");
231+ assert_eq!(a.can_delete(), delete, "{role:?} delete");
232+ }
233+ }
234+
235+ // ─── the maximum rule ────────────────────────────────────────────────────
236+
237+ #[test]
238+ fn effective_role_is_the_maximum_not_the_last_match() {
239+ // A read collaborator who is also an org admin gets admin, not read.
240+ let a = resolve(AccessInputs {
241+ viewer: Some(viewer()),
242+ collaborator_role: Some(RepoRole::Read),
243+ org_role: Some(OrgRole::Admin),
244+ ..base()
245+ });
246+ assert_eq!(a.role(), Some(RepoRole::Admin));
247+ }
248+
249+ #[test]
250+ fn a_lower_org_role_never_downgrades_a_higher_collaborator_role() {
251+ let a = resolve(AccessInputs {
252+ viewer: Some(viewer()),
253+ collaborator_role: Some(RepoRole::Admin),
254+ org_role: Some(OrgRole::Member),
255+ ..base()
256+ });
257+ assert_eq!(
258+ a.role(),
259+ Some(RepoRole::Admin),
260+ "org membership must not reduce an explicit collaborator grant"
261+ );
262+ }
263+
264+ #[test]
265+ fn site_admin_has_full_access_to_private_repos() {
266+ let a = resolve(AccessInputs {
267+ viewer: Some(Viewer { user_id: Uuid::from_u128(9), is_site_admin: true }),
268+ ..base()
269+ });
270+ assert!(a.can_delete());
271+ }
272+
273+ #[test]
274+ fn owner_has_admin_on_their_own_repo() {
275+ let v = viewer();
276+ let a = resolve(AccessInputs {
277+ viewer: Some(v),
278+ owner_user_id: Some(v.user_id),
279+ ..base()
280+ });
281+ assert_eq!(a.role(), Some(RepoRole::Admin));
282+ }
283+
284+ #[test]
285+ fn a_different_users_repo_is_not_owned() {
286+ let a = resolve(AccessInputs {
287+ viewer: Some(viewer()),
288+ owner_user_id: Some(Uuid::from_u128(999)),
289+ ..base()
290+ });
291+ assert!(!a.can_read());
292+ }
293+
294+ #[test]
295+ fn denied_is_the_default() {
296+ assert!(!RepoAccess::DENIED.can_read());
297+ assert_eq!(RepoAccess::DENIED.role(), None);
298+ }
299+}
Acrates/df-auth/src/provisioning.rs402 lines+402−0
@@ −0,0 +1,402 @@
1+//! User provisioning and site-admin bootstrap.
2+//!
3+//! Access is invite/allowlist only (decided): completing an OIDC login is not
4+//! sufficient to obtain a Dogfood account. An identity must additionally be on
5+//! the configured allowlist or hold an open invitation.
6+//!
7+//! Site admin is claimed once through a one-time setup token that the web
8+//! process mints and logs on first boot when no admin exists.
9+
10+use anyhow::{Context, Result};
11+use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
12+use argon2::Argon2;
13+use df_db::ids::new_id;
14+use df_db::models::User;
15+use rand::RngCore;
16+use sqlx::PgPool;
17+use uuid::Uuid;
18+
19+use crate::oidc::Identity;
20+
21+/// Why a login did not result in an account.
22+#[derive(Debug, Clone, PartialEq, Eq)]
23+pub enum Denied {
24+ /// Authenticated successfully, but not allowlisted or invited.
25+ NotInvited,
26+ /// The provider returned no email, so allowlist matching is impossible.
27+ NoEmail,
28+ /// A handle could not be derived and must be chosen by the user.
29+ NeedsHandle { suggested: Option<String> },
30+}
31+
32+/// Decide whether an identity may hold an account.
33+///
34+/// Pure so the policy is testable without a database. `invited` is the result
35+/// of the invitation lookup; `allowlist` comes from configuration.
36+pub fn admission(identity: &Identity, allowlist: &[String], invited: bool) -> Result<(), Denied> {
37+ // Entries may be either an email address or a raw OIDC subject. Subjects are
38+ // matched first and are the more dependable of the two: `sub` is guaranteed
39+ // present on every login, whereas `email` depends on the provider's consent
40+ // app re-supplying claims on each flow. Ory's Account Experience does not do
41+ // that once Hydra starts skipping consent, so an email-only allowlist stops
42+ // matching an hour after a user's first sign-in.
43+ let subject = identity.subject.trim();
44+ if !subject.is_empty()
45+ && allowlist
46+ .iter()
47+ .map(|a| a.trim())
48+ .filter(|a| !a.is_empty())
49+ // Subjects are opaque and case-sensitive; do not fold case here.
50+ .any(|a| a == subject)
51+ {
52+ return Ok(());
53+ }
54+
55+ let email = identity.email.as_deref().map(str::trim).unwrap_or("");
56+ if email.is_empty() {
57+ // Without an email we cannot match the allowlist, and silently creating
58+ // an account would defeat the policy. An empty-string email is treated
59+ // as absent so it can never match a malformed allowlist entry.
60+ return Err(Denied::NoEmail);
61+ }
62+
63+ let allowed = invited
64+ || allowlist
65+ .iter()
66+ .map(|a| a.trim())
67+ // A blank entry — from `DOGFOOD_ALLOWLIST=""` or a stray comma —
68+ // must never match anything.
69+ .filter(|a| !a.is_empty())
70+ .any(|a| a.eq_ignore_ascii_case(email));
71+
72+ if !allowed {
73+ return Err(Denied::NotInvited);
74+ }
75+ Ok(())
76+}
77+
78+/// Find an existing user by OIDC subject.
79+///
80+/// Keyed on `sub`, never email (spec §6).
81+pub async fn find_by_subject(db: &PgPool, subject: &str) -> Result<Option<User>> {
82+ let u = sqlx::query_as::<_, User>(
83+ "SELECT id, subject, handle, display_name, email, avatar_url, is_admin, created_at
84+ FROM users WHERE subject = $1",
85+ )
86+ .bind(subject)
87+ .fetch_optional(db)
88+ .await?;
89+ Ok(u)
90+}
91+
92+/// Whether an open invitation exists for this email.
93+pub async fn has_invitation(db: &PgPool, email: &str) -> Result<bool> {
94+ let found: Option<(Uuid,)> =
95+ sqlx::query_as("SELECT id FROM invitations WHERE email = $1 AND accepted_at IS NULL")
96+ .bind(email)
97+ .fetch_optional(db)
98+ .await?;
99+ Ok(found.is_some())
100+}
101+
102+/// Create a user, marking any matching invitation accepted.
103+pub async fn create_user(
104+ db: &PgPool,
105+ identity: &Identity,
106+ handle: &str,
107+) -> Result<User> {
108+ let mut tx = db.begin().await?;
109+ let id = new_id();
110+
111+ let user = sqlx::query_as::<_, User>(
112+ "INSERT INTO users (id, subject, handle, display_name, email)
113+ VALUES ($1, $2, $3, $4, $5)
114+ RETURNING id, subject, handle, display_name, email, avatar_url, is_admin, created_at",
115+ )
116+ .bind(id)
117+ .bind(&identity.subject)
118+ .bind(handle)
119+ .bind(identity.name.as_deref())
120+ .bind(identity.email.as_deref())
121+ .fetch_one(&mut *tx)
122+ .await
123+ .context("creating user")?;
124+
125+ if let Some(email) = identity.email.as_deref() {
126+ sqlx::query(
127+ "UPDATE invitations SET accepted_at = now(), accepted_by = $2
128+ WHERE email = $1 AND accepted_at IS NULL",
129+ )
130+ .bind(email)
131+ .bind(user.id)
132+ .execute(&mut *tx)
133+ .await?;
134+ }
135+
136+ sqlx::query(
137+ "INSERT INTO audit_log (id, actor_id, action, target, metadata)
138+ VALUES ($1, $2, 'user.provisioned', $3, $4)",
139+ )
140+ .bind(new_id())
141+ .bind(user.id)
142+ .bind(format!("user:{}", user.id))
143+ .bind(serde_json::json!({ "handle": handle, "subject": identity.subject }))
144+ .execute(&mut *tx)
145+ .await?;
146+
147+ tx.commit().await?;
148+ Ok(user)
149+}
150+
151+/// Whether a handle is free and not reserved.
152+pub async fn handle_available(db: &PgPool, handle: &str) -> Result<bool> {
153+ let taken: Option<(i32,)> = sqlx::query_as(
154+ "SELECT 1 FROM (
155+ SELECT handle FROM users
156+ UNION ALL SELECT handle FROM orgs
157+ UNION ALL SELECT handle FROM reserved_handles
158+ ) t WHERE handle = $1",
159+ )
160+ .bind(handle)
161+ .fetch_optional(db)
162+ .await?;
163+ Ok(taken.is_none())
164+}
165+
166+/// Pick a free handle derived from `base`, appending a numeric suffix if needed.
167+pub async fn unique_handle(db: &PgPool, base: &str) -> Result<Option<String>> {
168+ if handle_available(db, base).await? {
169+ return Ok(Some(base.to_string()));
170+ }
171+ for n in 2..=99 {
172+ // Keep within the 39-character limit the CHECK enforces.
173+ let suffix = n.to_string();
174+ let trimmed: String = base.chars().take(39 - suffix.len() - 1).collect();
175+ let candidate = format!("{}-{}", trimmed.trim_end_matches('-'), suffix);
176+ if handle_available(db, &candidate).await? {
177+ return Ok(Some(candidate));
178+ }
179+ }
180+ Ok(None)
181+}
182+
183+// ─── site admin bootstrap ────────────────────────────────────────────────────
184+
185+/// Whether any site admin exists.
186+pub async fn any_admin_exists(db: &PgPool) -> Result<bool> {
187+ let found: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM users WHERE is_admin LIMIT 1")
188+ .fetch_optional(db)
189+ .await?;
190+ Ok(found.is_some())
191+}
192+
193+/// Mint a setup token if no admin exists and no unconsumed token is outstanding.
194+///
195+/// Returns the plaintext exactly once, for logging. Only the hash is stored.
196+pub async fn ensure_setup_token(db: &PgPool) -> Result<Option<String>> {
197+ if any_admin_exists(db).await? {
198+ return Ok(None);
199+ }
200+
201+ let outstanding: Option<(Uuid,)> =
202+ sqlx::query_as("SELECT id FROM setup_tokens WHERE consumed_at IS NULL LIMIT 1")
203+ .fetch_optional(db)
204+ .await?;
205+ if outstanding.is_some() {
206+ // A token is already outstanding. We cannot show it again — we do not
207+ // have the plaintext — so say so rather than minting a second one.
208+ return Ok(None);
209+ }
210+
211+ let mut bytes = [0u8; 24];
212+ rand::thread_rng().fill_bytes(&mut bytes);
213+ let plaintext = hex::encode(bytes);
214+
215+ let salt = SaltString::generate(&mut rand::thread_rng());
216+ let hash = Argon2::default()
217+ .hash_password(plaintext.as_bytes(), &salt)
218+ .map_err(|e| anyhow::anyhow!("hashing setup token: {e}"))?
219+ .to_string();
220+
221+ sqlx::query("INSERT INTO setup_tokens (id, token_hash) VALUES ($1, $2)")
222+ .bind(new_id())
223+ .bind(hash)
224+ .execute(db)
225+ .await?;
226+
227+ Ok(Some(plaintext))
228+}
229+
230+/// Consume a setup token, promoting `user_id` to site admin.
231+///
232+/// Returns false for an unknown, already-consumed, or mismatched token.
233+pub async fn claim_admin(db: &PgPool, user_id: Uuid, presented: &str) -> Result<bool> {
234+ let candidates: Vec<(Uuid, String)> =
235+ sqlx::query_as("SELECT id, token_hash FROM setup_tokens WHERE consumed_at IS NULL")
236+ .fetch_all(db)
237+ .await?;
238+
239+ for (token_id, stored) in candidates {
240+ let Ok(parsed) = PasswordHash::new(&stored) else {
241+ tracing::error!(%token_id, "setup token hash unparseable");
242+ continue;
243+ };
244+ if Argon2::default()
245+ .verify_password(presented.as_bytes(), &parsed)
246+ .is_err()
247+ {
248+ continue;
249+ }
250+
251+ let mut tx = db.begin().await?;
252+ // Guard against two requests racing on the same token: the UPDATE only
253+ // matches while it is still unconsumed.
254+ let claimed = sqlx::query(
255+ "UPDATE setup_tokens SET consumed_at = now(), consumed_by = $2
256+ WHERE id = $1 AND consumed_at IS NULL",
257+ )
258+ .bind(token_id)
259+ .bind(user_id)
260+ .execute(&mut *tx)
261+ .await?;
262+
263+ if claimed.rows_affected() == 0 {
264+ tx.rollback().await?;
265+ return Ok(false);
266+ }
267+
268+ sqlx::query("UPDATE users SET is_admin = true WHERE id = $1")
269+ .bind(user_id)
270+ .execute(&mut *tx)
271+ .await?;
272+
273+ sqlx::query(
274+ "INSERT INTO audit_log (id, actor_id, action, target, metadata)
275+ VALUES ($1, $2, 'admin.claimed', $3, '{}')",
276+ )
277+ .bind(new_id())
278+ .bind(user_id)
279+ .bind(format!("user:{user_id}"))
280+ .execute(&mut *tx)
281+ .await?;
282+
283+ tx.commit().await?;
284+ return Ok(true);
285+ }
286+
287+ Ok(false)
288+}
289+
290+#[cfg(test)]
291+mod tests {
292+ use super::*;
293+
294+ fn ident(email: Option<&str>) -> Identity {
295+ Identity {
296+ subject: "kratos-uuid".into(),
297+ email: email.map(str::to_string),
298+ name: None,
299+ preferred_username: None,
300+ id_token: None,
301+ }
302+ }
303+
304+ #[test]
305+ fn allowlisted_email_is_admitted() {
306+ let allow = vec!["nycmattw@gmail.com".to_string()];
307+ assert_eq!(admission(&ident(Some("nycmattw@gmail.com")), &allow, false), Ok(()));
308+ }
309+
310+ #[test]
311+ fn allowlist_matching_is_case_and_whitespace_insensitive() {
312+ let allow = vec![" NycMattW@Gmail.com ".to_string()];
313+ assert_eq!(admission(&ident(Some("nycmattw@gmail.com")), &allow, false), Ok(()));
314+ }
315+
316+ #[test]
317+ fn a_stranger_is_denied_even_after_a_successful_login() {
318+ // The decided policy: authentication is not authorization.
319+ let allow = vec!["nycmattw@gmail.com".to_string()];
320+ assert_eq!(
321+ admission(&ident(Some("attacker@example.com")), &allow, false),
322+ Err(Denied::NotInvited)
323+ );
324+ }
325+
326+ #[test]
327+ fn an_invitation_admits_without_the_allowlist() {
328+ assert_eq!(admission(&ident(Some("new@example.com")), &[], true), Ok(()));
329+ }
330+
331+ #[test]
332+ fn no_email_is_denied_rather_than_admitted() {
333+ // Failing open here would let anyone Kratos authenticates hold an
334+ // account, which is the whole policy defeated.
335+ assert_eq!(admission(&ident(None), &[], false), Err(Denied::NoEmail));
336+ assert_eq!(
337+ admission(&ident(None), &["x@y.z".into()], false),
338+ Err(Denied::NoEmail),
339+ "a missing email must never fall through to an allowlist match"
340+ );
341+ }
342+
343+ #[test]
344+ fn an_empty_allowlist_admits_nobody_uninvited() {
345+ assert_eq!(
346+ admission(&ident(Some("anyone@example.com")), &[], false),
347+ Err(Denied::NotInvited)
348+ );
349+ }
350+
351+ #[test]
352+ fn a_blank_allowlist_entry_matches_nothing() {
353+ // `DOGFOOD_ALLOWLIST=""` or a stray comma parses to [""] or ["", "x"].
354+ // A blank entry must never admit anyone.
355+ assert_eq!(
356+ admission(&ident(Some("")), &["".to_string()], false),
357+ Err(Denied::NoEmail)
358+ );
359+ assert_eq!(
360+ admission(&ident(Some(" ")), &[" ".to_string()], false),
361+ Err(Denied::NoEmail)
362+ );
363+ assert_eq!(
364+ admission(&ident(Some("someone@example.com")), &["".to_string()], false),
365+ Err(Denied::NotInvited)
366+ );
367+ }
368+
369+ #[test]
370+ fn whitespace_only_email_is_treated_as_absent() {
371+ assert_eq!(admission(&ident(Some(" ")), &[], false), Err(Denied::NoEmail));
372+ }
373+
374+ // ─── subject allowlisting ────────────────────────────────────────────────
375+
376+ #[test]
377+ fn a_subject_entry_admits_without_any_email_claim() {
378+ // The case that unblocked sign-in on this deployment: Hydra skipped
379+ // consent, the AX supplied no claims, and the ID token carried only
380+ // `sub`. Subject matching must still admit.
381+ let allow = vec!["kratos-uuid".to_string()];
382+ assert_eq!(admission(&ident(None), &allow, false), Ok(()));
383+ }
384+
385+ #[test]
386+ fn subject_matching_is_case_sensitive() {
387+ // Subjects are opaque identifiers, not addresses. Folding case could
388+ // collide two distinct subjects on a provider that issues them.
389+ let allow = vec!["KRATOS-UUID".to_string()];
390+ assert_eq!(admission(&ident(None), &allow, false), Err(Denied::NoEmail));
391+ }
392+
393+ #[test]
394+ fn a_non_matching_subject_still_falls_through_to_email() {
395+ let allow = vec!["some-other-subject".into(), "matt@dogfood.sh".into()];
396+ assert_eq!(admission(&ident(Some("matt@dogfood.sh")), &allow, false), Ok(()));
397+ assert_eq!(
398+ admission(&ident(Some("other@example.com")), &allow, false),
399+ Err(Denied::NotInvited)
400+ );
401+ }
402+}
Acrates/df-auth/src/session.rs+162−0
@@ −0,0 +1,162 @@
1+//! Server-side sessions (spec §6).
2+//!
3+//! "Sessions are server-side rows with an opaque ID in a cookie: HttpOnly,
4+//! Secure, SameSite=Lax, 14-day sliding expiry. Store nothing in the cookie but
5+//! the ID."
6+
7+use anyhow::Result;
8+use chrono::{DateTime, Duration, Utc};
9+use df_db::ids::new_id;
10+use df_db::models::{Session, User};
11+use sqlx::PgPool;
12+use uuid::Uuid;
13+
14+pub const COOKIE_NAME: &str = "dogfood_session";
15+
16+/// Create a session row and return its id, which becomes the cookie value.
17+pub async fn create(
18+ db: &PgPool,
19+ user_id: Uuid,
20+ ttl_days: i64,
21+ user_agent: Option<&str>,
22+ ip: Option<std::net::IpAddr>,
23+ id_token: Option<&str>,
24+) -> Result<(Uuid, DateTime<Utc>)> {
25+ let id = new_id();
26+ let expires_at = Utc::now() + Duration::days(ttl_days);
27+
28+ sqlx::query(
29+ "INSERT INTO sessions (id, user_id, expires_at, user_agent, ip, id_token)
30+ VALUES ($1, $2, $3, $4, $5, $6)",
31+ )
32+ .bind(id)
33+ .bind(user_id)
34+ .bind(expires_at)
35+ // Truncated: a hostile client can send a multi-kilobyte User-Agent and
36+ // there is no reason to store it.
37+ .bind(user_agent.map(|ua| ua.chars().take(255).collect::<String>()))
38+ .bind(ip.map(sqlx::types::ipnetwork::IpNetwork::from))
39+ .bind(id_token)
40+ .execute(db)
41+ .await?;
42+
43+ Ok((id, expires_at))
44+}
45+
46+/// The ID token stashed at login, for `id_token_hint` on RP-Initiated Logout.
47+///
48+/// `None` both when the session predates this column and when the login that
49+/// created it had no token to store (the pending-handle path) — callers treat
50+/// both the same way: fall back to clearing the local session only.
51+pub async fn id_token(db: &PgPool, session_id: Uuid) -> Result<Option<String>> {
52+ let row: Option<(Option<String>,)> =
53+ sqlx::query_as("SELECT id_token FROM sessions WHERE id = $1")
54+ .bind(session_id)
55+ .fetch_optional(db)
56+ .await?;
57+ Ok(row.and_then(|r| r.0))
58+}
59+
60+/// Load the user for a session id, if the session exists and has not expired.
61+///
62+/// Returns the session so the caller can decide whether to slide the expiry.
63+pub async fn load(db: &PgPool, session_id: Uuid) -> Result<Option<(Session, User)>> {
64+ let row = sqlx::query_as::<_, (Uuid, Uuid, DateTime<Utc>, Uuid, String, String, Option<String>, Option<String>, Option<String>, bool, DateTime<Utc>)>(
65+ "SELECT s.id, s.user_id, s.expires_at,
66+ u.id, u.subject, u.handle, u.display_name, u.email, u.avatar_url,
67+ u.is_admin, u.created_at
68+ FROM sessions s
69+ JOIN users u ON u.id = s.user_id
70+ WHERE s.id = $1 AND s.expires_at > now()",
71+ )
72+ .bind(session_id)
73+ .fetch_optional(db)
74+ .await?;
75+
76+ Ok(row.map(|r| {
77+ (
78+ Session { id: r.0, user_id: r.1, expires_at: r.2 },
79+ User {
80+ id: r.3,
81+ subject: r.4,
82+ handle: r.5,
83+ display_name: r.6,
84+ email: r.7,
85+ avatar_url: r.8,
86+ is_admin: r.9,
87+ created_at: r.10,
88+ },
89+ )
90+ }))
91+}
92+
93+/// Extend a session's expiry — the "sliding" part of the 14-day window.
94+///
95+/// Only worth writing when the session is far enough along that the write is
96+/// not on every request; the caller decides via [`should_slide`].
97+pub async fn slide(db: &PgPool, session_id: Uuid, ttl_days: i64) -> Result<()> {
98+ sqlx::query("UPDATE sessions SET expires_at = $2 WHERE id = $1")
99+ .bind(session_id)
100+ .bind(Utc::now() + Duration::days(ttl_days))
101+ .execute(db)
102+ .await?;
103+ Ok(())
104+}
105+
106+/// Whether a session is worth sliding on this request.
107+///
108+/// Writing on every request would mean a database write per page view. Sliding
109+/// only once the session is past half its life keeps it to roughly one write
110+/// per user per week while still giving active users an unbroken session.
111+pub fn should_slide(expires_at: DateTime<Utc>, ttl_days: i64) -> bool {
112+ let remaining = expires_at - Utc::now();
113+ remaining < Duration::days(ttl_days) / 2
114+}
115+
116+pub async fn destroy(db: &PgPool, session_id: Uuid) -> Result<()> {
117+ sqlx::query("DELETE FROM sessions WHERE id = $1")
118+ .bind(session_id)
119+ .execute(db)
120+ .await?;
121+ Ok(())
122+}
123+
124+/// Invalidate every session for a user. Used when an account is disabled.
125+pub async fn destroy_all_for_user(db: &PgPool, user_id: Uuid) -> Result<u64> {
126+ let r = sqlx::query("DELETE FROM sessions WHERE user_id = $1")
127+ .bind(user_id)
128+ .execute(db)
129+ .await?;
130+ Ok(r.rows_affected())
131+}
132+
133+/// Delete expired sessions. Run periodically from the worker.
134+pub async fn sweep_expired(db: &PgPool) -> Result<u64> {
135+ let r = sqlx::query("DELETE FROM sessions WHERE expires_at < now()")
136+ .execute(db)
137+ .await?;
138+ Ok(r.rows_affected())
139+}
140+
141+#[cfg(test)]
142+mod tests {
143+ use super::*;
144+
145+ #[test]
146+ fn slides_only_in_the_second_half_of_the_window() {
147+ let ttl = 14;
148+ // Fresh session: no write.
149+ assert!(!should_slide(Utc::now() + Duration::days(14), ttl));
150+ assert!(!should_slide(Utc::now() + Duration::days(8), ttl));
151+ // Past halfway: extend it.
152+ assert!(should_slide(Utc::now() + Duration::days(6), ttl));
153+ assert!(should_slide(Utc::now() + Duration::hours(1), ttl));
154+ }
155+
156+ #[test]
157+ fn an_already_expired_session_would_slide_but_is_never_loaded() {
158+ // load() filters on expires_at > now(), so this case cannot reach
159+ // should_slide in practice; asserted so the interaction stays obvious.
160+ assert!(should_slide(Utc::now() - Duration::days(1), 14));
161+ }
162+}
Acrates/df-auth/src/ssh_keys.rs+193−0
@@ −0,0 +1,193 @@
1+//! SSH public keys (spec §6).
2+//!
3+//! > **Git over SSH** authenticates by public key. `dogfood-ssh` looks the
4+//! > presented key's SHA256 fingerprint up in `ssh_keys`, resolves the user […]
5+//!
6+//! This module owns the *only* place a user-supplied key is parsed. `dogfood-ssh`
7+//! does the reverse lookup by fingerprint; what it looks up has to have been
8+//! normalised the same way on the way in, or a legitimate key silently fails to
9+//! authenticate. Keeping both sides on one fingerprint function is the point.
10+//!
11+//! Two things a naive implementation gets wrong:
12+//!
13+//! * **Storing what the user typed.** An `authorized_keys` line carries options,
14+//! a comment, and arbitrary whitespace. We store the re-serialised key, so what
15+//! is in the database is canonical regardless of what was pasted.
16+//! * **Accepting a private key.** People paste `id_ed25519` instead of
17+//! `id_ed25519.pub` more often than you would think, and a forge that stores it
18+//! without noticing has just been handed a credential it should never hold.
19+//! [`parse`] rejects that explicitly, with a message that says what happened.
20+
21+use ssh_key::{HashAlg, PublicKey};
22+
23+/// A parsed, normalised public key ready to store.
24+#[derive(Debug, Clone, PartialEq, Eq)]
25+pub struct ParsedKey {
26+ /// `SHA256:…`, the lookup key `dogfood-ssh` authenticates against.
27+ pub fingerprint: String,
28+ /// The algorithm name, e.g. `ssh-ed25519`.
29+ pub key_type: String,
30+ /// Canonical OpenSSH serialisation, with the comment preserved.
31+ pub openssh: String,
32+ /// The comment from the key line, if it had one. Used to suggest a name.
33+ pub comment: String,
34+}
35+
36+#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
37+pub enum KeyError {
38+ #[error("that does not look like an SSH public key")]
39+ Malformed,
40+ #[error(
41+ "that is a *private* key — paste the contents of the matching .pub file instead, \
42+ and consider the key you just pasted compromised"
43+ )]
44+ PrivateKey,
45+ #[error("that key type is not accepted; use ed25519, ecdsa, or an RSA key of 2048 bits or more")]
46+ WeakAlgorithm,
47+ #[error("that key is too long to be an SSH public key")]
48+ TooLong,
49+}
50+
51+/// An `authorized_keys` line long enough to be a paste error rather than a key.
52+///
53+/// A 16 KiB RSA key serialises to well under this; nothing legitimate is near it.
54+const MAX_LEN: usize = 16 * 1024;
55+
56+/// Minimum RSA modulus size. 1024-bit RSA is factorable by a well-resourced
57+/// attacker and 2048 is the floor every current guideline agrees on.
58+const MIN_RSA_BITS: usize = 2048;
59+
60+/// Parse and validate a pasted public key.
61+pub fn parse(input: &str) -> Result<ParsedKey, KeyError> {
62+ let trimmed = input.trim();
63+
64+ if trimmed.len() > MAX_LEN {
65+ return Err(KeyError::TooLong);
66+ }
67+
68+ // Checked before parsing so the error is the useful one. `ssh-key` would
69+ // otherwise just report a malformed public key, and the user would go on
70+ // pasting the same private key.
71+ if trimmed.contains("PRIVATE KEY") {
72+ return Err(KeyError::PrivateKey);
73+ }
74+
75+ let key = PublicKey::from_openssh(trimmed).map_err(|_| KeyError::Malformed)?;
76+
77+ // DSA is disabled everywhere current, and undersized RSA is worse than no
78+ // key because it looks like security. Rejected at the door rather than
79+ // stored and quietly refused at authentication time.
80+ let algorithm = key.algorithm();
81+ match key.key_data() {
82+ d if d.dsa().is_some() => return Err(KeyError::WeakAlgorithm),
83+ d => {
84+ if let Some(rsa) = d.rsa() {
85+ // `as_positive_bytes` strips the sign padding, so this is the
86+ // true modulus size rather than the encoded length.
87+ let bits = rsa.n.as_positive_bytes().map(|b| b.len() * 8).unwrap_or(0);
88+ if bits < MIN_RSA_BITS {
89+ return Err(KeyError::WeakAlgorithm);
90+ }
91+ }
92+ }
93+ }
94+
95+ Ok(ParsedKey {
96+ fingerprint: key.fingerprint(HashAlg::Sha256).to_string(),
97+ key_type: algorithm.as_str().to_owned(),
98+ // Re-serialised, not echoed: whatever options or stray whitespace came
99+ // in are gone, and what is stored is exactly what we would compare.
100+ openssh: key.to_openssh().map_err(|_| KeyError::Malformed)?,
101+ comment: key.comment().to_owned(),
102+ })
103+}
104+
105+/// A sensible default name for a key, derived from its comment.
106+///
107+/// Comments are usually `user@host`, which is exactly the label a person wants
108+/// in the list. Falls back to the key type when there is no comment.
109+pub fn suggested_name(parsed: &ParsedKey) -> String {
110+ let c = parsed.comment.trim();
111+ if c.is_empty() {
112+ parsed.key_type.clone()
113+ } else {
114+ c.chars().take(100).collect()
115+ }
116+}
117+
118+#[cfg(test)]
119+mod tests {
120+ use super::*;
121+
122+ // Generated for this test with `ssh-keygen -t ed25519`. Never used anywhere.
123+ const ED25519: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGb7ZqFmz8XN2ZsxKUJyF3vT1v1L0mHNqQaVwZ6Kx1pP alice@example.com";
124+
125+ #[test]
126+ fn parses_an_ed25519_key_and_fingerprints_it() {
127+ let k = parse(ED25519).unwrap();
128+ assert_eq!(k.key_type, "ssh-ed25519");
129+ assert_eq!(k.comment, "alice@example.com");
130+ assert!(
131+ k.fingerprint.starts_with("SHA256:"),
132+ "the fingerprint is the ssh-ssh lookup key and must be in the standard form: {}",
133+ k.fingerprint
134+ );
135+ }
136+
137+ /// The fingerprint is what `dogfood-ssh` authenticates against, so the same
138+ /// key pasted with different surrounding whitespace, options, or comment
139+ /// must still resolve to one row.
140+ #[test]
141+ fn the_fingerprint_ignores_everything_but_the_key_material() {
142+ let base = parse(ED25519).unwrap();
143+
144+ let padded = parse(&format!(" \n\t{ED25519}\n\n ")).unwrap();
145+ assert_eq!(padded.fingerprint, base.fingerprint);
146+
147+ let recommented = ED25519.replace("alice@example.com", "bob@elsewhere");
148+ let other = parse(&recommented).unwrap();
149+ assert_eq!(
150+ other.fingerprint, base.fingerprint,
151+ "the comment is a label, not key material"
152+ );
153+ }
154+
155+ #[test]
156+ fn a_pasted_private_key_is_rejected_with_a_useful_message() {
157+ let private = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXk=\n-----END OPENSSH PRIVATE KEY-----";
158+ assert_eq!(parse(private).unwrap_err(), KeyError::PrivateKey);
159+ assert!(
160+ KeyError::PrivateKey.to_string().contains("compromised"),
161+ "the user needs to be told to rotate it"
162+ );
163+ }
164+
165+ #[test]
166+ fn junk_is_rejected() {
167+ for junk in ["", "hello", "ssh-ed25519", "ssh-ed25519 not-base64!!", "{}"] {
168+ assert_eq!(parse(junk).unwrap_err(), KeyError::Malformed, "{junk:?}");
169+ }
170+ }
171+
172+ #[test]
173+ fn absurdly_long_input_is_rejected_before_parsing() {
174+ let huge = "a".repeat(MAX_LEN + 1);
175+ assert_eq!(parse(&huge).unwrap_err(), KeyError::TooLong);
176+ }
177+
178+ #[test]
179+ fn stored_form_is_canonical_not_whatever_was_pasted() {
180+ let messy = format!(" {ED25519} ");
181+ let k = parse(&messy).unwrap();
182+ assert_eq!(k.openssh, k.openssh.trim(), "no stray whitespace reaches the database");
183+ // Re-parsing what we store must yield the same key.
184+ assert_eq!(parse(&k.openssh).unwrap().fingerprint, k.fingerprint);
185+ }
186+
187+ #[test]
188+ fn names_default_to_the_comment() {
189+ assert_eq!(suggested_name(&parse(ED25519).unwrap()), "alice@example.com");
190+ let no_comment = ED25519.rsplit_once(' ').unwrap().0;
191+ assert_eq!(suggested_name(&parse(no_comment).unwrap()), "ssh-ed25519");
192+ }
193+}
Acrates/df-auth/src/tokens.rs+199−0
@@ −0,0 +1,199 @@
1+//! Personal access tokens for Git over HTTPS (spec §6, §9).
2+//!
3+//! "Tokens are `dgf_` + 32 random bytes base62; store only an Argon2id hash and
4+//! an 8-character prefix for identification. Show plaintext exactly once."
5+
6+use anyhow::{Context, Result};
7+use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
8+use argon2::Argon2;
9+use chrono::{DateTime, Utc};
10+use df_db::ids::new_id;
11+use rand::RngCore;
12+use sqlx::PgPool;
13+use uuid::Uuid;
14+
15+const PREFIX: &str = "dgf_";
16+const RANDOM_BYTES: usize = 32;
17+/// Length of the identifying prefix stored alongside the hash. Includes `dgf_`.
18+const ID_PREFIX_LEN: usize = 8;
19+
20+const BASE62: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
21+
22+/// A freshly minted token. The plaintext exists only here and is shown once.
23+pub struct NewToken {
24+ pub id: Uuid,
25+ pub plaintext: String,
26+ pub prefix: String,
27+}
28+
29+/// Generate a token, hash it, and store it.
30+pub async fn create(
31+ db: &PgPool,
32+ user_id: Uuid,
33+ name: &str,
34+ scopes: &[String],
35+ expires_at: Option<DateTime<Utc>>,
36+) -> Result<NewToken> {
37+ let plaintext = generate();
38+ let prefix: String = plaintext.chars().take(ID_PREFIX_LEN).collect();
39+ let hash = hash_token(&plaintext)?;
40+ let id = new_id();
41+
42+ sqlx::query(
43+ "INSERT INTO access_tokens (id, user_id, name, token_hash, prefix, scopes, expires_at)
44+ VALUES ($1, $2, $3, $4, $5, $6, $7)",
45+ )
46+ .bind(id)
47+ .bind(user_id)
48+ .bind(name)
49+ .bind(&hash)
50+ .bind(&prefix)
51+ .bind(scopes)
52+ .bind(expires_at)
53+ .execute(db)
54+ .await
55+ .context("storing access token")?;
56+
57+ Ok(NewToken { id, plaintext, prefix })
58+}
59+
60+/// Generate a token string: `dgf_` followed by 32 random bytes in base62.
61+fn generate() -> String {
62+ let mut bytes = [0u8; RANDOM_BYTES];
63+ rand::thread_rng().fill_bytes(&mut bytes);
64+
65+ let mut s = String::with_capacity(PREFIX.len() + RANDOM_BYTES);
66+ s.push_str(PREFIX);
67+ // Rejection-free mapping: 256 is not a multiple of 62, so a plain modulo
68+ // biases the alphabet slightly. Each byte contributes well under a full
69+ // character of entropy anyway (32 bytes -> ~190 bits), so the bias is not
70+ // security-relevant here, but we draw fresh randomness per character rather
71+ // than reusing the byte array to keep that reasoning simple.
72+ let mut extra = [0u8; RANDOM_BYTES];
73+ rand::thread_rng().fill_bytes(&mut extra);
74+ for i in 0..RANDOM_BYTES {
75+ let v = ((bytes[i] as u16) << 8 | extra[i] as u16) % BASE62.len() as u16;
76+ s.push(BASE62[v as usize] as char);
77+ }
78+ s
79+}
80+
81+fn hash_token(plaintext: &str) -> Result<String> {
82+ let salt = SaltString::generate(&mut rand::thread_rng());
83+ let hash = Argon2::default()
84+ .hash_password(plaintext.as_bytes(), &salt)
85+ .map_err(|e| anyhow::anyhow!("hashing token: {e}"))?;
86+ Ok(hash.to_string())
87+}
88+
89+/// Verify a presented token and return the owning user id.
90+///
91+/// Looks up candidates by prefix so Argon2 runs against at most a handful of
92+/// rows rather than the whole table. Returns `None` for unknown, expired, or
93+/// mismatched tokens without distinguishing between them.
94+pub async fn verify(db: &PgPool, presented: &str) -> Result<Option<Uuid>> {
95+ if !presented.starts_with(PREFIX) || presented.len() != PREFIX.len() + RANDOM_BYTES {
96+ return Ok(None);
97+ }
98+ let prefix: String = presented.chars().take(ID_PREFIX_LEN).collect();
99+
100+ let candidates: Vec<(Uuid, Uuid, String)> = sqlx::query_as(
101+ "SELECT id, user_id, token_hash FROM access_tokens
102+ WHERE prefix = $1 AND (expires_at IS NULL OR expires_at > now())",
103+ )
104+ .bind(&prefix)
105+ .fetch_all(db)
106+ .await?;
107+
108+ for (token_id, user_id, stored) in candidates {
109+ let parsed = match PasswordHash::new(&stored) {
110+ Ok(p) => p,
111+ Err(e) => {
112+ tracing::error!(%token_id, "stored token hash is unparseable: {e}");
113+ continue;
114+ }
115+ };
116+ // Argon2's verify is constant-time with respect to the hash comparison.
117+ if Argon2::default()
118+ .verify_password(presented.as_bytes(), &parsed)
119+ .is_ok()
120+ {
121+ // Best-effort: a failure to record usage must not fail the request.
122+ if let Err(e) = sqlx::query("UPDATE access_tokens SET last_used_at = now() WHERE id = $1")
123+ .bind(token_id)
124+ .execute(db)
125+ .await
126+ {
127+ tracing::warn!(%token_id, "recording token use failed: {e}");
128+ }
129+ return Ok(Some(user_id));
130+ }
131+ }
132+
133+ Ok(None)
134+}
135+
136+pub async fn revoke(db: &PgPool, user_id: Uuid, token_id: Uuid) -> Result<bool> {
137+ let r = sqlx::query("DELETE FROM access_tokens WHERE id = $1 AND user_id = $2")
138+ .bind(token_id)
139+ .bind(user_id)
140+ .execute(db)
141+ .await?;
142+ Ok(r.rows_affected() > 0)
143+}
144+
145+#[cfg(test)]
146+mod tests {
147+ use super::*;
148+
149+ #[test]
150+ fn generated_tokens_have_the_documented_shape() {
151+ let t = generate();
152+ assert!(t.starts_with("dgf_"), "token must carry the dgf_ prefix: {t}");
153+ assert_eq!(t.len(), 4 + 32);
154+ assert!(
155+ t[4..].bytes().all(|b| BASE62.contains(&b)),
156+ "token body must be base62: {t}"
157+ );
158+ }
159+
160+ #[test]
161+ fn generated_tokens_are_unique() {
162+ use std::collections::HashSet;
163+ let set: HashSet<String> = (0..2000).map(|_| generate()).collect();
164+ assert_eq!(set.len(), 2000);
165+ }
166+
167+ #[test]
168+ fn hashing_is_salted_so_equal_tokens_differ_on_disk() {
169+ let t = generate();
170+ assert_ne!(
171+ hash_token(&t).unwrap(),
172+ hash_token(&t).unwrap(),
173+ "identical tokens must not produce identical hashes"
174+ );
175+ }
176+
177+ #[test]
178+ fn round_trips_through_argon2() {
179+ let t = generate();
180+ let stored = hash_token(&t).unwrap();
181+ let parsed = PasswordHash::new(&stored).unwrap();
182+ assert!(Argon2::default().verify_password(t.as_bytes(), &parsed).is_ok());
183+ assert!(Argon2::default()
184+ .verify_password(b"dgf_wrongwrongwrongwrongwrongwrongwr", &parsed)
185+ .is_err());
186+ }
187+
188+ #[test]
189+ fn the_stored_prefix_identifies_without_revealing() {
190+ let t = generate();
191+ let prefix: String = t.chars().take(ID_PREFIX_LEN).collect();
192+ assert_eq!(prefix.len(), 8);
193+ assert!(t.starts_with(&prefix));
194+ // 4 of the 8 characters are the constant `dgf_`, so only 4 random
195+ // characters are exposed — enough to tell tokens apart in a list,
196+ // far too few to guess the remaining 28.
197+ assert_eq!(&prefix[..4], "dgf_");
198+ }
199+}
Acrates/df-db/Cargo.toml+18−0
@@ −0,0 +1,18 @@
1+[package]
2+name = "df-db"
3+version.workspace = true
4+edition.workspace = true
5+rust-version.workspace = true
6+license.workspace = true
7+
8+[dependencies]
9+sqlx.workspace = true
10+uuid.workspace = true
11+chrono.workspace = true
12+serde.workspace = true
13+serde_json.workspace = true
14+anyhow.workspace = true
15+thiserror.workspace = true
16+tracing.workspace = true
17+tokio.workspace = true
18+futures.workspace = true
Acrates/df-db/build.rs+27−0
@@ −0,0 +1,27 @@
1+//! Rebuild `df-db` whenever a migration changes.
2+//!
3+//! `sqlx::migrate!` embeds the contents of `migrations/` into the binary at
4+//! **compile time**. Cargo cannot see that dependency by itself: nothing in
5+//! `src/` mentions those files, so adding a migration does not make the crate
6+//! look dirty and the old set stays embedded.
7+//!
8+//! That failure is silent and nasty. The binary starts, reports "migrations up
9+//! to date", and then every query against a column the missing migration was
10+//! supposed to add fails at runtime — which is exactly how it presented when it
11+//! happened here: a `column r.search does not exist` from a build that thought
12+//! it had applied migration 3.
13+//!
14+//! Emitting the directory as a dependency makes the whole thing ordinary again.
15+
16+fn main() {
17+ // The directory itself, so an added or removed file is noticed…
18+ println!("cargo:rerun-if-changed=../../migrations");
19+
20+ // …and each file, so an *edited* one is too. A directory's mtime does not
21+ // change when a file inside it is modified in place.
22+ if let Ok(entries) = std::fs::read_dir("../../migrations") {
23+ for entry in entries.flatten() {
24+ println!("cargo:rerun-if-changed={}", entry.path().display());
25+ }
26+ }
27+}
Acrates/df-db/src/ids.rs+44−0
@@ −0,0 +1,44 @@
1+//! UUIDv7 identifiers.
2+//!
3+//! Spec §5: "UUIDv7 primary keys throughout — time-ordered, index-friendly, and
4+//! they don't leak counts." Generated in the application rather than by the
5+//! database so an id is known before insert.
6+
7+use uuid::Uuid;
8+
9+/// Mint a new time-ordered identifier.
10+pub fn new_id() -> Uuid {
11+ Uuid::now_v7()
12+}
13+
14+#[cfg(test)]
15+mod tests {
16+ use super::*;
17+
18+ #[test]
19+ fn ids_are_version_7() {
20+ assert_eq!(new_id().get_version_num(), 7);
21+ }
22+
23+ #[test]
24+ fn ids_are_time_ordered() {
25+ // The property that makes these index-friendly: successive ids sort in
26+ // creation order, so btree inserts stay at the right edge of the tree.
27+ let mut prev = new_id();
28+ for _ in 0..1000 {
29+ let next = new_id();
30+ assert!(
31+ next > prev,
32+ "uuidv7 must be monotonically increasing: {prev} then {next}"
33+ );
34+ prev = next;
35+ }
36+ }
37+
38+ #[test]
39+ fn ids_are_unique() {
40+ use std::collections::HashSet;
41+ let set: HashSet<Uuid> = (0..10_000).map(|_| new_id()).collect();
42+ assert_eq!(set.len(), 10_000);
43+ }
44+}
Acrates/df-db/src/lib.rs+91−0
@@ −0,0 +1,91 @@
1+//! `df-db` — connection pooling, migrations, and shared row types.
2+//!
3+//! Every other crate talks to Postgres through this one so that pool
4+//! configuration, migration locking, and the enum mappings live in one place.
5+
6+use std::time::Duration;
7+
8+use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
9+use sqlx::ConnectOptions;
10+
11+pub mod ids;
12+pub mod models;
13+
14+pub use sqlx;
15+pub use sqlx::PgPool;
16+pub use sqlx::PgPool as Pool;
17+
18+/// Advisory lock key for schema migrations.
19+///
20+/// `web` runs migrations at startup (spec §10) and more than one instance may
21+/// start at once, so the lock serialises them. The constant is arbitrary but
22+/// must never change — a different value would let two versions migrate
23+/// concurrently.
24+const MIGRATION_LOCK_KEY: i64 = 0x0D06_F00D_0000_0001u64 as i64;
25+
26+/// Open the pool.
27+///
28+/// Statement logging is disabled below WARN: at INFO sqlx logs every statement,
29+/// which on the push path means logging the full contents of an indexing
30+/// transaction on every push.
31+pub async fn connect(url: &str, max_connections: u32) -> anyhow::Result<PgPool> {
32+ let opts: PgConnectOptions = url
33+ .parse::<PgConnectOptions>()?
34+ .log_statements(tracing::log::LevelFilter::Debug)
35+ .log_slow_statements(tracing::log::LevelFilter::Warn, Duration::from_millis(500))
36+ .application_name("dogfood");
37+
38+ let pool = PgPoolOptions::new()
39+ .max_connections(max_connections)
40+ // The provided instance reports max_connections = 100 and that budget is
41+ // shared, so we stay well clear of it and recycle idle connections.
42+ .min_connections(1)
43+ .acquire_timeout(Duration::from_secs(10))
44+ .idle_timeout(Duration::from_secs(300))
45+ .max_lifetime(Duration::from_secs(1800))
46+ .test_before_acquire(true)
47+ .connect_with(opts)
48+ .await?;
49+
50+ Ok(pool)
51+}
52+
53+/// Apply pending migrations under an advisory lock.
54+///
55+/// sqlx has its own locking, but it is per-migration; taking one session-level
56+/// lock around the whole run means a second starting instance waits for the
57+/// entire migration set rather than interleaving with it.
58+pub async fn migrate(pool: &PgPool) -> anyhow::Result<()> {
59+ // A dedicated connection: the lock is session-scoped, so it must be held on
60+ // one connection for the duration and released explicitly.
61+ let mut conn = pool.acquire().await?;
62+
63+ tracing::info!("acquiring migration advisory lock");
64+ sqlx::query("SELECT pg_advisory_lock($1)")
65+ .bind(MIGRATION_LOCK_KEY)
66+ .execute(&mut *conn)
67+ .await?;
68+
69+ let result = sqlx::migrate!("../../migrations").run(&mut *conn).await;
70+
71+ // Release the lock whether or not migration succeeded, so a failed deploy
72+ // does not wedge every subsequent start.
73+ let unlock = sqlx::query("SELECT pg_advisory_unlock($1)")
74+ .bind(MIGRATION_LOCK_KEY)
75+ .execute(&mut *conn)
76+ .await;
77+
78+ result?;
79+ unlock?;
80+
81+ tracing::info!("migrations up to date");
82+ Ok(())
83+}
84+
85+/// Liveness check for `/readyz`.
86+pub async fn ping(pool: &PgPool) -> anyhow::Result<()> {
87+ sqlx::query_scalar::<_, i32>("SELECT 1")
88+ .fetch_one(pool)
89+ .await?;
90+ Ok(())
91+}
Acrates/df-db/src/models.rs+168−0
@@ −0,0 +1,168 @@
1+//! Row types shared across crates.
2+//!
3+//! Only the types more than one crate needs live here. Query-specific shapes
4+//! stay next to their queries.
5+
6+use chrono::{DateTime, Utc};
7+use serde::{Deserialize, Serialize};
8+use uuid::Uuid;
9+
10+#[derive(Debug, Clone, sqlx::FromRow)]
11+pub struct User {
12+ pub id: Uuid,
13+ pub subject: String,
14+ pub handle: String,
15+ pub display_name: Option<String>,
16+ pub email: Option<String>,
17+ pub avatar_url: Option<String>,
18+ pub is_admin: bool,
19+ pub created_at: DateTime<Utc>,
20+}
21+
22+impl User {
23+ /// What to show in the UI: the display name if we have one, else the handle.
24+ pub fn label(&self) -> &str {
25+ self.display_name
26+ .as_deref()
27+ .filter(|s| !s.trim().is_empty())
28+ .unwrap_or(&self.handle)
29+ }
30+}
31+
32+#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
33+#[sqlx(type_name = "owner_kind", rename_all = "lowercase")]
34+#[serde(rename_all = "lowercase")]
35+pub enum OwnerKind {
36+ User,
37+ Org,
38+}
39+
40+#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
41+#[sqlx(type_name = "visibility", rename_all = "lowercase")]
42+#[serde(rename_all = "lowercase")]
43+pub enum Visibility {
44+ Public,
45+ Private,
46+}
47+
48+/// Repository access level.
49+///
50+/// Ordered least- to most-privileged; `PartialOrd` is derived from that order so
51+/// permission checks read as `role >= RepoRole::Write`.
52+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, sqlx::Type, Serialize, Deserialize)]
53+#[sqlx(type_name = "repo_role", rename_all = "lowercase")]
54+#[serde(rename_all = "lowercase")]
55+pub enum RepoRole {
56+ Read,
57+ Write,
58+ Maintain,
59+ Admin,
60+}
61+
62+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, sqlx::Type, Serialize, Deserialize)]
63+#[sqlx(type_name = "org_role", rename_all = "lowercase")]
64+#[serde(rename_all = "lowercase")]
65+pub enum OrgRole {
66+ Member,
67+ Admin,
68+}
69+
70+#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
71+#[sqlx(type_name = "change_state", rename_all = "lowercase")]
72+#[serde(rename_all = "lowercase")]
73+pub enum ChangeState {
74+ Draft,
75+ Open,
76+ Merged,
77+ Abandoned,
78+}
79+
80+#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
81+#[sqlx(type_name = "issue_state", rename_all = "lowercase")]
82+#[serde(rename_all = "lowercase")]
83+pub enum IssueState {
84+ Open,
85+ Closed,
86+}
87+
88+#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
89+#[sqlx(type_name = "anchor_state", rename_all = "lowercase")]
90+#[serde(rename_all = "lowercase")]
91+pub enum AnchorState {
92+ Current,
93+ Outdated,
94+ Orphaned,
95+}
96+
97+#[derive(Debug, Clone, sqlx::FromRow)]
98+pub struct Repo {
99+ pub id: Uuid,
100+ pub owner_kind: OwnerKind,
101+ pub owner_user_id: Option<Uuid>,
102+ pub owner_org_id: Option<Uuid>,
103+ pub name: String,
104+ pub description: Option<String>,
105+ pub visibility: Visibility,
106+ pub default_bookmark: String,
107+ pub fork_of_repo_id: Option<Uuid>,
108+ pub size_bytes: i64,
109+ pub pushed_at: Option<DateTime<Utc>>,
110+ pub archived: bool,
111+ pub created_at: DateTime<Utc>,
112+}
113+
114+impl Repo {
115+ pub fn is_public(&self) -> bool {
116+ matches!(self.visibility, Visibility::Public)
117+ }
118+
119+ /// Whether this repo has ever received a push. An empty repo shows clone
120+ /// instructions rather than a file listing.
121+ pub fn is_empty(&self) -> bool {
122+ self.pushed_at.is_none()
123+ }
124+}
125+
126+#[derive(Debug, Clone)]
127+pub struct Session {
128+ pub id: Uuid,
129+ pub user_id: Uuid,
130+ pub expires_at: DateTime<Utc>,
131+}
132+
133+#[cfg(test)]
134+mod tests {
135+ use super::*;
136+
137+ #[test]
138+ fn repo_roles_are_ordered_by_privilege() {
139+ // Permission checks rely on this ordering, so it is worth pinning.
140+ assert!(RepoRole::Read < RepoRole::Write);
141+ assert!(RepoRole::Write < RepoRole::Maintain);
142+ assert!(RepoRole::Maintain < RepoRole::Admin);
143+ assert_eq!(
144+ RepoRole::Admin.max(RepoRole::Read),
145+ RepoRole::Admin,
146+ "effective role is the maximum of the roles a user holds"
147+ );
148+ }
149+
150+ #[test]
151+ fn user_label_falls_back_to_handle() {
152+ let mut u = User {
153+ id: Uuid::nil(),
154+ subject: "s".into(),
155+ handle: "alice".into(),
156+ display_name: None,
157+ email: None,
158+ avatar_url: None,
159+ is_admin: false,
160+ created_at: Utc::now(),
161+ };
162+ assert_eq!(u.label(), "alice");
163+ u.display_name = Some(" ".into());
164+ assert_eq!(u.label(), "alice", "blank display names must not be shown");
165+ u.display_name = Some("Alice Liddell".into());
166+ assert_eq!(u.label(), "Alice Liddell");
167+ }
168+}
Acrates/df-hook/Cargo.toml+18−0
@@ −0,0 +1,18 @@
1+[package]
2+name = "df-hook"
3+version.workspace = true
4+edition.workspace = true
5+rust-version.workspace = true
6+license.workspace = true
7+
8+[[bin]]
9+name = "dogfood-hook"
10+path = "src/main.rs"
11+
12+[dependencies]
13+df-db = { path = "../df-db" }
14+df-store = { path = "../df-store" }
15+
16+sqlx.workspace = true
17+tokio = { workspace = true }
18+uuid.workspace = true
Acrates/df-hook/src/main.rs+314−0
@@ −0,0 +1,314 @@
1+//! `dogfood-hook` — the pre-receive hook (spec §4).
2+//!
3+//! > Hooks are the compiled `dogfood-hook` binary, installed into each repo's
4+//! > `hooks/` directory at creation and connecting to the database over a local
5+//! > socket. Do not use shell scripts — argument handling and error propagation
6+//! > are both worse.
7+//!
8+//! Runs synchronously on the push path with a <200ms budget, so it does the
9+//! cheap checks first and only touches the database when it must.
10+//!
11+//! stdin is one line per ref update: `<old-oid> <new-oid> <refname>`.
12+//! A non-zero exit rejects the entire push.
13+
14+use std::io::{BufRead, Write};
15+use std::process::ExitCode;
16+
17+use df_store::refname;
18+
19+/// Hard cap on the whole hook, so a database stall cannot hang a push
20+/// indefinitely. On timeout we *allow* the push: the alternative is that a
21+/// database blip blocks all pushes, and the indexer re-derives state anyway.
22+/// Ref-name validation is local and has already run by then.
23+const DB_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(1500);
24+
25+fn main() -> ExitCode {
26+ let mut stderr = std::io::stderr();
27+
28+ let updates = match read_updates() {
29+ Ok(u) => u,
30+ Err(e) => {
31+ let _ = writeln!(stderr, "dogfood: could not read ref updates: {e}");
32+ return ExitCode::FAILURE;
33+ }
34+ };
35+
36+ if updates.is_empty() {
37+ return ExitCode::SUCCESS;
38+ }
39+
40+ // ── 1. ref-name validation (local, no I/O) ───────────────────────────────
41+ for u in &updates {
42+ if let Err(e) = refname::validate_pushed_ref(&u.refname) {
43+ // The message is printed to the pusher's terminal. `RefError`'s
44+ // Display never echoes the offending byte, which is the point.
45+ let _ = writeln!(stderr, "dogfood: rejected `{}`: {e}", sanitise(&u.refname));
46+ return ExitCode::FAILURE;
47+ }
48+ }
49+
50+ // ── 2. protected bookmarks (needs the database) ──────────────────────────
51+ let repo_id = std::env::var("DOGFOOD_REPO_ID").ok();
52+ let database_url = std::env::var("DATABASE_URL").ok();
53+
54+ // Set by whichever transport spawned git, to say "this check is mandatory".
55+ // Its absence means an old hook script or a transport that forgot to pass
56+ // the environment — the exact failure that made bookmark protection a
57+ // no-op over HTTPS without anything reporting it.
58+ let enforced = std::env::var("DOGFOOD_ENFORCE").is_ok();
59+
60+ match (repo_id, database_url) {
61+ (Some(repo_id), Some(url)) => match check_protected(&url, &repo_id, &updates) {
62+ Ok(Some(violation)) => {
63+ let _ = writeln!(stderr, "dogfood: {violation}");
64+ return ExitCode::FAILURE;
65+ }
66+ Ok(None) => {}
67+ Err(e) => {
68+ // A *transient* failure fails open, loudly: a database blip
69+ // blocking every push is worse than one missed check, and ref
70+ // names were already validated locally above.
71+ let _ = writeln!(
72+ stderr,
73+ "dogfood: warning: could not verify bookmark protection ({e}); allowing push"
74+ );
75+ }
76+ },
77+ // Misconfiguration, not a blip. Failing open here is how a protected
78+ // bookmark stays unprotected forever with nobody noticing, so when the
79+ // caller said the check was mandatory, refuse.
80+ _ if enforced => {
81+ let _ = writeln!(
82+ stderr,
83+ "dogfood: push validation is not configured on this server \
84+ (missing DOGFOOD_REPO_ID or DATABASE_URL); refusing the push"
85+ );
86+ return ExitCode::FAILURE;
87+ }
88+ _ => {}
89+ }
90+
91+ ExitCode::SUCCESS
92+}
93+
94+struct Update {
95+ old: String,
96+ new: String,
97+ refname: String,
98+}
99+
100+impl Update {
101+ /// Whether this update deletes the ref.
102+ fn is_delete(&self) -> bool {
103+ self.new.chars().all(|c| c == '0')
104+ }
105+
106+ /// Whether this update is a non-fast-forward (a force push).
107+ ///
108+ /// The hook cannot cheaply prove ancestry without object access, so this is
109+ /// the conservative signal: any update to an existing ref that is not a
110+ /// creation. Protected bookmarks reject both.
111+ fn is_update_of_existing(&self) -> bool {
112+ !self.old.chars().all(|c| c == '0')
113+ }
114+}
115+
116+fn read_updates() -> std::io::Result<Vec<Update>> {
117+ let stdin = std::io::stdin();
118+ let mut out = Vec::new();
119+
120+ for line in stdin.lock().lines() {
121+ let line = line?;
122+ let line = line.trim_end_matches(['\r', '\n']);
123+ if line.is_empty() {
124+ continue;
125+ }
126+ let mut parts = line.split(' ');
127+ let (Some(old), Some(new), Some(refname)) = (parts.next(), parts.next(), parts.next())
128+ else {
129+ // A malformed line means we do not understand the push; refuse
130+ // rather than let it through unvalidated.
131+ return Err(std::io::Error::new(
132+ std::io::ErrorKind::InvalidData,
133+ "malformed ref update line",
134+ ));
135+ };
136+ out.push(Update {
137+ old: old.to_string(),
138+ new: new.to_string(),
139+ refname: refname.to_string(),
140+ });
141+ }
142+ Ok(out)
143+}
144+
145+/// Whether `old` is an ancestor of `new` — i.e. whether this update is a
146+/// fast-forward.
147+///
148+/// Asked of git rather than reasoned about: the hook already runs inside the
149+/// repository (git sets `GIT_DIR`), and re-implementing ancestry against a
150+/// pack we do not otherwise read would be a lot of code to get subtly wrong.
151+///
152+/// On any doubt this returns `false`, so an update we cannot classify is
153+/// treated as a force-push and refused. For a *protected* bookmark that is the
154+/// safe direction.
155+fn is_fast_forward(old: &str, new: &str) -> bool {
156+ std::process::Command::new("git")
157+ .args(["merge-base", "--is-ancestor", old, new])
158+ .stdin(std::process::Stdio::null())
159+ .stdout(std::process::Stdio::null())
160+ .stderr(std::process::Stdio::null())
161+ .status()
162+ .map(|s| s.success())
163+ .unwrap_or(false)
164+}
165+
166+/// Reject pushes to an archived repository, and deletes or force-pushes of
167+/// protected bookmarks.
168+///
169+/// "Protected" means what the settings page says it means: the bookmark cannot
170+/// be **deleted** or **force-updated**. An ordinary fast-forward is allowed —
171+/// refusing those too would block the normal `jj git push -b main` workflow
172+/// while the UI promised it was fine.
173+fn check_protected(
174+ database_url: &str,
175+ repo_id: &str,
176+ updates: &[Update],
177+) -> Result<Option<String>, String> {
178+ let runtime = tokio::runtime::Builder::new_current_thread()
179+ .enable_all()
180+ .build()
181+ .map_err(|e| e.to_string())?;
182+
183+ runtime.block_on(async {
184+ let fut = async {
185+ let pool = df_db::connect(database_url, 1)
186+ .await
187+ .map_err(|e| e.to_string())?;
188+
189+ let repo: uuid::Uuid = repo_id.parse().map_err(|_| "bad repo id".to_string())?;
190+
191+ // An archived repository accepts nothing. Checked here as well as on
192+ // each transport, because the hook is the one place both of them
193+ // pass through.
194+ let archived: Option<(bool,)> =
195+ sqlx::query_as("SELECT archived FROM repos WHERE id = $1")
196+ .bind(repo)
197+ .fetch_optional(&pool)
198+ .await
199+ .map_err(|e| e.to_string())?;
200+
201+ match archived {
202+ Some((true,)) => {
203+ return Ok::<_, String>(Some(
204+ "this repository is archived and does not accept pushes".to_string(),
205+ ))
206+ }
207+ // No row: the repository was deleted between the push starting
208+ // and this check. Refusing is the only safe reading.
209+ None => return Ok(Some("this repository no longer exists".to_string())),
210+ Some((false,)) => {}
211+ }
212+
213+ // The default bookmark is protected whether or not it carries the
214+ // flag. The settings page says "always" for it, and a `bookmarks`
215+ // row created by the indexer defaults to unprotected — so reading
216+ // the flag alone would quietly make that promise false.
217+ let protected: Vec<(String,)> = sqlx::query_as(
218+ "SELECT name FROM bookmarks WHERE repo_id = $1 AND protected
219+ UNION
220+ SELECT default_bookmark FROM repos WHERE id = $1",
221+ )
222+ .bind(repo)
223+ .fetch_all(&pool)
224+ .await
225+ .map_err(|e| e.to_string())?;
226+
227+ for u in updates {
228+ let Some(short) = u.refname.strip_prefix("refs/heads/") else {
229+ continue;
230+ };
231+ if !protected.iter().any(|(n,)| n == short) {
232+ continue;
233+ }
234+ if u.is_delete() {
235+ return Ok(Some(format!(
236+ "`{short}` is a protected bookmark and cannot be deleted"
237+ )));
238+ }
239+ // Creating it is fine — that is the first push to a new
240+ // repository. Updating it is fine too, as long as it only moves
241+ // forward.
242+ if u.is_update_of_existing() && !is_fast_forward(&u.old, &u.new) {
243+ return Ok(Some(format!(
244+ "`{short}` is a protected bookmark and cannot be force-updated; \
245+ rebase onto it, or open a change"
246+ )));
247+ }
248+ }
249+ Ok(None)
250+ };
251+
252+ match tokio::time::timeout(DB_TIMEOUT, fut).await {
253+ Ok(r) => r,
254+ Err(_) => Err("timed out".to_string()),
255+ }
256+ })
257+}
258+
259+/// Render a ref name safely for terminal output.
260+///
261+/// Validation has usually already rejected anything dangerous, but this runs on
262+/// the error path where the name is by definition untrusted.
263+fn sanitise(s: &str) -> String {
264+ s.chars()
265+ .map(|c| {
266+ if c.is_control() || c == '\u{7f}' {
267+ '?'
268+ } else {
269+ c
270+ }
271+ })
272+ .take(200)
273+ .collect()
274+}
275+
276+#[cfg(test)]
277+mod tests {
278+ use super::*;
279+
280+ fn u(old: &str, new: &str, name: &str) -> Update {
281+ Update {
282+ old: old.into(),
283+ new: new.into(),
284+ refname: name.into(),
285+ }
286+ }
287+
288+ const ZERO: &str = "0000000000000000000000000000000000000000";
289+ const OID: &str = "1111111111111111111111111111111111111111";
290+
291+ #[test]
292+ fn detects_deletes() {
293+ assert!(u(OID, ZERO, "refs/heads/main").is_delete());
294+ assert!(!u(OID, OID, "refs/heads/main").is_delete());
295+ }
296+
297+ #[test]
298+ fn detects_creation_versus_update() {
299+ assert!(!u(ZERO, OID, "refs/heads/new").is_update_of_existing());
300+ assert!(u(OID, OID, "refs/heads/main").is_update_of_existing());
301+ }
302+
303+ #[test]
304+ fn sanitise_strips_control_bytes() {
305+ assert_eq!(sanitise("main\x1b[31m"), "main?[31m");
306+ assert_eq!(sanitise("main\n"), "main?");
307+ assert_eq!(sanitise("ok"), "ok");
308+ }
309+
310+ #[test]
311+ fn sanitise_bounds_its_output() {
312+ assert_eq!(sanitise(&"a".repeat(500)).len(), 200);
313+ }
314+}
Acrates/df-index/Cargo.toml+22−0
@@ −0,0 +1,22 @@
1+[package]
2+name = "df-index"
3+version.workspace = true
4+edition.workspace = true
5+rust-version.workspace = true
6+license.workspace = true
7+
8+[dependencies]
9+# The line mapping comment anchors rebase through must agree with the diff the
10+# reviewer sees, so it uses the same differ df-store renders with.
11+similar.workspace = true
12+chrono.workspace = true
13+sha2.workspace = true
14+hex.workspace = true
15+thiserror.workspace = true
16+tracing.workspace = true
17+
18+[dev-dependencies]
19+# The fixture corpus is driven through the git CLI in integration tests rather
20+# than by linking gix here — df-index must not depend on a git library
21+# (spec §3 rule 1).
22+serde_json.workspace = true
Acrates/df-index/src/change_id.rs468 lines+468−0
@@ −0,0 +1,468 @@
1+//! Extraction of jj change ids from raw Git commit objects.
2+//!
3+//! This is the single most format-sensitive function in Dogfood. Everything in
4+//! the change index keys off its output, so it is deliberately strict: it
5+//! either returns an id it is certain about, or `None`.
6+//!
7+//! The wire format is documented, with the observations that established it, in
8+//! `docs/change-id-format.md`. The short version:
9+//!
10+//! - The header key is `change-id`, lowercase and hyphenated.
11+//! - The value is 32 characters in jj's reverse-hex alphabet (`k`..=`z`) — the
12+//! same string the CLI prints. It is **not** hex. Do not decode it.
13+//! - Its position among the headers is not fixed. It appears before `gpgsig` on
14+//! signed commits and after the multi-line `jj:conflict-labels` header on
15+//! conflicted ones.
16+//!
17+//! That last point is why this is a real header parser rather than a substring
18+//! search: a `gpgsig` signature body is base64, and a continuation line inside
19+//! it can begin with any byte sequence at all, including `change-id `.
20+
21+/// A jj change id, in the canonical reverse-hex letter encoding jj stores and
22+/// displays.
23+///
24+/// Stored and compared as the letter string. Constructing one guarantees it is
25+/// well-formed, so downstream code never has to re-validate.
26+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
27+pub struct ChangeId(String);
28+
29+/// Length of a jj change id: 16 bytes rendered as 32 reverse-hex characters.
30+const CHANGE_ID_LEN: usize = 32;
31+
32+/// jj's reverse-hex alphabet. `0123456789abcdef` maps to `zyxwvutsrqponmlk`,
33+/// so every character of a well-formed change id falls in `k..=z`.
34+const fn is_reverse_hex(b: u8) -> bool {
35+ b >= b'k' && b <= b'z'
36+}
37+
38+impl ChangeId {
39+ /// Parse a change id from its canonical letter encoding.
40+ ///
41+ /// Returns `None` unless the input is exactly 32 characters drawn from
42+ /// jj's reverse-hex alphabet. Being strict here is what keeps malformed
43+ /// ids out of the index, where they would be indistinguishable from real
44+ /// ones forever.
45+ pub fn parse(s: &str) -> Option<Self> {
46+ let b = s.as_bytes();
47+ if b.len() != CHANGE_ID_LEN || !b.iter().copied().all(is_reverse_hex) {
48+ return None;
49+ }
50+ Some(ChangeId(s.to_owned()))
51+ }
52+
53+ pub fn as_str(&self) -> &str {
54+ &self.0
55+ }
56+
57+ /// The abbreviated form shown in the UI, matching how the CLI abbreviates.
58+ ///
59+ /// This is presentation only. The shortest *unique* prefix within a repo is
60+ /// a database question, not a property of the id, and is resolved by the
61+ /// change lookup query rather than here.
62+ pub fn short(&self) -> &str {
63+ &self.0[..12.min(self.0.len())]
64+ }
65+}
66+
67+impl std::fmt::Display for ChangeId {
68+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69+ f.write_str(&self.0)
70+ }
71+}
72+
73+/// One header of a Git commit object, with continuation lines folded in.
74+struct Header<'a> {
75+ key: &'a [u8],
76+ /// Raw value. For multi-line headers this is the first line only; callers
77+ /// that need the folded value use [`Headers::folded`]. Every header
78+ /// Dogfood cares about is single-line, so this avoids allocating for the
79+ /// large `gpgsig` values that make up most of a signed commit.
80+ value: &'a [u8],
81+}
82+
83+/// Iterator over the header block of a raw commit object.
84+///
85+/// Stops at the first empty line, which per the Git object format terminates
86+/// the headers and begins the message. Lines beginning with a single space are
87+/// continuations of the preceding header and are skipped rather than being
88+/// mistaken for new headers — the hazard this parser exists to avoid.
89+struct Headers<'a> {
90+ rest: &'a [u8],
91+ done: bool,
92+}
93+
94+impl<'a> Headers<'a> {
95+ fn new(raw: &'a [u8]) -> Self {
96+ Headers { rest: raw, done: false }
97+ }
98+}
99+
100+impl<'a> Iterator for Headers<'a> {
101+ type Item = Header<'a>;
102+
103+ fn next(&mut self) -> Option<Header<'a>> {
104+ loop {
105+ if self.done || self.rest.is_empty() {
106+ return None;
107+ }
108+
109+ // Split off one line.
110+ let (line, tail) = match memchr(b'\n', self.rest) {
111+ Some(i) => (&self.rest[..i], &self.rest[i + 1..]),
112+ // No trailing newline: this is the last line of the object.
113+ None => (self.rest, &self.rest[self.rest.len()..]),
114+ };
115+ self.rest = tail;
116+
117+ // An empty line ends the header block; the message follows.
118+ if line.is_empty() {
119+ self.done = true;
120+ return None;
121+ }
122+
123+ // A leading space marks a continuation of the previous header's
124+ // value (gpgsig, jj:conflict-labels). Skip it: it is never the
125+ // start of a new header, no matter what bytes it contains.
126+ if line[0] == b' ' {
127+ continue;
128+ }
129+
130+ // `key SP value`. A header with no space is malformed; skip it
131+ // rather than aborting, so one odd header cannot cost us the
132+ // change id.
133+ return match memchr(b' ', line) {
134+ Some(i) => Some(Header { key: &line[..i], value: &line[i + 1..] }),
135+ None => continue,
136+ };
137+ }
138+ }
139+}
140+
141+/// Minimal byte search, to avoid pulling in `memchr` for two call sites.
142+fn memchr(needle: u8, haystack: &[u8]) -> Option<usize> {
143+ haystack.iter().position(|&b| b == needle)
144+}
145+
146+/// Extract the jj change id from a raw git commit object.
147+///
148+/// Returns `None` for commits authored by plain git, which carry no such
149+/// header, and for commits whose `change-id` value is not a well-formed id.
150+/// Callers treat `None` as "this commit needs a synthetic identity" (spec §4).
151+///
152+/// `raw_commit` is the object payload — the bytes `git cat-file commit` prints,
153+/// without the `commit <len>\0` prefix that precedes them in the object store.
154+pub fn extract_change_id(raw_commit: &[u8]) -> Option<ChangeId> {
155+ for h in Headers::new(raw_commit) {
156+ if h.key == b"change-id" {
157+ // Trailing `\r` would mean a mangled object; `from_utf8` on the
158+ // trimmed value rejects anything non-ASCII anyway, and `parse`
159+ // rejects anything outside the alphabet.
160+ return std::str::from_utf8(h.value).ok().and_then(ChangeId::parse);
161+ }
162+ }
163+ None
164+}
165+
166+/// Conflict information carried in a jj commit's `jj:trees` header.
167+///
168+/// jj stores a conflicted state as an alternating list of tree ids:
169+/// `side_0, base_0, side_1, base_1, side_2, …` — always one more side than
170+/// base. See `docs/change-id-format.md` §6.
171+#[derive(Debug, Clone, PartialEq, Eq)]
172+pub struct ConflictTrees {
173+ pub sides: Vec<String>,
174+ pub bases: Vec<String>,
175+}
176+
177+/// Extract conflict tree structure from a raw commit object.
178+///
179+/// Returns `None` for unconflicted commits. Detection uses the `jj:trees`
180+/// header rather than looking for `.jjconflict-*` entries in the tree, because
181+/// the header is authoritative and requires no object loads.
182+pub fn extract_conflict_trees(raw_commit: &[u8]) -> Option<ConflictTrees> {
183+ let value = Headers::new(raw_commit).find(|h| h.key == b"jj:trees")?.value;
184+ let ids: Vec<String> = std::str::from_utf8(value)
185+ .ok()?
186+ .split_ascii_whitespace()
187+ .map(str::to_owned)
188+ .collect();
189+
190+ // Must be a non-empty odd count: sides = bases + 1. Anything else is not a
191+ // shape we understand, and guessing at it would produce a wrong conflict
192+ // view rather than an honest failure.
193+ if ids.is_empty() || ids.len() % 2 == 0 {
194+ return None;
195+ }
196+
197+ let mut sides = Vec::with_capacity(ids.len() / 2 + 1);
198+ let mut bases = Vec::with_capacity(ids.len() / 2);
199+ for (i, id) in ids.into_iter().enumerate() {
200+ if i % 2 == 0 {
201+ sides.push(id);
202+ } else {
203+ bases.push(id);
204+ }
205+ }
206+ Some(ConflictTrees { sides, bases })
207+}
208+
209+/// Tree entry names jj uses for conflict storage. These are implementation
210+/// detail of the storage format and must never appear in a user-facing file
211+/// listing.
212+pub fn is_conflict_artifact(name: &str) -> bool {
213+ name.starts_with(".jjconflict-") || name == "JJ-CONFLICT-README"
214+}
215+
216+#[cfg(test)]
217+mod tests {
218+ use super::*;
219+
220+ /// A jj commit exactly as observed from `git cat-file commit`.
221+ const JJ_SIMPLE: &[u8] = b"\
222+tree 2e81171448eb9f2ee3821e3d447aa6b2fe3ddba1
223+author Fixture Author <fixture@dogfood.sh> 1785482369 +0000
224+committer Fixture Author <fixture@dogfood.sh> 1785482369 +0000
225+change-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu
226+
227+first change
228+";
229+
230+ const PLAIN_GIT: &[u8] = b"\
231+tree 4997ca7a42e3ad9b729fbad3acd44fbabd07b6bd
232+author Plain Git <pg@dogfood.sh> 1785482410 +0000
233+committer Plain Git <pg@dogfood.sh> 1785482410 +0000
234+
235+plain git commit
236+";
237+
238+ const JJ_MERGE: &[u8] = b"\
239+tree 6349276c93aa7281b3ea997c1ed9eee4fca779ca
240+parent 0e6a26973ecf5056359d2b82f39ad9901675bd7f
241+parent f4017227300e3da7b8b05c461bc1bc24047707c8
242+author Fixture Author <fixture@dogfood.sh> 1785482416 +0000
243+committer Fixture Author <fixture@dogfood.sh> 1785482416 +0000
244+change-id vokuuvrqvyoksvpnxznztskrpwnstmqt
245+
246+merge of left and right
247+";
248+
249+ /// Signed commit: `change-id` precedes a multi-line `gpgsig`.
250+ const JJ_SIGNED: &[u8] = b"\
251+tree ec67420ed747b72ce94854190b4c59deff01b9db
252+author Fixture Author <fixture@dogfood.sh> 1785482470 +0000
253+committer Fixture Author <fixture@dogfood.sh> 1785482470 +0000
254+change-id rwsosmnkorymmtvvzwkuuzzqqmuonwnn
255+gpgsig -----BEGIN SSH SIGNATURE-----
256+ U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgpDlDGOvAOfzKu8EtSuCO+q7OMl
257+ P4iz0WatcsQcBg98kAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
258+ AAAAQLPQ/lWPdf2zmi261EFYVcLDTOT1CpY8cEWRpjQXTPAkOOsVexrIzZypFgM4yTtgyU
259+ pwfjmHzZ3D9uEeK1H3Ygg=
260+ -----END SSH SIGNATURE-----
261+
262+signed via jj
263+";
264+
265+ /// Conflicted commit: `change-id` comes AFTER a multi-line `jj:` header.
266+ const JJ_CONFLICTED: &[u8] = b"\
267+tree 125bc2eed773e5eacbb11544e9966dab850ed4f9
268+parent 79976290ff8d8ad397f41d423a469b26d225f157
269+parent 47129393c3d17b7031ff88faa27ffe1b8180c225
270+author Fixture Author <fixture@dogfood.sh> 1785497562 +0000
271+committer Fixture Author <fixture@dogfood.sh> 1785497562 +0000
272+jj:conflict-labels qtmrpxym 79976290 \"side A\"
273+ qmksvpxn 691a639f \"conflict base\"
274+ syuzyywq 47129393 \"side B\"
275+jj:trees ada380c77b2f1923458d0b93cbfee42f297a58e9 892cad2b4d1f22ae36e2107a6a187a5dc227f965 14a356d6227b63a22f94c18817912d5e37b77c21
276+change-id psxoyvqkymplnoqklzrotrqopkxqkynz
277+
278+conflicted merge
279+";
280+
281+ #[test]
282+ fn extracts_from_simple_jj_commit() {
283+ let id = extract_change_id(JJ_SIMPLE).expect("jj commit must yield an id");
284+ assert_eq!(id.as_str(), "qstvwxmpkvovsosxxmrnxpqmuuyllsqu");
285+ }
286+
287+ #[test]
288+ fn plain_git_commit_has_no_change_id() {
289+ assert_eq!(extract_change_id(PLAIN_GIT), None);
290+ }
291+
292+ #[test]
293+ fn extracts_from_merge_commit() {
294+ let id = extract_change_id(JJ_MERGE).unwrap();
295+ assert_eq!(id.as_str(), "vokuuvrqvyoksvpnxznztskrpwnstmqt");
296+ }
297+
298+ #[test]
299+ fn extracts_when_followed_by_multiline_signature() {
300+ let id = extract_change_id(JJ_SIGNED).unwrap();
301+ assert_eq!(id.as_str(), "rwsosmnkorymmtvvzwkuuzzqqmuonwnn");
302+ }
303+
304+ #[test]
305+ fn extracts_when_preceded_by_multiline_jj_header() {
306+ // The case a naive "first N lines" parser gets wrong.
307+ let id = extract_change_id(JJ_CONFLICTED).unwrap();
308+ assert_eq!(id.as_str(), "psxoyvqkymplnoqklzrotrqopkxqkynz");
309+ }
310+
311+ #[test]
312+ fn continuation_lines_are_never_read_as_headers() {
313+ // The specific attack/corruption shape this parser exists to prevent: a
314+ // signature body whose base64 happens to start a line with
315+ // "change-id ". It must be ignored as a continuation, and the real
316+ // header below it must win.
317+ let raw = b"\
318+tree 0000000000000000000000000000000000000000
319+author A <a@b.c> 1 +0000
320+committer A <a@b.c> 1 +0000
321+gpgsig -----BEGIN SSH SIGNATURE-----
322+ change-id kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk
323+ -----END SSH SIGNATURE-----
324+change-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu
325+
326+msg
327+";
328+ let id = extract_change_id(raw).unwrap();
329+ assert_eq!(
330+ id.as_str(),
331+ "qstvwxmpkvovsosxxmrnxpqmuuyllsqu",
332+ "a continuation line must not be parsed as a change-id header"
333+ );
334+ }
335+
336+ #[test]
337+ fn body_text_after_headers_is_not_scanned() {
338+ // A commit message that mentions a change-id header must not be
339+ // mistaken for one. The header block ends at the first blank line.
340+ let raw = b"\
341+tree 0000000000000000000000000000000000000000
342+author A <a@b.c> 1 +0000
343+committer A <a@b.c> 1 +0000
344+
345+see also:
346+change-id kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk
347+";
348+ assert_eq!(extract_change_id(raw), None);
349+ }
350+
351+ #[test]
352+ fn rejects_malformed_values() {
353+ let cases: &[(&str, &[u8])] = &[
354+ ("too short", b"tree x\nchange-id qstvwxmp\n\nm\n"),
355+ (
356+ "too long",
357+ b"tree x\nchange-id qstvwxmpkvovsosxxmrnxpqmuuyllsquz\n\nm\n",
358+ ),
359+ (
360+ "hex, not reverse-hex",
361+ b"tree x\nchange-id 0123456789abcdef0123456789abcdef\n\nm\n",
362+ ),
363+ ("empty", b"tree x\nchange-id \n\nm\n"),
364+ (
365+ "uppercase",
366+ b"tree x\nchange-id QSTVWXMPKVOVSOSXXMRNXPQMUUYLLSQU\n\nm\n",
367+ ),
368+ ];
369+ for (name, raw) in cases {
370+ assert_eq!(extract_change_id(raw), None, "should reject: {name}");
371+ }
372+ }
373+
374+ #[test]
375+ fn header_key_match_is_exact() {
376+ // `jj:change-id`, `Change-Id` (Gerrit's, in the message trailer) and
377+ // similar must not be picked up.
378+ let raw = b"\
379+tree 0000000000000000000000000000000000000000
380+author A <a@b.c> 1 +0000
381+Change-Id qstvwxmpkvovsosxxmrnxpqmuuyllsqu
382+jj:change-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu
383+
384+m
385+";
386+ assert_eq!(extract_change_id(raw), None);
387+ }
388+
389+ #[test]
390+ fn tolerates_unknown_extra_headers() {
391+ let raw = b"\
392+tree 0000000000000000000000000000000000000000
393+author A <a@b.c> 1 +0000
394+committer A <a@b.c> 1 +0000
395+mergetag something
396+encoding ISO-8859-1
397+some-future-jj-header whatever
398+change-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu
399+
400+m
401+";
402+ assert_eq!(
403+ extract_change_id(raw).unwrap().as_str(),
404+ "qstvwxmpkvovsosxxmrnxpqmuuyllsqu"
405+ );
406+ }
407+
408+ #[test]
409+ fn handles_truncated_object_without_panicking() {
410+ // Robustness: the indexer must never panic on a malformed object.
411+ for n in 0..JJ_SIMPLE.len() {
412+ let _ = extract_change_id(&JJ_SIMPLE[..n]);
413+ }
414+ }
415+
416+ #[test]
417+ fn root_change_id_is_all_z() {
418+ // jj renders the root change as 32 'z' (= all-zero bytes). It is a
419+ // well-formed id and must parse, so the indexer can recognise and skip
420+ // it rather than treating it as a user change.
421+ let id = ChangeId::parse("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").unwrap();
422+ assert_eq!(id.short(), "zzzzzzzzzzzz");
423+ }
424+
425+ #[test]
426+ fn parses_conflict_trees() {
427+ let c = extract_conflict_trees(JJ_CONFLICTED).expect("conflicted commit");
428+ assert_eq!(
429+ c.sides,
430+ vec![
431+ "ada380c77b2f1923458d0b93cbfee42f297a58e9".to_string(),
432+ "14a356d6227b63a22f94c18817912d5e37b77c21".to_string(),
433+ ]
434+ );
435+ assert_eq!(
436+ c.bases,
437+ vec!["892cad2b4d1f22ae36e2107a6a187a5dc227f965".to_string()]
438+ );
439+ // The invariant jj's conflict representation guarantees.
440+ assert_eq!(c.sides.len(), c.bases.len() + 1);
441+ }
442+
443+ #[test]
444+ fn unconflicted_commits_have_no_conflict_trees() {
445+ assert_eq!(extract_conflict_trees(JJ_SIMPLE), None);
446+ assert_eq!(extract_conflict_trees(PLAIN_GIT), None);
447+ assert_eq!(extract_conflict_trees(JJ_SIGNED), None);
448+ }
449+
450+ #[test]
451+ fn rejects_even_length_tree_lists() {
452+ // sides == bases + 1 always, so an even count is a shape we do not
453+ // understand and must not guess at.
454+ let raw = b"tree x\njj:trees aaaa bbbb\nchange-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu\n\nm\n";
455+ assert_eq!(extract_conflict_trees(raw), None);
456+ }
457+
458+ #[test]
459+ fn filters_conflict_artifacts_from_listings() {
460+ assert!(is_conflict_artifact(".jjconflict-side-0"));
461+ assert!(is_conflict_artifact(".jjconflict-base-0"));
462+ assert!(is_conflict_artifact("JJ-CONFLICT-README"));
463+ assert!(!is_conflict_artifact("src"));
464+ assert!(!is_conflict_artifact("c.txt"));
465+ // Not a jj artifact: a user file that merely starts similarly.
466+ assert!(!is_conflict_artifact(".jjconflict"));
467+ }
468+}
Acrates/df-index/src/indexer.rs616 lines+616−0
@@ −0,0 +1,616 @@
1+//! The indexing pipeline (spec §4).
2+//!
3+//! ```text
4+//! worker: IndexPush
5+//! 1. walk new commits reachable from updated refs
6+//! 2. extract change ids
7+//! 3. upsert changes and revisions
8+//! 4. recompute stack edges for affected changes
9+//! 5. detect conflict state
10+//! 6. rebase comment anchors onto the new revision
11+//! 7. recompute change states vs target bookmarks
12+//! 8. emit timeline events
13+//! 9. NOTIFY so open pages can refresh
14+//! ```
15+//!
16+//! The indexer must be **idempotent and re-runnable** — running it twice over
17+//! the same repository must produce the same rows, because every change to
18+//! indexing logic means reindexing live repositories.
19+//!
20+//! This module holds the pure decision logic. The database and storage calls
21+//! live in `df-worker`, so the interesting parts are testable without either.
22+
23+use std::collections::{HashMap, HashSet};
24+
25+use crate::ChangeId;
26+
27+/// A commit as the indexer sees it, independent of any Git library.
28+#[derive(Debug, Clone, PartialEq, Eq)]
29+pub struct IndexedCommit {
30+ pub rev: String,
31+ pub change_id: Option<ChangeId>,
32+ pub parents: Vec<String>,
33+ pub author_name: String,
34+ pub author_email: String,
35+ pub authored_at: chrono::DateTime<chrono::Utc>,
36+ pub message: String,
37+ pub conflicted: bool,
38+ pub conflict_sides: Vec<String>,
39+ pub conflict_bases: Vec<String>,
40+}
41+
42+impl IndexedCommit {
43+ pub fn summary(&self) -> &str {
44+ self.message.lines().next().unwrap_or("").trim()
45+ }
46+}
47+
48+/// A stack edge: `parent` sits immediately below `child`.
49+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
50+pub struct StackEdge {
51+ pub parent: String,
52+ pub child: String,
53+}
54+
55+/// Compute stack edges over a set of commits.
56+///
57+/// Spec §4: "A stack is a chain of changes where each is a parent of the next
58+/// and none has yet merged into the target bookmark."
59+///
60+/// Edges are between *changes*, not revisions — that is what makes a stack
61+/// survive a rebase. `merged` names changes already on the target bookmark;
62+/// they terminate a stack rather than extending it.
63+///
64+/// Walks parent links transitively through commits whose change is already
65+/// merged, so a stack is not severed by an intervening landed change.
66+pub fn stack_edges(
67+ commits: &[IndexedCommit],
68+ merged: &HashSet<String>,
69+) -> HashSet<StackEdge> {
70+ // rev -> change id, for the commits in scope.
71+ let by_rev: HashMap<&str, &IndexedCommit> =
72+ commits.iter().map(|c| (c.rev.as_str(), c)).collect();
73+
74+ let change_of = |rev: &str| -> Option<String> {
75+ by_rev
76+ .get(rev)
77+ .and_then(|c| c.change_id.as_ref())
78+ .map(|c| c.as_str().to_owned())
79+ };
80+
81+ let mut edges = HashSet::new();
82+
83+ for c in commits {
84+ let Some(child) = c.change_id.as_ref().map(|c| c.as_str().to_owned()) else {
85+ continue;
86+ };
87+ if merged.contains(&child) {
88+ continue;
89+ }
90+
91+ // Walk up until we find an unmerged ancestor change, or run out.
92+ let mut frontier: Vec<String> = c.parents.clone();
93+ let mut seen: HashSet<String> = HashSet::new();
94+
95+ while let Some(rev) = frontier.pop() {
96+ if !seen.insert(rev.clone()) {
97+ continue;
98+ }
99+ let Some(parent_change) = change_of(&rev) else {
100+ // Outside the indexed set: stop this branch.
101+ continue;
102+ };
103+
104+ if parent_change == child {
105+ // Same change appearing twice in the walk; not an edge.
106+ continue;
107+ }
108+
109+ if merged.contains(&parent_change) {
110+ // Landed: look further up rather than ending the stack here.
111+ if let Some(p) = by_rev.get(rev.as_str()) {
112+ frontier.extend(p.parents.iter().cloned());
113+ }
114+ continue;
115+ }
116+
117+ edges.insert(StackEdge {
118+ parent: parent_change,
119+ child: child.clone(),
120+ });
121+ }
122+ }
123+
124+ edges
125+}
126+
127+/// Decide the new state of a change.
128+///
129+/// Spec §4 step 7, and the draft decision: `draft` is set by the author in the
130+/// UI and must never be overwritten by indexing.
131+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132+pub enum ChangeState {
133+ Draft,
134+ Open,
135+ Merged,
136+ Abandoned,
137+}
138+
139+pub fn next_state(current: ChangeState, on_target_bookmark: bool) -> ChangeState {
140+ match current {
141+ // The author's explicit choice wins over anything inferred.
142+ ChangeState::Draft => ChangeState::Draft,
143+ // Abandoned is also explicit; a re-push does not silently reopen it.
144+ ChangeState::Abandoned => ChangeState::Abandoned,
145+ _ if on_target_bookmark => ChangeState::Merged,
146+ // A change that was merged and is no longer reachable from the target
147+ // has been reverted or the bookmark was rewound; reopen it.
148+ ChangeState::Merged => ChangeState::Open,
149+ ChangeState::Open => ChangeState::Open,
150+ }
151+}
152+
153+/// Where a comment anchor ended up after a new revision landed.
154+///
155+/// Spec §5: "This is the mechanism that makes stable change identity actually
156+/// pay off for reviewers, and it is the single most valuable piece of logic in
157+/// the product."
158+#[derive(Debug, Clone, PartialEq, Eq)]
159+pub enum AnchorOutcome {
160+ /// Line is unchanged; keep `current`, update the anchor revision.
161+ Unchanged,
162+ /// Line moved; keep `current`, update the line number.
163+ Moved { new_line: u32 },
164+ /// The line's content changed; mark `outdated`, retain the original text.
165+ Outdated,
166+ /// The file or hunk is gone; mark `orphaned` and surface in the timeline.
167+ Orphaned,
168+}
169+
170+/// One line of the new version of a file, for anchor rebasing.
171+#[derive(Debug, Clone)]
172+pub struct NewLine {
173+ pub number: u32,
174+ pub content: String,
175+ /// The line number this came from in the old version, when it is a
176+ /// carried-over (context) line.
177+ pub from_old: Option<u32>,
178+}
179+
180+/// Re-anchor a comment onto a new revision.
181+///
182+/// `old_line` is where the comment sat; `anchor_context` is the text of that
183+/// line when the comment was written — the thing we match on, because line
184+/// numbers move but content is what the reviewer was talking about.
185+pub fn rebase_anchor(
186+ old_line: u32,
187+ anchor_context: Option<&str>,
188+ new_lines: &[NewLine],
189+ file_still_exists: bool,
190+) -> AnchorOutcome {
191+ if !file_still_exists {
192+ return AnchorOutcome::Orphaned;
193+ }
194+
195+ // 1. The line survived at a known position.
196+ if let Some(l) = new_lines.iter().find(|l| l.from_old == Some(old_line)) {
197+ // Its content may still have changed (whitespace, a rewrite).
198+ if let Some(ctx) = anchor_context {
199+ if l.content != ctx {
200+ return AnchorOutcome::Outdated;
201+ }
202+ }
203+ return if l.number == old_line {
204+ AnchorOutcome::Unchanged
205+ } else {
206+ AnchorOutcome::Moved { new_line: l.number }
207+ };
208+ }
209+
210+ // 2. The mapping is gone, but identical text still exists — the line was
211+ // moved rather than edited. Only trust this when the content is
212+ // distinctive: matching on a blank line or a lone `}` would anchor the
213+ // comment somewhere arbitrary, which is worse than admitting it is
214+ // outdated.
215+ if let Some(ctx) = anchor_context.filter(|c| is_distinctive(c)) {
216+ let matches: Vec<&NewLine> = new_lines.iter().filter(|l| l.content == ctx).collect();
217+ if matches.len() == 1 {
218+ return AnchorOutcome::Moved {
219+ new_line: matches[0].number,
220+ };
221+ }
222+ }
223+
224+ // 3. The line is gone from the file.
225+ AnchorOutcome::Outdated
226+}
227+
228+/// Whether a line's content is specific enough to re-anchor on.
229+fn is_distinctive(s: &str) -> bool {
230+ let t = s.trim();
231+ // Structural punctuation and blank lines repeat throughout a file.
232+ t.len() >= 4 && t.chars().any(|c| c.is_alphanumeric())
233+}
234+
235+/// Build the old→new line mapping for a file across a rewrite.
236+///
237+/// [`rebase_anchor`] needs to know, for each line of the new file, which line of
238+/// the old file it came from. A diff's hunks only carry that for the lines near
239+/// a change, and a comment can sit anywhere — so the mapping is computed over
240+/// the whole file rather than read out of a hunk.
241+///
242+/// Uses `similar`'s line diff, the same algorithm `df-store` renders diffs with,
243+/// so the mapping a comment is rebased through agrees with the diff a reviewer
244+/// is looking at.
245+pub fn line_map(old: &str, new: &str) -> Vec<NewLine> {
246+ use similar::{ChangeTag, TextDiff};
247+
248+ let diff = TextDiff::from_lines(old, new);
249+ let mut out = Vec::new();
250+
251+ for change in diff.iter_all_changes() {
252+ // Only lines that exist in the new file get an entry; a deleted line is
253+ // absent from the mapping, which is exactly what makes `rebase_anchor`
254+ // fall through to its content search.
255+ let Some(new_index) = change.new_index() else {
256+ continue;
257+ };
258+ out.push(NewLine {
259+ number: new_index as u32 + 1,
260+ content: change.value().trim_end_matches('\n').to_owned(),
261+ from_old: match change.tag() {
262+ // An inserted line came from nowhere.
263+ ChangeTag::Insert => None,
264+ _ => change.old_index().map(|i| i as u32 + 1),
265+ },
266+ });
267+ }
268+
269+ out
270+}
271+
272+#[cfg(test)]
273+mod tests {
274+ use super::*;
275+
276+ fn commit(rev: &str, change: &str, parents: &[&str]) -> IndexedCommit {
277+ IndexedCommit {
278+ rev: rev.into(),
279+ change_id: ChangeId::parse(change),
280+ parents: parents.iter().map(|s| s.to_string()).collect(),
281+ author_name: "A".into(),
282+ author_email: "a@b.c".into(),
283+ authored_at: chrono::Utc::now(),
284+ message: "msg".into(),
285+ conflicted: false,
286+ conflict_sides: vec![],
287+ conflict_bases: vec![],
288+ }
289+ }
290+
291+ /// 32 chars in the reverse-hex alphabet.
292+ fn cid(seed: char) -> String {
293+ std::iter::repeat(seed).take(32).collect()
294+ }
295+
296+ // ─── stacks ──────────────────────────────────────────────────────────────
297+
298+ #[test]
299+ fn a_linear_chain_produces_a_chain_of_edges() {
300+ let (a, b, c) = (cid('k'), cid('l'), cid('m'));
301+ let commits = vec![
302+ commit("r1", &a, &[]),
303+ commit("r2", &b, &["r1"]),
304+ commit("r3", &c, &["r2"]),
305+ ];
306+ let edges = stack_edges(&commits, &HashSet::new());
307+
308+ assert!(edges.contains(&StackEdge { parent: a.clone(), child: b.clone() }));
309+ assert!(edges.contains(&StackEdge { parent: b, child: c }));
310+ assert_eq!(edges.len(), 2);
311+ }
312+
313+ #[test]
314+ fn merged_changes_do_not_appear_in_stacks() {
315+ let (a, b) = (cid('k'), cid('l'));
316+ let commits = vec![commit("r1", &a, &[]), commit("r2", &b, &["r1"])];
317+
318+ let mut merged = HashSet::new();
319+ merged.insert(a);
320+
321+ let edges = stack_edges(&commits, &merged);
322+ assert!(
323+ edges.is_empty(),
324+ "a landed parent must not anchor a stack: {edges:?}"
325+ );
326+ }
327+
328+ #[test]
329+ fn a_stack_is_not_severed_by_a_landed_change_in_the_middle() {
330+ // a (merged) <- b (merged) <- c: c should still connect upward to
331+ // nothing, but a <- b <- c with only b merged must yield a -> c.
332+ let (a, b, c) = (cid('k'), cid('l'), cid('m'));
333+ let commits = vec![
334+ commit("r1", &a, &[]),
335+ commit("r2", &b, &["r1"]),
336+ commit("r3", &c, &["r2"]),
337+ ];
338+ let mut merged = HashSet::new();
339+ merged.insert(b.clone());
340+
341+ let edges = stack_edges(&commits, &merged);
342+ assert!(
343+ edges.contains(&StackEdge { parent: a, child: c }),
344+ "walk must pass through the landed change: {edges:?}"
345+ );
346+ }
347+
348+ #[test]
349+ fn commits_without_a_change_id_do_not_produce_edges() {
350+ let a = cid('k');
351+ let mut plain = commit("r1", &a, &[]);
352+ plain.change_id = None;
353+ let commits = vec![plain, commit("r2", &cid('l'), &["r1"])];
354+
355+ let edges = stack_edges(&commits, &HashSet::new());
356+ assert!(edges.is_empty(), "got {edges:?}");
357+ }
358+
359+ #[test]
360+ fn is_idempotent_over_repeated_runs() {
361+ // The indexer must be re-runnable (spec §4).
362+ let commits = vec![
363+ commit("r1", &cid('k'), &[]),
364+ commit("r2", &cid('l'), &["r1"]),
365+ ];
366+ let first = stack_edges(&commits, &HashSet::new());
367+ let second = stack_edges(&commits, &HashSet::new());
368+ assert_eq!(first, second);
369+ }
370+
371+ #[test]
372+ fn a_cycle_does_not_hang_the_walk() {
373+ // Impossible in Git, but the indexer must not be the thing that hangs
374+ // if the graph is ever malformed.
375+ let (a, b) = (cid('k'), cid('l'));
376+ let commits = vec![
377+ commit("r1", &a, &["r2"]),
378+ commit("r2", &b, &["r1"]),
379+ ];
380+ let _ = stack_edges(&commits, &HashSet::new());
381+ }
382+
383+ #[test]
384+ fn a_merge_commit_links_to_both_parents() {
385+ let (a, b, m) = (cid('k'), cid('l'), cid('m'));
386+ let commits = vec![
387+ commit("r1", &a, &[]),
388+ commit("r2", &b, &[]),
389+ commit("r3", &m, &["r1", "r2"]),
390+ ];
391+ let edges = stack_edges(&commits, &HashSet::new());
392+ assert!(edges.contains(&StackEdge { parent: a, child: m.clone() }));
393+ assert!(edges.contains(&StackEdge { parent: b, child: m }));
394+ }
395+
396+ // ─── state ───────────────────────────────────────────────────────────────
397+
398+ #[test]
399+ fn indexing_never_overwrites_draft() {
400+ // The decided behaviour: draft is the author's, set in the UI.
401+ assert_eq!(next_state(ChangeState::Draft, true), ChangeState::Draft);
402+ assert_eq!(next_state(ChangeState::Draft, false), ChangeState::Draft);
403+ }
404+
405+ #[test]
406+ fn indexing_never_reopens_an_abandoned_change() {
407+ assert_eq!(
408+ next_state(ChangeState::Abandoned, false),
409+ ChangeState::Abandoned
410+ );
411+ assert_eq!(
412+ next_state(ChangeState::Abandoned, true),
413+ ChangeState::Abandoned
414+ );
415+ }
416+
417+ #[test]
418+ fn a_change_on_the_target_bookmark_becomes_merged() {
419+ assert_eq!(next_state(ChangeState::Open, true), ChangeState::Merged);
420+ }
421+
422+ #[test]
423+ fn a_rewound_bookmark_reopens_a_merged_change() {
424+ assert_eq!(next_state(ChangeState::Merged, false), ChangeState::Open);
425+ }
426+
427+ // ─── anchor rebasing (spec §5) ───────────────────────────────────────────
428+
429+ fn lines(spec: &[(u32, &str, Option<u32>)]) -> Vec<NewLine> {
430+ spec.iter()
431+ .map(|(n, c, f)| NewLine {
432+ number: *n,
433+ content: (*c).to_string(),
434+ from_old: *f,
435+ })
436+ .collect()
437+ }
438+
439+ #[test]
440+ fn an_unchanged_line_stays_current() {
441+ let new = lines(&[(1, "let x = 1;", Some(1))]);
442+ assert_eq!(
443+ rebase_anchor(1, Some("let x = 1;"), &new, true),
444+ AnchorOutcome::Unchanged
445+ );
446+ }
447+
448+ #[test]
449+ fn a_line_pushed_down_by_an_insertion_moves() {
450+ // Someone added a line above; the comment follows its line.
451+ let new = lines(&[
452+ (1, "// new comment", None),
453+ (2, "let x = 1;", Some(1)),
454+ ]);
455+ assert_eq!(
456+ rebase_anchor(1, Some("let x = 1;"), &new, true),
457+ AnchorOutcome::Moved { new_line: 2 }
458+ );
459+ }
460+
461+ #[test]
462+ fn an_edited_line_becomes_outdated() {
463+ let new = lines(&[(1, "let x = 2;", Some(1))]);
464+ assert_eq!(
465+ rebase_anchor(1, Some("let x = 1;"), &new, true),
466+ AnchorOutcome::Outdated
467+ );
468+ }
469+
470+ #[test]
471+ fn a_deleted_file_orphans_the_comment() {
472+ assert_eq!(
473+ rebase_anchor(1, Some("anything"), &[], false),
474+ AnchorOutcome::Orphaned
475+ );
476+ }
477+
478+ #[test]
479+ fn a_deleted_line_becomes_outdated_not_orphaned() {
480+ // The file still exists, so the comment belongs in the diff view marked
481+ // outdated rather than being exiled to the timeline.
482+ let new = lines(&[(1, "something else entirely", None)]);
483+ assert_eq!(
484+ rebase_anchor(5, Some("let x = 1;"), &new, true),
485+ AnchorOutcome::Outdated
486+ );
487+ }
488+
489+ #[test]
490+ fn a_moved_line_is_found_by_its_distinctive_content() {
491+ // A rebase reordered the file; the mapping is gone but the text is
492+ // unique, so the comment follows it.
493+ let new = lines(&[
494+ (1, "unrelated", None),
495+ (2, "fn interesting_function() {", None),
496+ ]);
497+ assert_eq!(
498+ rebase_anchor(50, Some("fn interesting_function() {"), &new, true),
499+ AnchorOutcome::Moved { new_line: 2 }
500+ );
501+ }
502+
503+ #[test]
504+ fn ambiguous_content_does_not_move_the_anchor() {
505+ // Two identical lines: guessing would put the comment in the wrong
506+ // place, which is worse than marking it outdated.
507+ let new = lines(&[
508+ (1, " return None;", None),
509+ (2, " return None;", None),
510+ ]);
511+ assert_eq!(
512+ rebase_anchor(9, Some(" return None;"), &new, true),
513+ AnchorOutcome::Outdated
514+ );
515+ }
516+
517+ #[test]
518+ fn structural_lines_are_not_used_to_re_anchor() {
519+ // Matching on `}` or a blank line would anchor almost anywhere.
520+ let new = lines(&[(1, "}", None)]);
521+ assert_eq!(
522+ rebase_anchor(9, Some("}"), &new, true),
523+ AnchorOutcome::Outdated
524+ );
525+ assert_eq!(
526+ rebase_anchor(9, Some(" "), &new, true),
527+ AnchorOutcome::Outdated
528+ );
529+ }
530+
531+ #[test]
532+ fn whitespace_only_reindentation_marks_outdated_not_moved() {
533+ // The line is the same code, but its text changed. Marking it outdated
534+ // keeps the original text visible to the reviewer (spec §5 step 4).
535+ let new = lines(&[(1, " let x = 1;", Some(1))]);
536+ assert_eq!(
537+ rebase_anchor(1, Some("let x = 1;"), &new, true),
538+ AnchorOutcome::Outdated
539+ );
540+ }
541+
542+ #[test]
543+ fn a_comment_with_no_stored_context_follows_the_line_mapping() {
544+ // Older comments predate anchor_context; they still rebase by position.
545+ let new = lines(&[(7, "whatever", Some(3))]);
546+ assert_eq!(
547+ rebase_anchor(3, None, &new, true),
548+ AnchorOutcome::Moved { new_line: 7 }
549+ );
550+ }
551+
552+ #[test]
553+ fn survives_five_successive_rewrites() {
554+ // Spec §5: "a change that is rewritten five times".
555+ let mut line = 10u32;
556+ let context = "fn the_function_under_review() {";
557+
558+ for shift in 1..=5u32 {
559+ let new = lines(&[(line + shift, context, Some(line))]);
560+ match rebase_anchor(line, Some(context), &new, true) {
561+ AnchorOutcome::Moved { new_line } => line = new_line,
562+ other => panic!("rewrite {shift} lost the anchor: {other:?}"),
563+ }
564+ }
565+ assert_eq!(line, 25, "anchor should have tracked every shift");
566+ }
567+
568+ // ─── line mapping ────────────────────────────────────────────────────────
569+
570+ #[test]
571+ fn line_map_carries_unchanged_lines_through() {
572+ let m = line_map("a\nb\nc\n", "a\nb\nc\n");
573+ assert_eq!(m.len(), 3);
574+ for (i, l) in m.iter().enumerate() {
575+ assert_eq!(l.number, i as u32 + 1);
576+ assert_eq!(l.from_old, Some(i as u32 + 1), "identical files map 1:1");
577+ }
578+ }
579+
580+ #[test]
581+ fn an_inserted_line_shifts_the_ones_below_it() {
582+ let m = line_map("a\nb\n", "new\na\nb\n");
583+ assert_eq!(m[0].from_old, None, "the inserted line came from nowhere");
584+ assert_eq!(m[1].from_old, Some(1));
585+ assert_eq!(m[1].number, 2, "`a` moved from line 1 to line 2");
586+ }
587+
588+ #[test]
589+ fn a_deleted_line_is_absent_from_the_mapping() {
590+ let m = line_map("a\nb\nc\n", "a\nc\n");
591+ assert!(
592+ !m.iter().any(|l| l.from_old == Some(2)),
593+ "the deleted line must not appear: {m:?}"
594+ );
595+ assert_eq!(m.iter().find(|l| l.content == "c").unwrap().from_old, Some(3));
596+ }
597+
598+ /// The end-to-end property this exists for: a comment on a line survives an
599+ /// insertion above it.
600+ #[test]
601+ fn a_comment_follows_its_line_through_a_real_rewrite() {
602+ let old = "fn a() {}\nfn the_reviewed_function() {}\nfn c() {}\n";
603+ let new = "use std::io;\n\nfn a() {}\nfn the_reviewed_function() {}\nfn c() {}\n";
604+ let m = line_map(old, new);
605+ assert_eq!(
606+ rebase_anchor(2, Some("fn the_reviewed_function() {}"), &m, true),
607+ AnchorOutcome::Moved { new_line: 4 }
608+ );
609+ }
610+
611+ #[test]
612+ fn a_file_emptied_by_a_rewrite_outdates_rather_than_panicking() {
613+ let m = line_map("a\nb\n", "");
614+ assert_eq!(rebase_anchor(1, Some("a"), &m, true), AnchorOutcome::Outdated);
615+ }
616+}
Acrates/df-index/src/lib.rs+48−0
@@ −0,0 +1,48 @@
1+//! `df-index` — reconstruction of jj semantics from pushed Git objects.
2+//!
3+//! Dogfood never runs the `jj` binary. A `jj git push` is an ordinary Git push;
4+//! this crate reads jj's metadata back out of the pushed commit objects and
5+//! turns it into the change index the product is built on.
6+//!
7+//! The crate is deliberately free of any Git library dependency. Everything
8+//! here operates on raw bytes and plain data, which keeps `gix` confined to
9+//! `df-store` (spec §3 rule 1) and makes the format-sensitive logic testable
10+//! against byte literals captured from real repositories.
11+
12+pub mod change_id;
13+pub mod indexer;
14+pub mod synthetic;
15+
16+pub use change_id::{
17+ extract_change_id, extract_conflict_trees, is_conflict_artifact, ChangeId, ConflictTrees,
18+};
19+pub use indexer::{line_map, rebase_anchor, stack_edges, AnchorOutcome, IndexedCommit, NewLine, StackEdge};
20+pub use synthetic::{normalise_diff, synthetic_change_id, PatchIdentity, SyntheticId};
21+
22+/// The identity assigned to an indexed commit.
23+///
24+/// Every commit gets one. Which variant it is determines whether the UI shows a
25+/// change chip and how much revision history Dogfood can promise (spec §4).
26+#[derive(Debug, Clone, PartialEq, Eq)]
27+pub enum Identity {
28+ /// A real jj change id, read from the commit's `change-id` header.
29+ Change(ChangeId),
30+ /// A patch-derived identity for a commit authored by plain git.
31+ Synthetic(SyntheticId),
32+}
33+
34+impl Identity {
35+ /// The canonical string stored in `changes.change_id`.
36+ pub fn as_str(&self) -> &str {
37+ match self {
38+ Identity::Change(c) => c.as_str(),
39+ Identity::Synthetic(s) => s.as_str(),
40+ }
41+ }
42+
43+ /// Whether this identity is synthesised rather than authored by jj.
44+ /// Maps directly to `changes.synthetic`.
45+ pub fn is_synthetic(&self) -> bool {
46+ matches!(self, Identity::Synthetic(_))
47+ }
48+}
Acrates/df-index/src/synthetic.rs+220−0
@@ −0,0 +1,220 @@
1+//! Synthetic change identity for commits that carry no jj change id.
2+//!
3+//! Spec §4: commits arriving from a plain `git push` still need identity, so
4+//! that `git` users are not second-class. The identity is derived from the
5+//! commit's *patch* rather than its SHA, so that rebasing the same work
6+//! continues to map to the same change — which is the whole point, since a
7+//! rebase changes the commit id but not the work.
8+//!
9+//! These ids are marked `synthetic: true` in the database and rendered without
10+//! a change chip. Dogfood never presents one as a real jj change id.
11+
12+use sha2::{Digest, Sha256};
13+
14+/// Inputs to a synthetic identity.
15+///
16+/// Deliberately excludes the commit id, the tree id, the parent ids, and the
17+/// committer — all of which change under a rebase. It also excludes the commit
18+/// message, so that amending a typo in the description does not orphan the
19+/// review.
20+#[derive(Debug, Clone)]
21+pub struct PatchIdentity<'a> {
22+ /// The diff of this commit against its first parent, normalised.
23+ pub diff: &'a [u8],
24+ /// Author email — stable across rebase, unlike the committer.
25+ pub author_email: &'a str,
26+ /// Author date as a Unix timestamp. Git preserves this across a rebase;
27+ /// the committer date does not survive.
28+ pub author_date: i64,
29+}
30+
31+/// A synthetic change identity, rendered in the same 32-character reverse-hex
32+/// alphabet as a real jj change id so that column widths and URL shapes match.
33+///
34+/// It is *not* a jj change id and is never displayed as one.
35+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
36+pub struct SyntheticId(String);
37+
38+impl SyntheticId {
39+ pub fn as_str(&self) -> &str {
40+ &self.0
41+ }
42+}
43+
44+impl std::fmt::Display for SyntheticId {
45+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46+ f.write_str(&self.0)
47+ }
48+}
49+
50+/// Map a nibble to jj's reverse-hex alphabet: `0..=15` -> `z..=k`.
51+fn reverse_hex_nibble(n: u8) -> u8 {
52+ debug_assert!(n < 16);
53+ b'z' - n
54+}
55+
56+/// Compute a stable synthetic identity for a plain-git commit.
57+///
58+/// Two commits with the same patch, author, and author date produce the same
59+/// id regardless of their commit ids or position in history — so a rebase, a
60+/// cherry-pick onto an updated base, or a re-push after an unrelated upstream
61+/// change all continue to resolve to the same Dogfood change.
62+pub fn synthetic_change_id(p: &PatchIdentity<'_>) -> SyntheticId {
63+ let mut h = Sha256::new();
64+ // Domain separation: this hash must never collide with any other digest
65+ // Dogfood computes, and the version tag lets the scheme change later
66+ // without silently re-identifying every existing synthetic change.
67+ h.update(b"dogfood/synthetic-change-id/v1\0");
68+ h.update(p.author_email.as_bytes());
69+ h.update(b"\0");
70+ h.update(p.author_date.to_be_bytes());
71+ h.update(b"\0");
72+ h.update(p.diff);
73+
74+ let digest = h.finalize();
75+ // Take 16 bytes, matching a real change id's width.
76+ let mut s = String::with_capacity(32);
77+ for byte in digest.iter().take(16) {
78+ s.push(reverse_hex_nibble(byte >> 4) as char);
79+ s.push(reverse_hex_nibble(byte & 0x0f) as char);
80+ }
81+ SyntheticId(s)
82+}
83+
84+/// Normalise a diff before hashing.
85+///
86+/// Strips the parts that change under rebase without the work changing: the
87+/// `index <old>..<new>` lines carrying blob OIDs, and hunk header line numbers
88+/// (`@@ -a,b +c,d @@`), whose offsets shift when unrelated earlier hunks move.
89+/// The hunk's trailing context label is kept, since it is part of the content.
90+pub fn normalise_diff(raw: &[u8]) -> Vec<u8> {
91+ let mut out = Vec::with_capacity(raw.len());
92+ for line in raw.split(|&b| b == b'\n') {
93+ if line.starts_with(b"index ") {
94+ continue;
95+ }
96+ if line.starts_with(b"@@") {
97+ // Keep the marker and any trailing context, drop the line numbers.
98+ if let Some(end) = find(line, b"@@", 2) {
99+ out.extend_from_slice(b"@@");
100+ out.extend_from_slice(&line[end + 2..]);
101+ out.push(b'\n');
102+ }
103+ continue;
104+ }
105+ out.extend_from_slice(line);
106+ out.push(b'\n');
107+ }
108+ out
109+}
110+
111+/// Find `needle` in `haystack` starting at `from`.
112+fn find(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
113+ if from >= haystack.len() {
114+ return None;
115+ }
116+ haystack[from..]
117+ .windows(needle.len())
118+ .position(|w| w == needle)
119+ .map(|i| i + from)
120+}
121+
122+#[cfg(test)]
123+mod tests {
124+ use super::*;
125+
126+ fn id_for(diff: &str, email: &str, date: i64) -> String {
127+ synthetic_change_id(&PatchIdentity {
128+ diff: diff.as_bytes(),
129+ author_email: email,
130+ author_date: date,
131+ })
132+ .as_str()
133+ .to_owned()
134+ }
135+
136+ #[test]
137+ fn is_deterministic() {
138+ assert_eq!(id_for("a", "x@y.z", 100), id_for("a", "x@y.z", 100));
139+ }
140+
141+ #[test]
142+ fn uses_the_reverse_hex_alphabet_and_correct_width() {
143+ let id = id_for("a", "x@y.z", 100);
144+ assert_eq!(id.len(), 32);
145+ assert!(
146+ id.bytes().all(|b| (b'k'..=b'z').contains(&b)),
147+ "synthetic ids must share the change-id alphabet, got {id}"
148+ );
149+ }
150+
151+ #[test]
152+ fn differs_on_different_patches() {
153+ assert_ne!(id_for("a", "x@y.z", 100), id_for("b", "x@y.z", 100));
154+ }
155+
156+ #[test]
157+ fn differs_on_different_authors() {
158+ assert_ne!(id_for("a", "x@y.z", 100), id_for("a", "q@y.z", 100));
159+ }
160+
161+ #[test]
162+ fn differs_on_different_author_dates() {
163+ assert_ne!(id_for("a", "x@y.z", 100), id_for("a", "x@y.z", 101));
164+ }
165+
166+ #[test]
167+ fn survives_a_rebase() {
168+ // The behaviour that justifies patch-based identity: the same work
169+ // rebased onto a new base has different blob OIDs and different hunk
170+ // offsets, but must keep its identity.
171+ let before = b"\
172+diff --git a/f.txt b/f.txt
173+index 1111111..2222222 100644
174+--- a/f.txt
175++++ b/f.txt
176+@@ -10,3 +10,4 @@ fn main() {
177+ context
178+-old
179++new
180+";
181+ let after = b"\
182+diff --git a/f.txt b/f.txt
183+index 3333333..4444444 100644
184+--- a/f.txt
185++++ b/f.txt
186+@@ -42,3 +42,4 @@ fn main() {
187+ context
188+-old
189++new
190+";
191+ let a = synthetic_change_id(&PatchIdentity {
192+ diff: &normalise_diff(before),
193+ author_email: "x@y.z",
194+ author_date: 100,
195+ });
196+ let b = synthetic_change_id(&PatchIdentity {
197+ diff: &normalise_diff(after),
198+ author_email: "x@y.z",
199+ author_date: 100,
200+ });
201+ assert_eq!(a, b, "a rebase must not change the synthetic identity");
202+ }
203+
204+ #[test]
205+ fn normalisation_drops_index_and_hunk_offsets_only() {
206+ let out = normalise_diff(b"index abc..def 100644\n@@ -1,2 +3,4 @@ fn main()\n-a\n+b\n");
207+ let s = String::from_utf8(out).unwrap();
208+ assert!(!s.contains("index abc"), "index line must be dropped");
209+ assert!(s.contains("@@ fn main()"), "hunk context must be kept: {s:?}");
210+ assert!(!s.contains("-1,2"), "hunk offsets must be dropped: {s:?}");
211+ assert!(s.contains("-a\n+b"), "content must be kept");
212+ }
213+
214+ #[test]
215+ fn genuinely_different_work_still_differs_after_normalisation() {
216+ let a = normalise_diff(b"@@ -1,1 +1,1 @@\n-a\n+b\n");
217+ let b = normalise_diff(b"@@ -1,1 +1,1 @@\n-a\n+c\n");
218+ assert_ne!(a, b);
219+ }
220+}
Acrates/df-index/tests/corpus.rs+273−0
@@ −0,0 +1,273 @@
1+//! Integration tests against the real fixture corpus.
2+//!
3+//! The unit tests in `change_id.rs` run against byte literals transcribed from
4+//! real commits, which proves the parser handles those shapes but not that the
5+//! shapes are still what `jj` produces. These tests read the actual bare
6+//! repositories built by `fixtures/gen.sh`, so a jj format change fails here.
7+//!
8+//! Objects are read via `git cat-file`, not a Git library, because `df-index`
9+//! must not depend on one (spec §3 rule 1).
10+//!
11+//! Skipped with a warning if the corpus has not been generated, so a plain
12+//! `cargo test` on a fresh checkout does not fail confusingly. CI runs
13+//! `fixtures/gen.sh` first, and `corpus_is_present` fails when `DF_REQUIRE_FIXTURES`
14+//! is set so a CI misconfiguration cannot silently skip all of this.
15+
16+use std::collections::HashSet;
17+use std::path::{Path, PathBuf};
18+use std::process::Command;
19+
20+use df_index::{extract_change_id, extract_conflict_trees};
21+
22+fn corpus_root() -> PathBuf {
23+ Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/repos")
24+}
25+
26+fn repo(name: &str) -> Option<PathBuf> {
27+ let p = corpus_root().join(format!("{name}.git"));
28+ p.is_dir().then_some(p)
29+}
30+
31+/// Run a git command against a bare repo, returning stdout as raw bytes.
32+fn git(repo: &Path, args: &[&str]) -> Vec<u8> {
33+ let out = Command::new("git")
34+ .arg("--git-dir")
35+ .arg(repo)
36+ .args(args)
37+ .output()
38+ .expect("git must be on PATH to run corpus tests");
39+ assert!(
40+ out.status.success(),
41+ "git {args:?} failed in {}: {}",
42+ repo.display(),
43+ String::from_utf8_lossy(&out.stderr)
44+ );
45+ out.stdout
46+}
47+
48+/// Every commit object in the repo, as raw bytes.
49+fn all_commits(repo: &Path) -> Vec<(String, Vec<u8>)> {
50+ let listing = git(repo, &["cat-file", "--batch-all-objects", "--batch-check=%(objectname) %(objecttype)"]);
51+ String::from_utf8_lossy(&listing)
52+ .lines()
53+ .filter_map(|l| {
54+ let (oid, kind) = l.split_once(' ')?;
55+ (kind == "commit").then(|| oid.to_owned())
56+ })
57+ .map(|oid| {
58+ let raw = git(repo, &["cat-file", "commit", &oid]);
59+ (oid, raw)
60+ })
61+ .collect()
62+}
63+
64+#[test]
65+fn corpus_is_present() {
66+ if repo("basic").is_none() {
67+ let required = std::env::var_os("DF_REQUIRE_FIXTURES").is_some();
68+ assert!(
69+ !required,
70+ "DF_REQUIRE_FIXTURES is set but the corpus is missing — run fixtures/gen.sh"
71+ );
72+ eprintln!("warning: fixture corpus absent, corpus tests skipped; run fixtures/gen.sh");
73+ }
74+}
75+
76+#[test]
77+fn every_jj_commit_yields_a_change_id() {
78+ let Some(r) = repo("basic") else { return };
79+ let commits = all_commits(&r);
80+ assert!(!commits.is_empty(), "basic.git must contain commits");
81+ for (oid, raw) in commits {
82+ assert!(
83+ extract_change_id(&raw).is_some(),
84+ "jj-authored commit {oid} in basic.git yielded no change id"
85+ );
86+ }
87+}
88+
89+#[test]
90+fn no_plain_git_commit_yields_a_change_id() {
91+ let Some(r) = repo("plain-git") else { return };
92+ let commits = all_commits(&r);
93+ assert!(!commits.is_empty(), "plain-git.git must contain commits");
94+ for (oid, raw) in commits {
95+ assert_eq!(
96+ extract_change_id(&raw),
97+ None,
98+ "plain-git commit {oid} must not yield a change id"
99+ );
100+ }
101+}
102+
103+/// The property the entire product rests on (spec §4): rewriting a change
104+/// produces a new commit but preserves the change id.
105+#[test]
106+fn change_id_is_stable_across_five_rewrites() {
107+ let Some(r) = repo("rewritten") else { return };
108+ let commits = all_commits(&r);
109+
110+ assert_eq!(
111+ commits.len(),
112+ 5,
113+ "rewritten.git should retain all five revisions as objects"
114+ );
115+
116+ let ids: HashSet<String> = commits
117+ .iter()
118+ .map(|(oid, raw)| {
119+ extract_change_id(raw)
120+ .unwrap_or_else(|| panic!("commit {oid} has no change id"))
121+ .as_str()
122+ .to_owned()
123+ })
124+ .collect();
125+
126+ assert_eq!(
127+ ids.len(),
128+ 1,
129+ "all five revisions must share one change id, got {ids:?}"
130+ );
131+
132+ let oids: HashSet<&String> = commits.iter().map(|(o, _)| o).collect();
133+ assert_eq!(oids.len(), 5, "the five revisions must be distinct commits");
134+}
135+
136+#[test]
137+fn merge_commits_carry_a_change_id() {
138+ let Some(r) = repo("merge") else { return };
139+ let merges: Vec<_> = all_commits(&r)
140+ .into_iter()
141+ .filter(|(_, raw)| {
142+ // Count `parent ` headers in the header block.
143+ raw.split(|&b| b == b'\n')
144+ .take_while(|l| !l.is_empty())
145+ .filter(|l| l.starts_with(b"parent "))
146+ .count()
147+ > 1
148+ })
149+ .collect();
150+
151+ assert!(!merges.is_empty(), "merge.git must contain a merge commit");
152+ for (oid, raw) in merges {
153+ assert!(
154+ extract_change_id(&raw).is_some(),
155+ "merge commit {oid} yielded no change id"
156+ );
157+ }
158+}
159+
160+/// The parser hazard: `change-id` sits next to a multi-line `gpgsig` whose
161+/// base64 body could otherwise be misread as headers.
162+#[test]
163+fn signed_commits_yield_a_change_id() {
164+ let Some(r) = repo("signed") else { return };
165+ let signed: Vec<_> = all_commits(&r)
166+ .into_iter()
167+ .filter(|(_, raw)| {
168+ raw.split(|&b| b == b'\n')
169+ .take_while(|l| !l.is_empty())
170+ .any(|l| l.starts_with(b"gpgsig "))
171+ })
172+ .collect();
173+
174+ assert!(!signed.is_empty(), "signed.git must contain a signed commit");
175+ for (oid, raw) in signed {
176+ assert!(
177+ extract_change_id(&raw).is_some(),
178+ "signed commit {oid} yielded no change id"
179+ );
180+ }
181+}
182+
183+/// Conflicted commits put `change-id` *after* the multi-line
184+/// `jj:conflict-labels` header — the layout a naive parser gets wrong.
185+#[test]
186+fn conflicted_commits_yield_both_change_id_and_conflict_trees() {
187+ let Some(r) = repo("conflict") else { return };
188+ let conflicted: Vec<_> = all_commits(&r)
189+ .into_iter()
190+ .filter(|(_, raw)| extract_conflict_trees(raw).is_some())
191+ .collect();
192+
193+ assert!(
194+ !conflicted.is_empty(),
195+ "conflict.git must contain a conflicted commit"
196+ );
197+
198+ for (oid, raw) in conflicted {
199+ assert!(
200+ extract_change_id(&raw).is_some(),
201+ "conflicted commit {oid} yielded no change id despite preceding jj: headers"
202+ );
203+ let c = extract_conflict_trees(&raw).unwrap();
204+ assert_eq!(
205+ c.sides.len(),
206+ c.bases.len() + 1,
207+ "conflict {oid} violates the sides == bases + 1 invariant"
208+ );
209+ // Every referenced tree must actually exist in the repo.
210+ for t in c.sides.iter().chain(c.bases.iter()) {
211+ let kind = git(&r, &["cat-file", "-t", t]);
212+ assert_eq!(
213+ String::from_utf8_lossy(&kind).trim(),
214+ "tree",
215+ "conflict side/base {t} is not a tree"
216+ );
217+ }
218+ }
219+}
220+
221+/// Unconflicted commits must not be misdetected as conflicted — otherwise every
222+/// change in the product renders a conflict banner.
223+#[test]
224+fn unconflicted_repos_report_no_conflicts() {
225+ for name in ["basic", "stack", "merge", "signed", "plain-git"] {
226+ let Some(r) = repo(name) else { continue };
227+ for (oid, raw) in all_commits(&r) {
228+ assert_eq!(
229+ extract_conflict_trees(&raw),
230+ None,
231+ "commit {oid} in {name}.git falsely reports a conflict"
232+ );
233+ }
234+ }
235+}
236+
237+/// A repo migrated to jj: the indexer must produce real ids for the jj commits
238+/// and fall back to synthetic identity for the plain-git ones, in one history.
239+#[test]
240+fn mixed_history_splits_into_real_and_synthetic() {
241+ let Some(r) = repo("mixed") else { return };
242+ let commits = all_commits(&r);
243+ let with = commits.iter().filter(|(_, c)| extract_change_id(c).is_some()).count();
244+ let without = commits.len() - with;
245+
246+ assert!(with > 0, "mixed.git must contain at least one jj commit");
247+ assert!(
248+ without > 0,
249+ "mixed.git must contain at least one plain-git commit"
250+ );
251+}
252+
253+/// Change ids read from the corpus must all be well-formed under our own
254+/// validation — this is what catches an alphabet or width change in jj.
255+#[test]
256+fn all_corpus_change_ids_are_well_formed() {
257+ let mut seen = 0usize;
258+ for name in ["basic", "rewritten", "stack", "merge", "conflict", "signed", "mixed"] {
259+ let Some(r) = repo(name) else { continue };
260+ for (_, raw) in all_commits(&r) {
261+ if let Some(id) = extract_change_id(&raw) {
262+ let s = id.as_str();
263+ assert_eq!(s.len(), 32, "change id {s} has unexpected width");
264+ assert!(
265+ s.bytes().all(|b| (b'k'..=b'z').contains(&b)),
266+ "change id {s} leaves the reverse-hex alphabet"
267+ );
268+ seen += 1;
269+ }
270+ }
271+ }
272+ assert!(seen > 0, "corpus yielded no change ids at all");
273+}
Acrates/df-render/Cargo.toml+43−0
@@ −0,0 +1,43 @@
1+[package]
2+name = "df-render"
3+version.workspace = true
4+edition.workspace = true
5+rust-version.workspace = true
6+license.workspace = true
7+
8+[dependencies]
9+ammonia.workspace = true
10+comrak.workspace = true
11+
12+# Syntax highlighting (spec §8). The grammar set is curated deliberately: each
13+# one is a C library compiled into the binary, so adding a language is a real
14+# cost and the list is the "roughly the top 25" the spec asks for.
15+tree-sitter.workspace = true
16+tree-sitter-highlight.workspace = true
17+
18+tree-sitter-bash = "0.23"
19+tree-sitter-c = "0.23"
20+tree-sitter-c-sharp = "0.23"
21+tree-sitter-cpp = "0.23"
22+tree-sitter-css = "0.23"
23+tree-sitter-elixir = "0.3"
24+tree-sitter-go = "0.23"
25+tree-sitter-haskell = "0.23"
26+tree-sitter-html = "0.23"
27+tree-sitter-java = "0.23"
28+tree-sitter-javascript = "0.23"
29+tree-sitter-json = "0.24"
30+tree-sitter-lua = "0.2"
31+tree-sitter-md = "0.3"
32+tree-sitter-nix = "0.3"
33+tree-sitter-php = "0.23"
34+tree-sitter-python = "0.23"
35+tree-sitter-ruby = "0.23"
36+tree-sitter-rust = "0.24"
37+tree-sitter-scala = "0.23"
38+tree-sitter-sequel = "0.3"
39+tree-sitter-swift = "0.7"
40+tree-sitter-toml-ng = "0.7"
41+tree-sitter-typescript = "0.23"
42+tree-sitter-yaml = "0.7"
43+tree-sitter-zig = "1"
Acrates/df-render/src/highlight.rs480 lines+480−0
@@ −0,0 +1,480 @@
1+//! Syntax highlighting (spec §8).
2+//!
3+//! > **Syntax highlighting** with `tree-sitter` and a curated grammar set
4+//! > (roughly the top 25 languages), falling back to plain text. […] Skip files
5+//! > over 1 MB or with lines over 5000 characters, and render those as plain
6+//! > text with a notice.
7+//!
8+//! The output is a `Vec<String>` of **per-line HTML fragments**, not one blob of
9+//! markup. The blob view needs a line-number gutter and anchorable line ids, and
10+//! producing lines here is what lets the diff view highlight a single side of a
11+//! hunk without re-parsing.
12+//!
13+//! Two safety properties this module is responsible for:
14+//!
15+//! * **Escaping.** The source is repository content, which is attacker
16+//! controlled. `tree-sitter-highlight`'s `HtmlRenderer` escapes `<`, `&`, `'`
17+//! and `"` in the text it emits, and every attribute this module adds is a
18+//! fixed string chosen from the highlight-name table — no user data ever
19+//! reaches an attribute value. `escaping_is_not_optional` pins that.
20+//! * **Termination.** A pathological file must degrade, not hang the process.
21+//! The size and line-length caps are checked *before* the parser runs, and a
22+//! highlight error returns `None` rather than propagating.
23+
24+use std::collections::HashMap;
25+use std::sync::LazyLock;
26+
27+use tree_sitter_highlight::{HighlightConfiguration, Highlighter, HtmlRenderer};
28+
29+/// Files above this are served as plain text (spec §8).
30+pub const MAX_BYTES: usize = 1024 * 1024;
31+
32+/// A single line this long means the file is generated or minified. Parsing it
33+/// is slow and the result is unreadable either way (spec §8).
34+pub const MAX_LINE_CHARS: usize = 5_000;
35+
36+/// Highlight capture names, in the order the CSS classes are derived from.
37+///
38+/// The index into this array is what `tree-sitter-highlight` hands back, so the
39+/// order is load-bearing: it must match [`CLASSES`] exactly.
40+const HIGHLIGHT_NAMES: &[&str] = &[
41+ "attribute",
42+ "boolean",
43+ "character",
44+ "comment",
45+ "comment.documentation",
46+ "constant",
47+ "constant.builtin",
48+ "constructor",
49+ "embedded",
50+ "escape",
51+ "function",
52+ "function.builtin",
53+ "function.method",
54+ "keyword",
55+ "label",
56+ "module",
57+ "number",
58+ "operator",
59+ "property",
60+ "punctuation",
61+ "punctuation.bracket",
62+ "punctuation.delimiter",
63+ "punctuation.special",
64+ "string",
65+ "string.escape",
66+ "string.special",
67+ "tag",
68+ "type",
69+ "type.builtin",
70+ "variable",
71+ "variable.builtin",
72+ "variable.parameter",
73+];
74+
75+/// The `class="…"` attribute emitted for each highlight index.
76+///
77+/// Precomputed as complete attribute bytes so the render callback is a slice
78+/// copy rather than a format call per token — highlighting a large file emits a
79+/// great many of these.
80+static CLASSES: LazyLock<Vec<Vec<u8>>> = LazyLock::new(|| {
81+ HIGHLIGHT_NAMES
82+ .iter()
83+ .map(|name| {
84+ // `hl-function-builtin` also carries `hl-function`, so the stylesheet
85+ // can theme a whole family and refine one member of it.
86+ let mut classes = String::new();
87+ let mut acc = String::new();
88+ for part in name.split('.') {
89+ if !acc.is_empty() {
90+ acc.push('-');
91+ }
92+ acc.push_str(part);
93+ if !classes.is_empty() {
94+ classes.push(' ');
95+ }
96+ classes.push_str("hl-");
97+ classes.push_str(&acc);
98+ }
99+ format!(r#"class="{classes}""#).into_bytes()
100+ })
101+ .collect()
102+});
103+
104+/// A highlighted file.
105+#[derive(Debug)]
106+pub struct Highlighted {
107+ /// The grammar that was used, for the "detected as Rust" affordance.
108+ pub language: &'static str,
109+ /// One HTML fragment per line, already escaped. No trailing newline.
110+ pub lines: Vec<String>,
111+}
112+
113+/// Why a file was not highlighted.
114+///
115+/// Distinguished from "no grammar" so the view can say *why* — a 4 MB file
116+/// rendering as plain text is a decision the reader should be told about, and a
117+/// `.md` file rendering plain because it is minified is worth explaining.
118+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119+pub enum Skipped {
120+ /// Over [`MAX_BYTES`].
121+ TooLarge,
122+ /// Contains a line over [`MAX_LINE_CHARS`].
123+ LineTooLong,
124+ /// No grammar for this file type.
125+ NoGrammar,
126+ /// The grammar failed on this input. Rare, and never fatal.
127+ Failed,
128+}
129+
130+/// Highlight `source`, choosing a grammar from `path`.
131+///
132+/// Returns `Err(Skipped)` whenever the file should be shown as plain text —
133+/// there is deliberately no partial result, because half a highlighted file is
134+/// worse than none.
135+pub fn highlight(path: &str, source: &str) -> Result<Highlighted, Skipped> {
136+ if source.len() > MAX_BYTES {
137+ return Err(Skipped::TooLarge);
138+ }
139+ if source.lines().any(|l| l.chars().count() > MAX_LINE_CHARS) {
140+ return Err(Skipped::LineTooLong);
141+ }
142+
143+ let lang = language_for_path(path).ok_or(Skipped::NoGrammar)?;
144+ let config = GRAMMARS.get(lang.grammar).ok_or(Skipped::NoGrammar)?;
145+
146+ let mut highlighter = Highlighter::new();
147+ // The injection callback resolves embedded languages (JS inside HTML, and so
148+ // on). Returning `None` means "render the embedded region with the outer
149+ // grammar", which is correct-but-plain rather than wrong.
150+ let events = highlighter
151+ .highlight(config, source.as_bytes(), None, |name| GRAMMARS.get(name))
152+ .map_err(|_| Skipped::Failed)?;
153+
154+ let mut renderer = HtmlRenderer::new();
155+ renderer
156+ .render(events, source.as_bytes(), &|h, out: &mut Vec<u8>| {
157+ if let Some(class) = CLASSES.get(h.0) {
158+ out.extend_from_slice(class);
159+ }
160+ })
161+ .map_err(|_| Skipped::Failed)?;
162+
163+ // `HtmlRenderer::lines` yields each line with a trailing newline and with
164+ // every span opened on that line also closed on it, so a line is a valid
165+ // standalone fragment.
166+ let lines = renderer
167+ .lines()
168+ .map(|l| l.trim_end_matches('\n').to_owned())
169+ .collect();
170+
171+ Ok(Highlighted { language: lang.display, lines })
172+}
173+
174+/// A grammar we can highlight with.
175+#[derive(Debug, Clone, Copy)]
176+pub struct Language {
177+ /// Key into [`GRAMMARS`]. Also the name injections are looked up by, so it
178+ /// matches tree-sitter's own language names.
179+ pub grammar: &'static str,
180+ /// Human-readable, for the UI.
181+ pub display: &'static str,
182+}
183+
184+/// Pick a grammar for a path.
185+///
186+/// Whole filenames are checked before extensions, so `Dockerfile` and
187+/// `.bashrc` resolve even though they have no useful extension.
188+pub fn language_for_path(path: &str) -> Option<Language> {
189+ let name = path.rsplit('/').next().unwrap_or(path);
190+ let lower = name.to_ascii_lowercase();
191+
192+ let by_name = match lower.as_str() {
193+ "makefile" | "gnumakefile" | "dockerfile" | "containerfile" | "justfile" => Some("bash"),
194+ ".bashrc" | ".bash_profile" | ".profile" | ".zshrc" | ".zprofile" => Some("bash"),
195+ "cargo.lock" | "gemfile.lock" => Some("toml"),
196+ "gemfile" | "rakefile" | "podfile" => Some("ruby"),
197+ "cmakelists.txt" => None,
198+ _ => None,
199+ };
200+ if let Some(g) = by_name {
201+ return GRAMMAR_DISPLAY
202+ .iter()
203+ .find(|(k, _)| *k == g)
204+ .map(|(grammar, display)| Language { grammar, display });
205+ }
206+
207+ // `.tar.gz` must not resolve as `gz`; only the final component matters.
208+ let ext = lower.rsplit_once('.').map(|(_, e)| e)?;
209+
210+ let grammar = match ext {
211+ "rs" => "rust",
212+ "py" | "pyi" | "pyw" => "python",
213+ "js" | "mjs" | "cjs" | "jsx" => "javascript",
214+ "ts" | "mts" | "cts" => "typescript",
215+ "tsx" => "tsx",
216+ "go" => "go",
217+ "c" | "h" => "c",
218+ "cc" | "cpp" | "cxx" | "hpp" | "hh" | "hxx" => "cpp",
219+ "java" => "java",
220+ "rb" | "rake" | "gemspec" => "ruby",
221+ "json" | "jsonc" | "webmanifest" => "json",
222+ "toml" => "toml",
223+ "yaml" | "yml" => "yaml",
224+ "html" | "htm" | "xhtml" => "html",
225+ "css" | "scss" => "css",
226+ "sh" | "bash" | "zsh" | "ksh" => "bash",
227+ "md" | "markdown" | "mdown" => "markdown",
228+ "sql" => "sql",
229+ "php" => "php",
230+ "cs" => "c_sharp",
231+ "scala" | "sbt" | "sc" => "scala",
232+ "hs" => "haskell",
233+ "swift" => "swift",
234+ "ex" | "exs" => "elixir",
235+ "lua" => "lua",
236+ "zig" => "zig",
237+ "nix" => "nix",
238+ _ => return None,
239+ };
240+
241+ GRAMMAR_DISPLAY
242+ .iter()
243+ .find(|(k, _)| *k == grammar)
244+ .map(|(grammar, display)| Language { grammar, display })
245+}
246+
247+/// Grammar key → display name. Also the authoritative list of what is loaded.
248+const GRAMMAR_DISPLAY: &[(&str, &str)] = &[
249+ ("bash", "Shell"),
250+ ("c", "C"),
251+ ("c_sharp", "C#"),
252+ ("cpp", "C++"),
253+ ("css", "CSS"),
254+ ("elixir", "Elixir"),
255+ ("go", "Go"),
256+ ("haskell", "Haskell"),
257+ ("html", "HTML"),
258+ ("java", "Java"),
259+ ("javascript", "JavaScript"),
260+ ("json", "JSON"),
261+ ("lua", "Lua"),
262+ ("markdown", "Markdown"),
263+ ("nix", "Nix"),
264+ ("php", "PHP"),
265+ ("python", "Python"),
266+ ("ruby", "Ruby"),
267+ ("rust", "Rust"),
268+ ("scala", "Scala"),
269+ ("sql", "SQL"),
270+ ("swift", "Swift"),
271+ ("toml", "TOML"),
272+ ("tsx", "TSX"),
273+ ("typescript", "TypeScript"),
274+ ("yaml", "YAML"),
275+ ("zig", "Zig"),
276+];
277+
278+/// Every grammar, parsed and configured once.
279+///
280+/// `HighlightConfiguration::configure` is what binds capture names to the
281+/// indices used by [`CLASSES`], and it mutates the configuration — so it happens
282+/// here, once, rather than per request. Building these costs a few milliseconds
283+/// each and the map is shared across every request thereafter.
284+static GRAMMARS: LazyLock<HashMap<&'static str, HighlightConfiguration>> = LazyLock::new(|| {
285+ let mut m = HashMap::new();
286+
287+ /// Build one configuration, skipping the grammar entirely if its queries do
288+ /// not compile. A broken grammar must degrade to plain text, never panic at
289+ /// the first request that touches it.
290+ fn add(
291+ m: &mut HashMap<&'static str, HighlightConfiguration>,
292+ name: &'static str,
293+ language: tree_sitter::Language,
294+ highlights: &str,
295+ injections: &str,
296+ locals: &str,
297+ ) {
298+ match HighlightConfiguration::new(language, name, highlights, injections, locals) {
299+ Ok(mut c) => {
300+ c.configure(HIGHLIGHT_NAMES);
301+ m.insert(name, c);
302+ }
303+ Err(e) => tracing_warn(name, &e.to_string()),
304+ }
305+ }
306+
307+ add(&mut m, "rust", tree_sitter_rust::LANGUAGE.into(),
308+ tree_sitter_rust::HIGHLIGHTS_QUERY, tree_sitter_rust::INJECTIONS_QUERY, "");
309+ add(&mut m, "python", tree_sitter_python::LANGUAGE.into(),
310+ tree_sitter_python::HIGHLIGHTS_QUERY, "", "");
311+ add(&mut m, "javascript", tree_sitter_javascript::LANGUAGE.into(),
312+ tree_sitter_javascript::HIGHLIGHT_QUERY,
313+ tree_sitter_javascript::INJECTIONS_QUERY,
314+ tree_sitter_javascript::LOCALS_QUERY);
315+ add(&mut m, "typescript", tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
316+ // The TypeScript grammar's queries extend JavaScript's rather than
317+ // repeating them, so both must be supplied or half the file is plain.
318+ &format!("{}\n{}", tree_sitter_javascript::HIGHLIGHT_QUERY, tree_sitter_typescript::HIGHLIGHTS_QUERY),
319+ tree_sitter_javascript::INJECTIONS_QUERY,
320+ tree_sitter_typescript::LOCALS_QUERY);
321+ add(&mut m, "tsx", tree_sitter_typescript::LANGUAGE_TSX.into(),
322+ &format!("{}\n{}", tree_sitter_javascript::HIGHLIGHT_QUERY, tree_sitter_typescript::HIGHLIGHTS_QUERY),
323+ tree_sitter_javascript::INJECTIONS_QUERY,
324+ tree_sitter_typescript::LOCALS_QUERY);
325+ add(&mut m, "go", tree_sitter_go::LANGUAGE.into(),
326+ tree_sitter_go::HIGHLIGHTS_QUERY, "", "");
327+ add(&mut m, "c", tree_sitter_c::LANGUAGE.into(),
328+ tree_sitter_c::HIGHLIGHT_QUERY, "", "");
329+ add(&mut m, "cpp", tree_sitter_cpp::LANGUAGE.into(),
330+ &format!("{}\n{}", tree_sitter_c::HIGHLIGHT_QUERY, tree_sitter_cpp::HIGHLIGHT_QUERY), "", "");
331+ add(&mut m, "java", tree_sitter_java::LANGUAGE.into(),
332+ tree_sitter_java::HIGHLIGHTS_QUERY, "", "");
333+ add(&mut m, "ruby", tree_sitter_ruby::LANGUAGE.into(),
334+ tree_sitter_ruby::HIGHLIGHTS_QUERY, "", tree_sitter_ruby::LOCALS_QUERY);
335+ add(&mut m, "json", tree_sitter_json::LANGUAGE.into(),
336+ tree_sitter_json::HIGHLIGHTS_QUERY, "", "");
337+ add(&mut m, "toml", tree_sitter_toml_ng::LANGUAGE.into(),
338+ tree_sitter_toml_ng::HIGHLIGHTS_QUERY, "", "");
339+ add(&mut m, "yaml", tree_sitter_yaml::LANGUAGE.into(),
340+ tree_sitter_yaml::HIGHLIGHTS_QUERY, "", "");
341+ add(&mut m, "html", tree_sitter_html::LANGUAGE.into(),
342+ tree_sitter_html::HIGHLIGHTS_QUERY, tree_sitter_html::INJECTIONS_QUERY, "");
343+ add(&mut m, "css", tree_sitter_css::LANGUAGE.into(),
344+ tree_sitter_css::HIGHLIGHTS_QUERY, "", "");
345+ add(&mut m, "bash", tree_sitter_bash::LANGUAGE.into(),
346+ tree_sitter_bash::HIGHLIGHT_QUERY, "", "");
347+ add(&mut m, "markdown", tree_sitter_md::LANGUAGE.into(),
348+ tree_sitter_md::HIGHLIGHT_QUERY_BLOCK, tree_sitter_md::INJECTION_QUERY_BLOCK, "");
349+ add(&mut m, "sql", tree_sitter_sequel::LANGUAGE.into(),
350+ tree_sitter_sequel::HIGHLIGHTS_QUERY, "", "");
351+ add(&mut m, "php", tree_sitter_php::LANGUAGE_PHP.into(),
352+ tree_sitter_php::HIGHLIGHTS_QUERY, tree_sitter_php::INJECTIONS_QUERY, "");
353+ add(&mut m, "c_sharp", tree_sitter_c_sharp::LANGUAGE.into(),
354+ tree_sitter_c_sharp::HIGHLIGHTS_QUERY, "", "");
355+ add(&mut m, "scala", tree_sitter_scala::LANGUAGE.into(),
356+ tree_sitter_scala::HIGHLIGHTS_QUERY, "", tree_sitter_scala::LOCALS_QUERY);
357+ add(&mut m, "haskell", tree_sitter_haskell::LANGUAGE.into(),
358+ tree_sitter_haskell::HIGHLIGHTS_QUERY, tree_sitter_haskell::INJECTIONS_QUERY,
359+ tree_sitter_haskell::LOCALS_QUERY);
360+ add(&mut m, "swift", tree_sitter_swift::LANGUAGE.into(),
361+ tree_sitter_swift::HIGHLIGHTS_QUERY, tree_sitter_swift::INJECTIONS_QUERY,
362+ tree_sitter_swift::LOCALS_QUERY);
363+ add(&mut m, "elixir", tree_sitter_elixir::LANGUAGE.into(),
364+ tree_sitter_elixir::HIGHLIGHTS_QUERY, tree_sitter_elixir::INJECTIONS_QUERY, "");
365+ add(&mut m, "lua", tree_sitter_lua::LANGUAGE.into(),
366+ tree_sitter_lua::HIGHLIGHTS_QUERY, tree_sitter_lua::INJECTIONS_QUERY,
367+ tree_sitter_lua::LOCALS_QUERY);
368+ add(&mut m, "zig", tree_sitter_zig::LANGUAGE.into(),
369+ tree_sitter_zig::HIGHLIGHTS_QUERY, tree_sitter_zig::INJECTIONS_QUERY, "");
370+ add(&mut m, "nix", tree_sitter_nix::LANGUAGE.into(),
371+ tree_sitter_nix::HIGHLIGHTS_QUERY, tree_sitter_nix::INJECTIONS_QUERY, "");
372+
373+ m
374+});
375+
376+/// `df-render` deliberately has no `tracing` dependency — it is a pure
377+/// rendering crate — so a grammar that fails to load reports on stderr.
378+/// This only ever runs once per process, at first use.
379+fn tracing_warn(name: &str, message: &str) {
380+ eprintln!("df-render: grammar {name} failed to load, files will render as plain text: {message}");
381+}
382+
383+#[cfg(test)]
384+mod tests {
385+ use super::*;
386+
387+ #[test]
388+ fn highlights_rust_and_splits_by_line() {
389+ let out = highlight("src/main.rs", "fn main() {\n let x = 1;\n}\n").unwrap();
390+ assert_eq!(out.language, "Rust");
391+ assert_eq!(out.lines.len(), 3, "one fragment per source line");
392+ assert!(out.lines[0].contains("hl-keyword"), "`fn` should be a keyword: {:?}", out.lines[0]);
393+ // The Rust grammar captures integer literals as `constant.builtin`, not
394+ // `number`. Asserting the family class rather than the leaf keeps this
395+ // test about "the literal got classified" instead of about upstream's
396+ // choice of capture name.
397+ assert!(
398+ out.lines[1].contains("hl-constant"),
399+ "`1` should be classified: {:?}",
400+ out.lines[1]
401+ );
402+ }
403+
404+ /// The security-critical property: repository content is attacker
405+ /// controlled, and it reaches a page that renders on our origin.
406+ #[test]
407+ fn escaping_is_not_optional() {
408+ let evil = "let s = \"</span><script>alert(1)</script>\";\n";
409+ let out = highlight("x.rs", evil).unwrap();
410+ let joined = out.lines.join("\n");
411+ assert!(
412+ !joined.contains("<script>"),
413+ "raw markup escaped from the highlighter: {joined}"
414+ );
415+ assert!(joined.contains("&lt;script&gt;"), "expected escaped form: {joined}");
416+ }
417+
418+ /// Even with no grammar the caller must be able to tell "plain" from
419+ /// "broken", so every rejection carries a reason.
420+ #[test]
421+ fn unknown_extensions_fall_back_rather_than_erroring() {
422+ assert_eq!(highlight("a.unknownext", "hello").unwrap_err(), Skipped::NoGrammar);
423+ assert_eq!(highlight("noextension", "hello").unwrap_err(), Skipped::NoGrammar);
424+ }
425+
426+ #[test]
427+ fn the_spec_size_limits_are_enforced_before_parsing() {
428+ let big = "a\n".repeat(MAX_BYTES);
429+ assert_eq!(highlight("a.rs", &big).unwrap_err(), Skipped::TooLarge);
430+
431+ let minified = format!("let x = \"{}\";\n", "y".repeat(MAX_LINE_CHARS + 1));
432+ assert_eq!(highlight("a.js", &minified).unwrap_err(), Skipped::LineTooLong);
433+ }
434+
435+ #[test]
436+ fn class_names_carry_the_whole_family() {
437+ let idx = HIGHLIGHT_NAMES.iter().position(|n| *n == "function.builtin").unwrap();
438+ let class = String::from_utf8(CLASSES[idx].clone()).unwrap();
439+ assert_eq!(class, r#"class="hl-function hl-function-builtin""#);
440+ }
441+
442+ #[test]
443+ fn filenames_without_a_useful_extension_still_resolve() {
444+ assert_eq!(language_for_path("Dockerfile").unwrap().display, "Shell");
445+ assert_eq!(language_for_path("a/b/Makefile").unwrap().display, "Shell");
446+ assert_eq!(language_for_path("Cargo.lock").unwrap().display, "TOML");
447+ // Only the final extension counts.
448+ assert!(language_for_path("archive.tar.gz").is_none());
449+ assert_eq!(language_for_path("a.tar.rs").unwrap().display, "Rust");
450+ }
451+
452+ /// Every grammar in the display table must actually have loaded. A typo in
453+ /// a query, or an upstream grammar that stops compiling its queries against
454+ /// the pinned `tree-sitter`, silently turns a language plain — this is the
455+ /// test that notices.
456+ #[test]
457+ fn every_advertised_grammar_loads() {
458+ for (key, display) in GRAMMAR_DISPLAY {
459+ assert!(
460+ GRAMMARS.contains_key(key),
461+ "grammar {key} ({display}) is advertised but did not load"
462+ );
463+ }
464+ assert!(GRAMMARS.len() >= 25, "spec §8 asks for roughly the top 25 languages");
465+ }
466+
467+ /// A file that ends without a trailing newline must not lose its last line.
468+ #[test]
469+ fn a_missing_trailing_newline_does_not_drop_a_line() {
470+ let out = highlight("a.rs", "fn a() {}\nfn b() {}").unwrap();
471+ assert_eq!(out.lines.len(), 2);
472+ assert!(out.lines[1].contains("b"));
473+ }
474+
475+ #[test]
476+ fn an_empty_file_highlights_to_nothing() {
477+ let out = highlight("a.rs", "").unwrap();
478+ assert!(out.lines.is_empty() || out.lines == vec![""]);
479+ }
480+}
Acrates/df-render/src/lib.rs+252−0
@@ −0,0 +1,252 @@
1+//! `df-render` — markdown and content rendering.
2+//!
3+//! Spec §8: "Markdown via `comrak` with GFM extensions, rendered server-side
4+//! and sanitized with `ammonia` on a strict allowlist. No raw HTML passthrough."
5+//!
6+//! Repository content is attacker-controlled: anyone who can push can put
7+//! anything in a README. Every path out of this module is sanitised, and the
8+//! sanitiser is applied *after* rendering, so no markdown construct can smuggle
9+//! markup past it.
10+
11+use std::sync::LazyLock;
12+
13+use ammonia::Builder;
14+
15+pub mod autolink;
16+pub mod highlight;
17+pub mod symbols;
18+
19+/// The sanitiser, built once.
20+///
21+/// Deliberately strict. `ammonia`'s defaults already strip `<script>`, but the
22+/// dangerous surface is wider than that: `style` attributes enable
23+/// clickjacking overlays, and unrestricted URL schemes enable `javascript:`.
24+static CLEANER: LazyLock<Builder<'static>> = LazyLock::new(|| {
25+ let mut b = Builder::default();
26+
27+ // Only http/https/mailto. This is what stops `javascript:` and `data:`
28+ // URLs in links and images.
29+ b.url_schemes(["http", "https", "mailto"].into_iter().collect());
30+
31+ // Anything with a target must not be able to reach back via window.opener.
32+ b.link_rel(Some("noopener noreferrer nofollow"));
33+
34+ // No inline styles, no id (which can override page anchors and break
35+ // fragment navigation), no event handlers of any kind.
36+ b.generic_attributes(["title"].into_iter().collect());
37+
38+ // Task-list checkboxes. Allowing `<input>` is safe *only* because
39+ // `render.unsafe_ = false` below makes comrak escape raw HTML from the
40+ // source — so the only `<input>` that ever reaches the sanitiser is the
41+ // disabled checkbox comrak itself emits for `- [x]`. A user cannot inject
42+ // one; `raw_input_tags_are_still_escaped` pins that.
43+ b.add_tags(["input"]);
44+ b.add_tag_attributes("input", ["type", "checked", "disabled"]);
45+ b.attribute_filter(|element, attribute, value| match (element, attribute) {
46+ // Defence in depth: even from comrak, accept only checkboxes.
47+ ("input", "type") if value != "checkbox" => None,
48+ _ => Some(value.into()),
49+ });
50+
51+ b
52+});
53+
54+/// Render GitHub-flavoured markdown to sanitised HTML.
55+pub fn markdown_to_html(source: &str) -> String {
56+ let mut options = comrak::Options::default();
57+
58+ options.extension.strikethrough = true;
59+ options.extension.table = true;
60+ options.extension.autolink = true;
61+ options.extension.tasklist = true;
62+ options.extension.footnotes = true;
63+
64+ // Belt and braces with the sanitiser below: comrak is told not to emit raw
65+ // HTML at all, and ammonia then strips anything that slips through.
66+ options.render.unsafe_ = false;
67+ options.render.escape = false;
68+ options.render.hardbreaks = false;
69+
70+ let rendered = comrak::markdown_to_html(source, &options);
71+ CLEANER.clean(&rendered).to_string()
72+}
73+
74+/// Render markdown for a comment body.
75+///
76+/// Same pipeline as README rendering; kept as a separate entry point so the
77+/// two can diverge (comments will gain `#123` and `@handle` autolinking in M3)
78+/// without loosening README rendering.
79+pub fn comment_to_html(source: &str) -> String {
80+ markdown_to_html(source)
81+}
82+
83+/// Strip markdown to a plain-text excerpt, for list views and page titles.
84+pub fn excerpt(source: &str, max_chars: usize) -> String {
85+ let mut out = String::with_capacity(max_chars.min(source.len()));
86+ let mut chars = 0;
87+
88+ for line in source.lines() {
89+ let line = line.trim();
90+ // Skip headings, fences and blockquote markers; we want prose.
91+ if line.is_empty() || line.starts_with('#') || line.starts_with("```") {
92+ continue;
93+ }
94+ for c in line.chars() {
95+ if chars >= max_chars {
96+ out.push('…');
97+ return out;
98+ }
99+ // Collapse markdown emphasis characters rather than showing them.
100+ if matches!(c, '*' | '_' | '`' | '[' | ']') {
101+ continue;
102+ }
103+ out.push(c);
104+ chars += 1;
105+ }
106+ out.push(' ');
107+ chars += 1;
108+ }
109+
110+ out.trim().to_string()
111+}
112+
113+#[cfg(test)]
114+mod tests {
115+ use super::*;
116+
117+ #[test]
118+ fn renders_basic_markdown() {
119+ let h = markdown_to_html("# Title\n\nSome **bold** text.");
120+ assert!(h.contains("<h1>"));
121+ assert!(h.contains("<strong>bold</strong>"));
122+ }
123+
124+ #[test]
125+ fn renders_gfm_tables_and_tasklists() {
126+ let h = markdown_to_html("| a | b |\n|---|---|\n| 1 | 2 |");
127+ assert!(h.contains("<table>"), "GFM tables must render: {h}");
128+
129+ // The checkbox carries the done/todo distinction. Stripping it renders
130+ // both items identically, silently losing the meaning.
131+ let h = markdown_to_html("- [x] done\n- [ ] todo");
132+ assert!(h.contains("checkbox"), "tasklists must render: {h}");
133+ assert!(h.contains("checked"), "completed items must stay marked: {h}");
134+ }
135+
136+ #[test]
137+ fn raw_input_tags_are_still_escaped() {
138+ // The assumption that makes allowing <input> safe: raw HTML in the
139+ // source never reaches the sanitiser as markup.
140+ let h = markdown_to_html("<input type=\"text\" name=\"password\">");
141+ assert!(
142+ !h.contains("<input"),
143+ "a user-authored <input> must not survive: {h}"
144+ );
145+
146+ let h = markdown_to_html("<input type=\"checkbox\" onclick=\"alert(1)\">");
147+ assert!(!h.contains("onclick"), "handler survived: {h}");
148+ assert!(!h.contains("<input"), "user-authored input survived: {h}");
149+ }
150+
151+ // ─── the sanitiser (spec §9) ─────────────────────────────────────────────
152+
153+ #[test]
154+ fn strips_script_tags() {
155+ let h = markdown_to_html("<script>alert('xss')</script>");
156+ assert!(!h.contains("<script"), "script survived: {h}");
157+ assert!(!h.contains("alert"), "script body survived: {h}");
158+ }
159+
160+ #[test]
161+ fn strips_javascript_urls() {
162+ // The classic README XSS.
163+ let h = markdown_to_html("[click me](javascript:alert(1))");
164+ assert!(!h.contains("javascript:"), "javascript: URL survived: {h}");
165+ }
166+
167+ #[test]
168+ fn strips_event_handlers() {
169+ let h = markdown_to_html("<img src=x onerror=\"alert(1)\">");
170+ assert!(!h.contains("onerror"), "event handler survived: {h}");
171+ }
172+
173+ #[test]
174+ fn strips_inline_styles() {
175+ // Inline styles enable invisible full-page overlays.
176+ let h = markdown_to_html("<div style=\"position:fixed;inset:0\">x</div>");
177+ assert!(!h.contains("style="), "inline style survived: {h}");
178+ }
179+
180+ #[test]
181+ fn strips_data_urls() {
182+ let h = markdown_to_html("[x](data:text/html;base64,PHNjcmlwdD4=)");
183+ assert!(!h.contains("data:text/html"), "data: URL survived: {h}");
184+ }
185+
186+ #[test]
187+ fn strips_iframes_and_objects() {
188+ for src in [
189+ "<iframe src=\"https://evil.example\"></iframe>",
190+ "<object data=\"x\"></object>",
191+ "<embed src=\"x\">",
192+ "<form action=\"https://evil.example\"><input name=p></form>",
193+ ] {
194+ let h = markdown_to_html(src);
195+ for tag in ["<iframe", "<object", "<embed", "<form"] {
196+ assert!(!h.contains(tag), "{tag} survived from {src:?}: {h}");
197+ }
198+ }
199+ }
200+
201+ #[test]
202+ fn keeps_ordinary_links_but_adds_rel() {
203+ let h = markdown_to_html("[jj](https://github.com/jj-vcs/jj)");
204+ assert!(h.contains("https://github.com/jj-vcs/jj"));
205+ assert!(h.contains("noopener"), "rel must be set: {h}");
206+ }
207+
208+ #[test]
209+ fn html_entities_in_text_are_escaped_not_executed() {
210+ let h = markdown_to_html("5 < 6 & 7 > 2");
211+ assert!(h.contains("&lt;") || h.contains("&amp;"), "got {h}");
212+ }
213+
214+ #[test]
215+ fn a_malicious_readme_cannot_break_out_of_a_code_fence() {
216+ let h = markdown_to_html("```\n</code></pre><script>alert(1)</script>\n```");
217+ assert!(!h.contains("<script"), "escaped the fence: {h}");
218+ }
219+
220+ #[test]
221+ fn survives_pathological_input_without_panicking() {
222+ // Deeply nested emphasis and lists are a known parser stressor.
223+ let nasty = "*".repeat(5000);
224+ let _ = markdown_to_html(&nasty);
225+ let nested = "> ".repeat(1000) + "text";
226+ let _ = markdown_to_html(&nested);
227+ let _ = markdown_to_html(&"[".repeat(2000));
228+ }
229+
230+ // ─── excerpt ─────────────────────────────────────────────────────────────
231+
232+ #[test]
233+ fn excerpt_skips_headings_and_strips_emphasis() {
234+ let e = excerpt("# Heading\n\nSome **bold** prose here.", 100);
235+ assert!(!e.contains('#'));
236+ assert!(!e.contains('*'));
237+ assert!(e.contains("Some bold prose"));
238+ }
239+
240+ #[test]
241+ fn excerpt_truncates_with_an_ellipsis() {
242+ let e = excerpt("a very long line of prose that keeps going", 10);
243+ assert!(e.chars().count() <= 11, "got {e:?}");
244+ assert!(e.ends_with('…'));
245+ }
246+
247+ #[test]
248+ fn excerpt_of_empty_input_is_empty() {
249+ assert_eq!(excerpt("", 50), "");
250+ assert_eq!(excerpt("# only a heading", 50), "");
251+ }
252+}
Acrates/df-render/src/symbols.rs+348−0
@@ −0,0 +1,348 @@
1+//! Symbol extraction from source files.
2+//!
3+//! Extracts top-level symbols (functions, structs, classes, interfaces, etc.)
4+//! from source code using tree-sitter grammars. This powers the symbol outline
5+//! sidebar in the code view.
6+//!
7+//! The extraction is best-effort: languages without a grammar or files that fail
8+//! to parse simply return an empty list, never an error.
9+
10+use tree_sitter::{Parser, Tree};
11+
12+/// A symbol extracted from a source file.
13+#[derive(Debug, Clone, PartialEq, Eq)]
14+pub struct Symbol {
15+ /// The symbol name (e.g. `main`, `MyStruct`, `handle_request`).
16+ pub name: String,
17+ /// What kind of symbol this is.
18+ pub kind: SymbolKind,
19+ /// 1-based line number where the symbol starts.
20+ pub line: usize,
21+}
22+
23+/// The kind of symbol.
24+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25+pub enum SymbolKind {
26+ Function,
27+ Method,
28+ Class,
29+ Struct,
30+ Enum,
31+ Interface,
32+ Trait,
33+ Constant,
34+ Module,
35+ Type,
36+}
37+
38+impl SymbolKind {
39+ /// Short label for the UI.
40+ pub fn label(&self) -> &'static str {
41+ match self {
42+ SymbolKind::Function => "fn",
43+ SymbolKind::Method => "method",
44+ SymbolKind::Class => "class",
45+ SymbolKind::Struct => "struct",
46+ SymbolKind::Enum => "enum",
47+ SymbolKind::Interface => "iface",
48+ SymbolKind::Trait => "trait",
49+ SymbolKind::Constant => "const",
50+ SymbolKind::Module => "mod",
51+ SymbolKind::Type => "type",
52+ }
53+ }
54+
55+ /// CSS class suffix for styling.
56+ pub fn css_class(&self) -> &'static str {
57+ match self {
58+ SymbolKind::Function | SymbolKind::Method => "fn",
59+ SymbolKind::Class | SymbolKind::Struct => "type",
60+ SymbolKind::Enum => "enum",
61+ SymbolKind::Interface | SymbolKind::Trait => "trait",
62+ SymbolKind::Constant => "const",
63+ SymbolKind::Module => "mod",
64+ SymbolKind::Type => "type",
65+ }
66+ }
67+}
68+
69+/// Extract symbols from the given source code.
70+///
71+/// `path` is used to determine the language (by extension). Returns an empty
72+/// vec for unsupported languages or if parsing fails.
73+pub fn extract_symbols(path: &str, source: &str) -> Vec<Symbol> {
74+ // Don't attempt symbol extraction on very large files.
75+ if source.len() > 1024 * 1024 {
76+ return Vec::new();
77+ }
78+
79+ let lang = match language_for_symbols(path) {
80+ Some(l) => l,
81+ None => return Vec::new(),
82+ };
83+
84+ let mut parser = Parser::new();
85+ if parser.set_language(&lang.ts_language).is_err() {
86+ return Vec::new();
87+ }
88+
89+ let tree = match parser.parse(source, None) {
90+ Some(t) => t,
91+ None => return Vec::new(),
92+ };
93+
94+ extract_from_tree(&tree, source, lang.node_kinds)
95+}
96+
97+/// Walk the tree and collect symbols based on language-specific node kinds.
98+fn extract_from_tree(tree: &Tree, source: &str, node_kinds: &[NodeKindMapping]) -> Vec<Symbol> {
99+ let mut symbols = Vec::new();
100+ let root = tree.root_node();
101+
102+ let mut cursor = root.walk();
103+ // Only walk top-level and one level deep (for impl blocks, class bodies).
104+ for node in root.children(&mut cursor) {
105+ extract_node(&node, source, node_kinds, &mut symbols, 0);
106+ }
107+
108+ symbols
109+}
110+
111+/// Maximum nesting depth for symbol extraction.
112+const MAX_DEPTH: usize = 2;
113+
114+fn extract_node(
115+ node: &tree_sitter::Node,
116+ source: &str,
117+ node_kinds: &[NodeKindMapping],
118+ symbols: &mut Vec<Symbol>,
119+ depth: usize,
120+) {
121+ let kind = node.kind();
122+
123+ // Check if this node kind maps to a symbol.
124+ for mapping in node_kinds {
125+ if mapping.node_kind == kind {
126+ if let Some(name) = find_name_child(node, source, mapping.name_field) {
127+ symbols.push(Symbol {
128+ name,
129+ kind: mapping.symbol_kind,
130+ line: node.start_position().row + 1,
131+ });
132+ }
133+ break;
134+ }
135+ }
136+
137+ // Recurse into container nodes (impl blocks, class bodies) but not too deep.
138+ if depth < MAX_DEPTH && is_container_node(kind) {
139+ let mut cursor = node.walk();
140+ for child in node.children(&mut cursor) {
141+ extract_node(&child, source, node_kinds, symbols, depth + 1);
142+ }
143+ }
144+}
145+
146+/// Find the name of a node by looking for a specific child field.
147+fn find_name_child(
148+ node: &tree_sitter::Node,
149+ source: &str,
150+ name_field: &str,
151+) -> Option<String> {
152+ let child = node.child_by_field_name(name_field)?;
153+ let text = child.utf8_text(source.as_bytes()).ok()?;
154+ // Limit name length to avoid absurd entries.
155+ if text.len() > 200 {
156+ return None;
157+ }
158+ Some(text.to_string())
159+}
160+
161+/// Whether a node is a "container" that might have nested symbols.
162+fn is_container_node(kind: &str) -> bool {
163+ matches!(
164+ kind,
165+ "impl_item"
166+ | "class_declaration"
167+ | "class_definition"
168+ | "class_body"
169+ | "module"
170+ | "namespace_declaration"
171+ | "object_declaration"
172+ | "trait_definition"
173+ | "interface_declaration"
174+ | "declaration_list"
175+ | "block"
176+ | "program"
177+ )
178+}
179+
180+// ─── Language-specific configuration ─────────────────────────────────────────
181+
182+struct NodeKindMapping {
183+ node_kind: &'static str,
184+ name_field: &'static str,
185+ symbol_kind: SymbolKind,
186+}
187+
188+struct SymbolLanguage {
189+ ts_language: tree_sitter::Language,
190+ node_kinds: &'static [NodeKindMapping],
191+}
192+
193+const RUST_KINDS: &[NodeKindMapping] = &[
194+ NodeKindMapping { node_kind: "function_item", name_field: "name", symbol_kind: SymbolKind::Function },
195+ NodeKindMapping { node_kind: "struct_item", name_field: "name", symbol_kind: SymbolKind::Struct },
196+ NodeKindMapping { node_kind: "enum_item", name_field: "name", symbol_kind: SymbolKind::Enum },
197+ NodeKindMapping { node_kind: "trait_item", name_field: "name", symbol_kind: SymbolKind::Trait },
198+ NodeKindMapping { node_kind: "type_item", name_field: "name", symbol_kind: SymbolKind::Type },
199+ NodeKindMapping { node_kind: "const_item", name_field: "name", symbol_kind: SymbolKind::Constant },
200+ NodeKindMapping { node_kind: "mod_item", name_field: "name", symbol_kind: SymbolKind::Module },
201+ NodeKindMapping { node_kind: "impl_item", name_field: "type", symbol_kind: SymbolKind::Struct },
202+];
203+
204+const PYTHON_KINDS: &[NodeKindMapping] = &[
205+ NodeKindMapping { node_kind: "function_definition", name_field: "name", symbol_kind: SymbolKind::Function },
206+ NodeKindMapping { node_kind: "class_definition", name_field: "name", symbol_kind: SymbolKind::Class },
207+];
208+
209+const JS_KINDS: &[NodeKindMapping] = &[
210+ NodeKindMapping { node_kind: "function_declaration", name_field: "name", symbol_kind: SymbolKind::Function },
211+ NodeKindMapping { node_kind: "class_declaration", name_field: "name", symbol_kind: SymbolKind::Class },
212+ NodeKindMapping { node_kind: "method_definition", name_field: "name", symbol_kind: SymbolKind::Method },
213+];
214+
215+const GO_KINDS: &[NodeKindMapping] = &[
216+ NodeKindMapping { node_kind: "function_declaration", name_field: "name", symbol_kind: SymbolKind::Function },
217+ NodeKindMapping { node_kind: "method_declaration", name_field: "name", symbol_kind: SymbolKind::Method },
218+ NodeKindMapping { node_kind: "type_declaration", name_field: "name", symbol_kind: SymbolKind::Type },
219+];
220+
221+const JAVA_KINDS: &[NodeKindMapping] = &[
222+ NodeKindMapping { node_kind: "class_declaration", name_field: "name", symbol_kind: SymbolKind::Class },
223+ NodeKindMapping { node_kind: "method_declaration", name_field: "name", symbol_kind: SymbolKind::Method },
224+ NodeKindMapping { node_kind: "interface_declaration", name_field: "name", symbol_kind: SymbolKind::Interface },
225+ NodeKindMapping { node_kind: "enum_declaration", name_field: "name", symbol_kind: SymbolKind::Enum },
226+];
227+
228+const C_KINDS: &[NodeKindMapping] = &[
229+ NodeKindMapping { node_kind: "function_definition", name_field: "declarator", symbol_kind: SymbolKind::Function },
230+ NodeKindMapping { node_kind: "struct_specifier", name_field: "name", symbol_kind: SymbolKind::Struct },
231+ NodeKindMapping { node_kind: "enum_specifier", name_field: "name", symbol_kind: SymbolKind::Enum },
232+];
233+
234+const RUBY_KINDS: &[NodeKindMapping] = &[
235+ NodeKindMapping { node_kind: "method", name_field: "name", symbol_kind: SymbolKind::Method },
236+ NodeKindMapping { node_kind: "class", name_field: "name", symbol_kind: SymbolKind::Class },
237+ NodeKindMapping { node_kind: "module", name_field: "name", symbol_kind: SymbolKind::Module },
238+];
239+
240+fn language_for_symbols(path: &str) -> Option<SymbolLanguage> {
241+ let name = path.rsplit('/').next().unwrap_or(path);
242+ let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase())?;
243+
244+ match ext.as_str() {
245+ "rs" => Some(SymbolLanguage {
246+ ts_language: tree_sitter_rust::LANGUAGE.into(),
247+ node_kinds: RUST_KINDS,
248+ }),
249+ "py" | "pyi" | "pyw" => Some(SymbolLanguage {
250+ ts_language: tree_sitter_python::LANGUAGE.into(),
251+ node_kinds: PYTHON_KINDS,
252+ }),
253+ "js" | "mjs" | "cjs" | "jsx" => Some(SymbolLanguage {
254+ ts_language: tree_sitter_javascript::LANGUAGE.into(),
255+ node_kinds: JS_KINDS,
256+ }),
257+ "ts" | "mts" | "cts" => Some(SymbolLanguage {
258+ ts_language: tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
259+ node_kinds: JS_KINDS,
260+ }),
261+ "tsx" => Some(SymbolLanguage {
262+ ts_language: tree_sitter_typescript::LANGUAGE_TSX.into(),
263+ node_kinds: JS_KINDS,
264+ }),
265+ "go" => Some(SymbolLanguage {
266+ ts_language: tree_sitter_go::LANGUAGE.into(),
267+ node_kinds: GO_KINDS,
268+ }),
269+ "c" | "h" => Some(SymbolLanguage {
270+ ts_language: tree_sitter_c::LANGUAGE.into(),
271+ node_kinds: C_KINDS,
272+ }),
273+ "cc" | "cpp" | "cxx" | "hpp" | "hh" | "hxx" => Some(SymbolLanguage {
274+ ts_language: tree_sitter_cpp::LANGUAGE.into(),
275+ node_kinds: C_KINDS,
276+ }),
277+ "java" => Some(SymbolLanguage {
278+ ts_language: tree_sitter_java::LANGUAGE.into(),
279+ node_kinds: JAVA_KINDS,
280+ }),
281+ "rb" | "rake" | "gemspec" => Some(SymbolLanguage {
282+ ts_language: tree_sitter_ruby::LANGUAGE.into(),
283+ node_kinds: RUBY_KINDS,
284+ }),
285+ _ => None,
286+ }
287+}
288+
289+#[cfg(test)]
290+mod tests {
291+ use super::*;
292+
293+ #[test]
294+ fn extracts_rust_symbols() {
295+ let source = r#"
296+fn main() {
297+ println!("hello");
298+}
299+
300+struct Foo {
301+ x: i32,
302+}
303+
304+enum Bar {
305+ A,
306+ B,
307+}
308+
309+trait Baz {
310+ fn method(&self);
311+}
312+"#;
313+ let symbols = extract_symbols("src/main.rs", source);
314+ let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect();
315+ assert!(names.contains(&"main"));
316+ assert!(names.contains(&"Foo"));
317+ assert!(names.contains(&"Bar"));
318+ assert!(names.contains(&"Baz"));
319+ }
320+
321+ #[test]
322+ fn extracts_python_symbols() {
323+ let source = r#"
324+def hello():
325+ pass
326+
327+class World:
328+ def method(self):
329+ pass
330+"#;
331+ let symbols = extract_symbols("app.py", source);
332+ let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect();
333+ assert!(names.contains(&"hello"));
334+ assert!(names.contains(&"World"));
335+ }
336+
337+ #[test]
338+ fn unsupported_language_returns_empty() {
339+ let symbols = extract_symbols("data.csv", "a,b,c\n1,2,3\n");
340+ assert!(symbols.is_empty());
341+ }
342+
343+ #[test]
344+ fn empty_source_returns_empty() {
345+ let symbols = extract_symbols("main.rs", "");
346+ assert!(symbols.is_empty());
347+ }
348+}
Acrates/df-ssh/Cargo.toml+24−0
@@ −0,0 +1,24 @@
1+[package]
2+name = "df-ssh"
3+version.workspace = true
4+edition.workspace = true
5+rust-version.workspace = true
6+license.workspace = true
7+
8+[[bin]]
9+name = "dogfood-ssh"
10+path = "src/main.rs"
11+
12+[dependencies]
13+df-db = { path = "../df-db" }
14+df-auth = { path = "../df-auth" }
15+
16+anyhow.workspace = true
17+dotenvy.workspace = true
18+rand.workspace = true
19+russh.workspace = true
20+sqlx.workspace = true
21+tokio.workspace = true
22+tracing.workspace = true
23+tracing-subscriber.workspace = true
24+uuid.workspace = true
Acrates/df-ssh/src/exec.rs+322−0
@@ −0,0 +1,322 @@
1+//! Parsing the requested SSH command (spec §6, §9).
2+//!
3+//! > `dogfood-ssh` … dispatches only `git-upload-pack` and `git-receive-pack`.
4+//! > No shell, no port forwarding, no agent forwarding, no PTY, no SFTP, no exec
5+//! > of anything else. Parse the requested command strictly and reject anything
6+//! > unrecognized.
7+//!
8+//! This is the module that decides what a connected key is allowed to run, so
9+//! it is pure, strict, and heavily tested.
10+
11+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12+pub enum Service {
13+ UploadPack,
14+ ReceivePack,
15+}
16+
17+impl Service {
18+ pub fn git_subcommand(self) -> &'static str {
19+ match self {
20+ Service::UploadPack => "upload-pack",
21+ Service::ReceivePack => "receive-pack",
22+ }
23+ }
24+
25+ /// Whether running this service modifies the repository.
26+ pub fn is_write(self) -> bool {
27+ matches!(self, Service::ReceivePack)
28+ }
29+}
30+
31+#[derive(Debug, Clone, PartialEq, Eq)]
32+pub struct Request {
33+ pub service: Service,
34+ pub owner: String,
35+ pub repo: String,
36+}
37+
38+#[derive(Debug, Clone, PartialEq, Eq)]
39+pub enum ExecError {
40+ /// Not one of the two permitted commands.
41+ UnsupportedCommand,
42+ /// The path was not `owner/repo`.
43+ BadPath,
44+ Malformed,
45+}
46+
47+impl std::fmt::Display for ExecError {
48+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49+ match self {
50+ ExecError::UnsupportedCommand => write!(
51+ f,
52+ "Dogfood accepts only `git-upload-pack` and `git-receive-pack` over SSH."
53+ ),
54+ ExecError::BadPath => write!(f, "Expected a path of the form 'owner/repo'."),
55+ ExecError::Malformed => write!(f, "Could not parse the requested command."),
56+ }
57+ }
58+}
59+
60+/// Parse an SSH exec request.
61+///
62+/// Git sends, for example: `git-upload-pack 'owner/repo.git'`.
63+/// The quoting varies between clients, so both quoted and bare forms are
64+/// accepted — but nothing else is. There is deliberately no shell involved:
65+/// the string is split on whitespace and the pieces are matched literally, so
66+/// metacharacters carry no meaning.
67+pub fn parse(command: &str) -> Result<Request, ExecError> {
68+ // Guard against absurd input before doing any work.
69+ if command.is_empty() || command.len() > 4096 {
70+ return Err(ExecError::Malformed);
71+ }
72+ // A control character has no place in a command and is a strong signal of
73+ // an injection attempt.
74+ if command.bytes().any(|b| b < 0x20 || b == 0x7f) {
75+ return Err(ExecError::Malformed);
76+ }
77+
78+ let trimmed = command.trim();
79+ let (verb, rest) = match trimmed.split_once(char::is_whitespace) {
80+ Some((v, r)) => (v, r.trim()),
81+ None => return Err(ExecError::UnsupportedCommand),
82+ };
83+
84+ // Exactly two commands. `git upload-pack` (space form) is also accepted
85+ // because some clients send it, but nothing else — in particular not
86+ // `git-upload-pack-with-anything`, and not a shell pipeline.
87+ let (service, rest) = match verb {
88+ "git-upload-pack" => (Service::UploadPack, rest),
89+ "git-receive-pack" => (Service::ReceivePack, rest),
90+ "git" => {
91+ let (sub, r) = rest.split_once(char::is_whitespace).ok_or(ExecError::Malformed)?;
92+ match sub {
93+ "upload-pack" => (Service::UploadPack, r.trim()),
94+ "receive-pack" => (Service::ReceivePack, r.trim()),
95+ _ => return Err(ExecError::UnsupportedCommand),
96+ }
97+ }
98+ _ => return Err(ExecError::UnsupportedCommand),
99+ };
100+
101+ // Only one argument is permitted: the repository path. More than one means
102+ // the client is trying to pass flags.
103+ if rest.split_whitespace().count() != 1 {
104+ return Err(ExecError::Malformed);
105+ }
106+
107+ let path = unquote(rest);
108+ let (owner, repo) = split_path(&path)?;
109+
110+ Ok(Request {
111+ service,
112+ owner,
113+ repo,
114+ })
115+}
116+
117+fn unquote(s: &str) -> String {
118+ let s = s.trim();
119+ for q in ['\'', '"'] {
120+ if s.len() >= 2 && s.starts_with(q) && s.ends_with(q) {
121+ return s[1..s.len() - 1].to_string();
122+ }
123+ }
124+ s.to_string()
125+}
126+
127+/// Split `owner/repo(.git)` into its parts, rejecting anything else.
128+///
129+/// Traversal is impossible by construction: exactly two components are allowed
130+/// and neither may contain a slash or a dot-dot.
131+fn split_path(path: &str) -> Result<(String, String), ExecError> {
132+ let path = path.trim_start_matches('/').trim_end_matches('/');
133+ let path = path.strip_suffix(".git").unwrap_or(path);
134+
135+ let mut parts = path.split('/');
136+ let (Some(owner), Some(repo), None) = (parts.next(), parts.next(), parts.next()) else {
137+ return Err(ExecError::BadPath);
138+ };
139+
140+ if owner.is_empty() || repo.is_empty() {
141+ return Err(ExecError::BadPath);
142+ }
143+ // Belt and braces — a component cannot contain a slash here, but `..` as a
144+ // whole component would still be a traversal attempt worth naming.
145+ if owner.contains("..") || repo.contains("..") {
146+ return Err(ExecError::BadPath);
147+ }
148+ if !owner.chars().all(valid_owner_char) || !repo.chars().all(valid_repo_char) {
149+ return Err(ExecError::BadPath);
150+ }
151+
152+ Ok((owner.to_string(), repo.to_string()))
153+}
154+
155+fn valid_owner_char(c: char) -> bool {
156+ c.is_ascii_alphanumeric() || c == '-'
157+}
158+
159+fn valid_repo_char(c: char) -> bool {
160+ c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')
161+}
162+
163+#[cfg(test)]
164+mod tests {
165+ use super::*;
166+
167+ fn req(service: Service, owner: &str, repo: &str) -> Request {
168+ Request {
169+ service,
170+ owner: owner.into(),
171+ repo: repo.into(),
172+ }
173+ }
174+
175+ #[test]
176+ fn parses_the_standard_forms() {
177+ assert_eq!(
178+ parse("git-upload-pack 'snow/core.git'").unwrap(),
179+ req(Service::UploadPack, "snow", "core")
180+ );
181+ assert_eq!(
182+ parse("git-receive-pack 'snow/core.git'").unwrap(),
183+ req(Service::ReceivePack, "snow", "core")
184+ );
185+ // Unquoted, no .git, leading slash — all seen from real clients.
186+ assert_eq!(
187+ parse("git-upload-pack /snow/core").unwrap(),
188+ req(Service::UploadPack, "snow", "core")
189+ );
190+ assert_eq!(
191+ parse("git-upload-pack \"snow/core.git\"").unwrap(),
192+ req(Service::UploadPack, "snow", "core")
193+ );
194+ assert_eq!(
195+ parse("git upload-pack 'snow/core.git'").unwrap(),
196+ req(Service::UploadPack, "snow", "core")
197+ );
198+ }
199+
200+ // ─── everything else is refused (spec §9) ────────────────────────────────
201+
202+ #[test]
203+ fn refuses_any_other_command() {
204+ for bad in [
205+ "bash",
206+ "sh -c 'rm -rf /'",
207+ "scp -t /tmp",
208+ "rsync --server",
209+ "git-upload-archive 'a/b'",
210+ "git gc",
211+ "git-upload-packX 'a/b'",
212+ "/usr/bin/git-upload-pack 'a/b'",
213+ "env",
214+ "cat /etc/passwd",
215+ ] {
216+ assert!(
217+ matches!(
218+ parse(bad),
219+ Err(ExecError::UnsupportedCommand) | Err(ExecError::Malformed)
220+ ),
221+ "must refuse {bad:?}, got {:?}",
222+ parse(bad)
223+ );
224+ }
225+ }
226+
227+ #[test]
228+ fn shell_metacharacters_carry_no_meaning() {
229+ // There is no shell, so these are simply invalid paths — never executed.
230+ for bad in [
231+ "git-upload-pack 'a/b; rm -rf /'",
232+ "git-upload-pack 'a/b && curl evil'",
233+ "git-upload-pack 'a/b|nc evil 1234'",
234+ "git-upload-pack '$(whoami)/x'",
235+ "git-upload-pack '`id`/x'",
236+ ] {
237+ assert!(parse(bad).is_err(), "must refuse {bad:?}");
238+ }
239+ }
240+
241+ #[test]
242+ fn refuses_path_traversal() {
243+ for bad in [
244+ "git-upload-pack '../../etc/passwd'",
245+ "git-upload-pack 'a/../../b'",
246+ "git-upload-pack 'a/b/c'",
247+ "git-upload-pack 'onlyone'",
248+ "git-upload-pack '..'",
249+ "git-upload-pack '/'",
250+ ] {
251+ assert!(
252+ matches!(parse(bad), Err(ExecError::BadPath) | Err(ExecError::Malformed)),
253+ "must refuse {bad:?}, got {:?}",
254+ parse(bad)
255+ );
256+ }
257+ }
258+
259+ /// An absolute path is the ordinary wire form, not an attack.
260+ ///
261+ /// `ssh://git@host/owner/repo.git` makes Git send `'/owner/repo.git'`, so
262+ /// the leading slash has to be accepted. That means
263+ /// `git-upload-pack '/etc/passwd'` parses — as owner `etc`, repo `passwd`,
264+ /// which is a *name*, not a path.
265+ ///
266+ /// Nothing is disclosed by that, and it is worth being precise about why:
267+ /// storage location derives from the repository's UUID and never from these
268+ /// strings (spec §3, §9), so a parsed name that matches no row resolves to
269+ /// no repository at all. The two components can never become a filesystem
270+ /// path, whatever they say.
271+ #[test]
272+ fn an_absolute_path_is_the_normal_wire_form_not_traversal() {
273+ assert_eq!(
274+ parse("git-upload-pack '/alice/dogfood.git'").unwrap(),
275+ req(Service::UploadPack, "alice", "dogfood")
276+ );
277+ assert_eq!(
278+ parse("git-upload-pack '/etc/passwd'").unwrap(),
279+ req(Service::UploadPack, "etc", "passwd"),
280+ "parsed as two names; resolution against the database is what denies it"
281+ );
282+ }
283+
284+ #[test]
285+ fn refuses_extra_arguments() {
286+ // Otherwise a client could pass flags to git.
287+ assert!(parse("git-upload-pack --upload-pack=/bin/sh 'a/b'").is_err());
288+ assert!(parse("git-upload-pack 'a/b' 'c/d'").is_err());
289+ assert!(parse("git-upload-pack --help").is_err());
290+ }
291+
292+ #[test]
293+ fn refuses_control_characters() {
294+ assert_eq!(
295+ parse("git-upload-pack 'a/b'\nrm -rf /"),
296+ Err(ExecError::Malformed)
297+ );
298+ assert_eq!(parse("git-upload-pack 'a\0b'"), Err(ExecError::Malformed));
299+ }
300+
301+ #[test]
302+ fn refuses_empty_and_overlong() {
303+ assert_eq!(parse(""), Err(ExecError::Malformed));
304+ assert_eq!(parse("git-upload-pack"), Err(ExecError::UnsupportedCommand));
305+ assert_eq!(parse(&"a".repeat(5000)), Err(ExecError::Malformed));
306+ }
307+
308+ #[test]
309+ fn write_classification_is_correct() {
310+ assert!(!Service::UploadPack.is_write());
311+ assert!(Service::ReceivePack.is_write());
312+ }
313+
314+ #[test]
315+ fn a_repo_named_with_dot_git_inside_is_preserved() {
316+ // Only a trailing `.git` is stripped.
317+ assert_eq!(
318+ parse("git-upload-pack 'snow/my.git.repo'").unwrap().repo,
319+ "my.git.repo"
320+ );
321+ }
322+}
Acrates/df-ssh/src/limits.rs+249−0
@@ −0,0 +1,249 @@
1+//! Per-source-IP connection limits (spec §9).
2+//!
3+//! > **SSH.** Key auth only. […] Rate-limit connections per source IP.
4+//!
5+//! SSH is the cheapest thing on this host to attack: a connection costs the
6+//! client one TCP handshake and costs the server a key exchange, a database
7+//! lookup, and an Argon2-free but still real authentication path. Without a
8+//! limit, one source can occupy every connection slot.
9+//!
10+//! Two limits, for the two shapes that takes:
11+//!
12+//! * a **rate** limit, so a source cannot open connections in a tight loop, and
13+//! * a **concurrency** limit, so it cannot hold many open at once.
14+//!
15+//! Enforced at `new_client`, before the key exchange — the earliest point at
16+//! which the peer is known, and before any work has been done on its behalf.
17+
18+use std::collections::HashMap;
19+use std::net::IpAddr;
20+use std::sync::{Arc, Mutex};
21+use std::time::{Duration, Instant};
22+
23+/// Sustained new connections per second, per source.
24+///
25+/// A `jj git push` opens one connection. Even a script fetching every
26+/// repository in a loop stays well under this.
27+const REFILL_PER_SEC: f64 = 0.5;
28+
29+/// Burst above the sustained rate, for a client that clones several
30+/// repositories at once.
31+const BURST: f64 = 10.0;
32+
33+/// Simultaneous connections from one source.
34+const MAX_CONCURRENT: u32 = 8;
35+
36+/// Sources idle longer than this are forgotten.
37+const IDLE_EVICT: Duration = Duration::from_secs(600);
38+
39+/// Distinct sources tracked before new ones are refused. Reached only under a
40+/// distributed flood.
41+const MAX_TRACKED: usize = 20_000;
42+
43+#[derive(Clone, Default)]
44+pub struct ConnectionLimits(Arc<Mutex<State>>);
45+
46+#[derive(Default)]
47+struct State {
48+ sources: HashMap<IpAddr, Source>,
49+ last_sweep: Option<Instant>,
50+}
51+
52+struct Source {
53+ tokens: f64,
54+ open: u32,
55+ last: Instant,
56+}
57+
58+impl ConnectionLimits {
59+ pub fn new() -> Self {
60+ Self::default()
61+ }
62+
63+ /// Admit a connection from `peer`, or refuse it.
64+ ///
65+ /// The returned guard releases the concurrency slot when the connection's
66+ /// handler is dropped — which happens however the connection ends, so a
67+ /// client that disappears mid-session does not leak a slot.
68+ pub fn admit(&self, peer: Option<std::net::SocketAddr>) -> Option<ConnectionGuard> {
69+ // A connection whose peer cannot be determined is not attributable to a
70+ // source, so it is admitted without a slot rather than refused: in
71+ // practice this does not happen, and failing closed here would break
72+ // every connection if it ever did.
73+ let ip = peer?.ip();
74+ self.admit_at(ip, Instant::now())
75+ }
76+
77+ fn admit_at(&self, ip: IpAddr, now: Instant) -> Option<ConnectionGuard> {
78+ let mut state = self.0.lock().ok()?;
79+ state.sweep(now);
80+
81+ if state.sources.len() >= MAX_TRACKED && !state.sources.contains_key(&ip) {
82+ return None;
83+ }
84+
85+ let source = state.sources.entry(ip).or_insert(Source {
86+ tokens: BURST,
87+ open: 0,
88+ last: now,
89+ });
90+
91+ let elapsed = now.saturating_duration_since(source.last).as_secs_f64();
92+ source.tokens = (source.tokens + elapsed * REFILL_PER_SEC).min(BURST);
93+ source.last = now;
94+
95+ if source.open >= MAX_CONCURRENT || source.tokens < 1.0 {
96+ return None;
97+ }
98+
99+ source.tokens -= 1.0;
100+ source.open += 1;
101+
102+ Some(ConnectionGuard { limits: self.clone(), ip })
103+ }
104+
105+ fn release(&self, ip: IpAddr) {
106+ if let Ok(mut state) = self.0.lock() {
107+ if let Some(s) = state.sources.get_mut(&ip) {
108+ s.open = s.open.saturating_sub(1);
109+ }
110+ }
111+ }
112+
113+ #[cfg(test)]
114+ fn tracked(&self) -> usize {
115+ self.0.lock().unwrap().sources.len()
116+ }
117+}
118+
119+impl State {
120+ fn sweep(&mut self, now: Instant) {
121+ let due = self
122+ .last_sweep
123+ .is_none_or(|t| now.saturating_duration_since(t) >= Duration::from_secs(60));
124+ if !due {
125+ return;
126+ }
127+ self.last_sweep = Some(now);
128+ // Never evict a source with a connection open; that would lose the count
129+ // and let the concurrency cap be bypassed by long-lived sessions.
130+ self.sources
131+ .retain(|_, s| s.open > 0 || now.saturating_duration_since(s.last) < IDLE_EVICT);
132+ }
133+}
134+
135+/// Holds one connection slot for a source.
136+pub struct ConnectionGuard {
137+ limits: ConnectionLimits,
138+ ip: IpAddr,
139+}
140+
141+impl Drop for ConnectionGuard {
142+ fn drop(&mut self) {
143+ self.limits.release(self.ip);
144+ }
145+}
146+
147+#[cfg(test)]
148+mod tests {
149+ use super::*;
150+
151+ fn ip(s: &str) -> IpAddr {
152+ s.parse().unwrap()
153+ }
154+
155+ #[test]
156+ fn concurrent_connections_are_capped() {
157+ let l = ConnectionLimits::new();
158+ let now = Instant::now();
159+
160+ let mut held = Vec::new();
161+ for i in 0..MAX_CONCURRENT {
162+ held.push(l.admit_at(ip("10.0.0.1"), now).expect_and_count(i));
163+ }
164+ assert!(
165+ l.admit_at(ip("10.0.0.1"), now).is_none(),
166+ "the concurrency cap must refuse the next connection"
167+ );
168+
169+ drop(held.pop());
170+ assert!(
171+ l.admit_at(ip("10.0.0.1"), now).is_some(),
172+ "closing a connection frees its slot"
173+ );
174+ }
175+
176+ #[test]
177+ fn the_burst_is_spent_and_then_refills() {
178+ let l = ConnectionLimits::new();
179+ let start = Instant::now();
180+
181+ // Guards dropped immediately, so only the token bucket applies.
182+ for _ in 0..BURST as usize {
183+ assert!(l.admit_at(ip("10.0.0.2"), start).is_some());
184+ }
185+ assert!(l.admit_at(ip("10.0.0.2"), start).is_none(), "burst spent");
186+
187+ let later = start + Duration::from_secs(4);
188+ assert!(
189+ l.admit_at(ip("10.0.0.2"), later).is_some(),
190+ "{REFILL_PER_SEC}/s should have refilled by then"
191+ );
192+ }
193+
194+ #[test]
195+ fn sources_do_not_share_a_budget() {
196+ let l = ConnectionLimits::new();
197+ let now = Instant::now();
198+ for _ in 0..BURST as usize {
199+ let _ = l.admit_at(ip("10.0.0.3"), now);
200+ }
201+ assert!(l.admit_at(ip("10.0.0.3"), now).is_none());
202+ assert!(
203+ l.admit_at(ip("10.0.0.4"), now).is_some(),
204+ "one source must not exhaust another's budget"
205+ );
206+ }
207+
208+ #[test]
209+ fn a_peerless_connection_is_admitted_without_a_slot() {
210+ let l = ConnectionLimits::new();
211+ assert!(
212+ l.admit(None).is_none(),
213+ "no peer means no guard — the connection proceeds unmetered"
214+ );
215+ }
216+
217+ #[test]
218+ fn idle_sources_are_forgotten() {
219+ let l = ConnectionLimits::new();
220+ let start = Instant::now();
221+ drop(l.admit_at(ip("10.0.0.5"), start));
222+ assert_eq!(l.tracked(), 1);
223+
224+ let later = start + IDLE_EVICT + Duration::from_secs(1);
225+ let _g = l.admit_at(ip("10.0.0.6"), later);
226+ assert_eq!(l.tracked(), 1, "the idle source should have been swept");
227+ }
228+
229+ #[test]
230+ fn a_backwards_clock_does_not_mint_tokens() {
231+ let l = ConnectionLimits::new();
232+ let now = Instant::now();
233+ for _ in 0..BURST as usize {
234+ let _ = l.admit_at(ip("10.0.0.7"), now);
235+ }
236+ let earlier = now.checked_sub(Duration::from_secs(600)).unwrap_or(now);
237+ assert!(l.admit_at(ip("10.0.0.7"), earlier).is_none());
238+ }
239+
240+ /// Small helper so the concurrency test reads clearly when it fails.
241+ trait ExpectAndCount {
242+ fn expect_and_count(self, i: u32) -> ConnectionGuard;
243+ }
244+ impl ExpectAndCount for Option<ConnectionGuard> {
245+ fn expect_and_count(self, i: u32) -> ConnectionGuard {
246+ self.unwrap_or_else(|| panic!("connection {i} should have been admitted"))
247+ }
248+ }
249+}
Acrates/df-ssh/src/main.rs477 lines+477−0
@@ −0,0 +1,477 @@
1+//! `dogfood-ssh` — Git over SSH (spec §6, §9).
2+//!
3+//! Public-key authentication only. The presented key's SHA256 fingerprint is
4+//! looked up in `ssh_keys`, which resolves the user; then only
5+//! `git-upload-pack` and `git-receive-pack` are dispatched.
6+//!
7+//! No password auth, no shell, no forwarding, no PTY, no SFTP.
8+
9+use std::sync::Arc;
10+
11+use anyhow::{Context, Result};
12+use russh::server::{Auth, Handler, Msg, Server as _, Session};
13+use russh::{Channel, ChannelId, MethodKind, MethodSet};
14+use sqlx::PgPool;
15+use uuid::Uuid;
16+
17+mod exec;
18+mod limits;
19+mod repo;
20+
21+
22+/// How long a connection may sit before authenticating.
23+///
24+/// Distinct from [`INACTIVITY_TIMEOUT`], which governs an *established* session:
25+/// a client that connects and then says nothing has done no work we can charge
26+/// it for, and holding the slot open is the whole attack.
27+const AUTH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
28+
29+/// How long an authenticated session may sit idle. Generous, because a large
30+/// `git-upload-pack` can be quiet for a while as the client works.
31+const INACTIVITY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
32+
33+struct Server {
34+ db: PgPool,
35+ repo_root: String,
36+ database_url: String,
37+ hook_binary: String,
38+ /// Per-source-IP connection limits (spec §9).
39+ limits: limits::ConnectionLimits,
40+}
41+
42+impl russh::server::Server for Server {
43+ type Handler = Connection;
44+
45+ fn new_client(&mut self, peer: Option<std::net::SocketAddr>) -> Connection {
46+ // Admission happens here, before the key exchange — the earliest point
47+ // at which the peer is known and the last one before we spend anything
48+ // on its behalf. A refused connection is still constructed (russh gives
49+ // no way to decline one at this point), but it carries no guard and is
50+ // rejected at the first authentication attempt.
51+ let guard = self.limits.admit(peer);
52+ if guard.is_none() && peer.is_some() {
53+ tracing::warn!(?peer, "ssh connection refused: source rate limit");
54+ }
55+
56+ Connection {
57+ db: self.db.clone(),
58+ repo_root: self.repo_root.clone(),
59+ database_url: self.database_url.clone(),
60+ hook_binary: self.hook_binary.clone(),
61+ peer,
62+ admitted: guard.is_some() || peer.is_none(),
63+ _guard: guard,
64+ user: None,
65+ git_stdin: None,
66+ }
67+ }
68+
69+ fn handle_session_error(&mut self, error: <Connection as Handler>::Error) {
70+ // Client disconnects are routine; log at debug so they do not drown the
71+ // signal.
72+ tracing::debug!("ssh session ended: {error}");
73+ }
74+}
75+
76+struct Connection {
77+ db: PgPool,
78+ repo_root: String,
79+ database_url: String,
80+ hook_binary: String,
81+ peer: Option<std::net::SocketAddr>,
82+ /// Whether the source's connection limit admitted this connection.
83+ admitted: bool,
84+ /// Holds the source's concurrency slot for the life of the connection.
85+ _guard: Option<limits::ConnectionGuard>,
86+ /// Set once a key has authenticated.
87+ user: Option<AuthedUser>,
88+ /// git's stdin for the running service, fed by `data`.
89+ git_stdin: Option<tokio::process::ChildStdin>,
90+}
91+
92+#[derive(Clone)]
93+struct AuthedUser {
94+ id: Uuid,
95+ handle: String,
96+}
97+
98+impl Handler for Connection {
99+ type Error = anyhow::Error;
100+
101+ /// Public-key authentication.
102+ ///
103+ /// The SSH username is ignored entirely — everyone connects as `git`, and
104+ /// the key decides who they are. That is the standard forge convention and
105+ /// means a user does not have to configure a per-host username.
106+ async fn auth_publickey(
107+ &mut self,
108+ _user: &str,
109+ key: &russh::keys::ssh_key::PublicKey,
110+ ) -> Result<Auth, Self::Error> {
111+ // A connection the limiter refused never gets as far as a database
112+ // lookup — which is the work the limit exists to prevent.
113+ if !self.admitted {
114+ return Ok(Auth::Reject {
115+ proceed_with_methods: None,
116+ partial_success: false,
117+ });
118+ }
119+
120+ let fingerprint = key
121+ .fingerprint(russh::keys::ssh_key::HashAlg::Sha256)
122+ .to_string();
123+
124+ let found: Option<(Uuid, String)> = sqlx::query_as(
125+ "SELECT u.id, u.handle::text
126+ FROM ssh_keys k JOIN users u ON u.id = k.user_id
127+ WHERE k.fingerprint = $1",
128+ )
129+ .bind(&fingerprint)
130+ .fetch_optional(&self.db)
131+ .await
132+ .unwrap_or(None);
133+
134+ match found {
135+ Some((id, handle)) => {
136+ tracing::info!(peer = ?self.peer, %handle, "ssh key accepted");
137+ // Best-effort usage tracking; never fail auth on it.
138+ let _ = sqlx::query("UPDATE ssh_keys SET last_used_at = now() WHERE fingerprint = $1")
139+ .bind(&fingerprint)
140+ .execute(&self.db)
141+ .await;
142+ self.user = Some(AuthedUser { id, handle });
143+ Ok(Auth::Accept)
144+ }
145+ None => {
146+ tracing::info!(peer = ?self.peer, %fingerprint, "ssh key rejected: unknown");
147+ Ok(Auth::Reject {
148+ proceed_with_methods: None,
149+ partial_success: false,
150+ })
151+ }
152+ }
153+ }
154+
155+ /// Password authentication is never permitted (spec §9).
156+ async fn auth_password(&mut self, _user: &str, _password: &str) -> Result<Auth, Self::Error> {
157+ Ok(Auth::Reject {
158+ proceed_with_methods: Some(publickey_only()),
159+ partial_success: false,
160+ })
161+ }
162+
163+ async fn auth_none(&mut self, _user: &str) -> Result<Auth, Self::Error> {
164+ Ok(Auth::Reject {
165+ proceed_with_methods: Some(publickey_only()),
166+ partial_success: false,
167+ })
168+ }
169+
170+ async fn channel_open_session(
171+ &mut self,
172+ _channel: Channel<Msg>,
173+ _session: &mut Session,
174+ ) -> Result<bool, Self::Error> {
175+ Ok(true)
176+ }
177+
178+ /// The only thing a session may do.
179+ async fn exec_request(
180+ &mut self,
181+ channel: ChannelId,
182+ data: &[u8],
183+ session: &mut Session,
184+ ) -> Result<(), Self::Error> {
185+ let Some(user) = self.user.clone() else {
186+ // Cannot happen — russh will not deliver exec before auth — but
187+ // default-deny rather than assume.
188+ return deny(session, channel, "Not authenticated.");
189+ };
190+
191+ let command = String::from_utf8_lossy(data).to_string();
192+
193+ let request = match exec::parse(&command) {
194+ Ok(r) => r,
195+ Err(e) => {
196+ tracing::info!(handle = %user.handle, "rejected ssh exec: {e}");
197+ return deny(session, channel, &e.to_string());
198+ }
199+ };
200+
201+ // Authorize before touching the filesystem.
202+ let resolved = match repo::authorize(
203+ &self.db,
204+ &request.owner,
205+ &request.repo,
206+ user.id,
207+ request.service.is_write(),
208+ )
209+ .await
210+ {
211+ Ok(Some(r)) => r,
212+ Ok(None) => {
213+ // Same message whether the repo is missing or merely invisible
214+ // (spec §9).
215+ return deny(
216+ session,
217+ channel,
218+ &format!(
219+ "Repository '{}/{}' not found.",
220+ request.owner, request.repo
221+ ),
222+ );
223+ }
224+ Err(e) => {
225+ tracing::error!("authorizing ssh request failed: {e:#}");
226+ return deny(session, channel, "Internal error.");
227+ }
228+ };
229+
230+ if request.service.is_write() && !resolved.can_push {
231+ return deny(
232+ session,
233+ channel,
234+ if resolved.archived {
235+ "This repository is archived and does not accept pushes."
236+ } else {
237+ "You do not have push access to this repository."
238+ },
239+ );
240+ }
241+
242+ let dir = repo::repo_path(&self.repo_root, resolved.repo_id);
243+ if !dir.is_dir() {
244+ tracing::error!(?dir, "repository directory missing");
245+ return deny(session, channel, "Repository storage is unavailable.");
246+ }
247+
248+ tracing::info!(
249+ handle = %user.handle,
250+ service = request.service.git_subcommand(),
251+ repo = %resolved.repo_id,
252+ "dispatching git"
253+ );
254+
255+ // git's stdout/stderr stream back from inside `run_git`; its stdin is
256+ // returned here so `data` can feed it the client's pack.
257+ let stdin = repo::run_git(
258+ session,
259+ channel,
260+ request.service,
261+ &dir,
262+ &self.database_url,
263+ &self.hook_binary,
264+ resolved.repo_id,
265+ user.id,
266+ )
267+ .await?;
268+
269+ self.git_stdin = Some(stdin);
270+ Ok(())
271+ }
272+
273+ /// Client -> git. Every frame the client sends is the other half of the
274+ /// pack protocol conversation.
275+ async fn data(
276+ &mut self,
277+ _channel: ChannelId,
278+ data: &[u8],
279+ _session: &mut Session,
280+ ) -> Result<(), Self::Error> {
281+ if let Some(stdin) = self.git_stdin.as_mut() {
282+ use tokio::io::AsyncWriteExt;
283+ if let Err(e) = stdin.write_all(data).await {
284+ // The child exited early — a rejected push, for instance.
285+ tracing::debug!("writing to git stdin failed: {e}");
286+ self.git_stdin = None;
287+ }
288+ }
289+ Ok(())
290+ }
291+
292+ /// The client has finished sending. Closing stdin is what lets
293+ /// receive-pack stop waiting and start processing.
294+ async fn channel_eof(
295+ &mut self,
296+ _channel: ChannelId,
297+ _session: &mut Session,
298+ ) -> Result<(), Self::Error> {
299+ if let Some(mut stdin) = self.git_stdin.take() {
300+ use tokio::io::AsyncWriteExt;
301+ let _ = stdin.shutdown().await;
302+ }
303+ Ok(())
304+ }
305+
306+ // ── everything else is refused (spec §9) ─────────────────────────────────
307+
308+ async fn shell_request(
309+ &mut self,
310+ channel: ChannelId,
311+ session: &mut Session,
312+ ) -> Result<(), Self::Error> {
313+ deny(
314+ session,
315+ channel,
316+ "Dogfood does not provide shell access. Use git or jj.",
317+ )
318+ }
319+
320+ async fn pty_request(
321+ &mut self,
322+ channel: ChannelId,
323+ _: &str,
324+ _: u32,
325+ _: u32,
326+ _: u32,
327+ _: u32,
328+ _: &[(russh::Pty, u32)],
329+ session: &mut Session,
330+ ) -> Result<(), Self::Error> {
331+ deny(session, channel, "No PTY.")
332+ }
333+
334+ async fn subsystem_request(
335+ &mut self,
336+ channel: ChannelId,
337+ name: &str,
338+ session: &mut Session,
339+ ) -> Result<(), Self::Error> {
340+ // Notably SFTP.
341+ tracing::info!("refused subsystem request: {name}");
342+ deny(session, channel, "No subsystems.")
343+ }
344+
345+ async fn tcpip_forward(
346+ &mut self,
347+ _address: &str,
348+ _port: &mut u32,
349+ _session: &mut Session,
350+ ) -> Result<bool, Self::Error> {
351+ // Refusing this is what stops the SSH server being an open proxy into
352+ // the internal network.
353+ Ok(false)
354+ }
355+
356+}
357+
358+/// The only authentication method Dogfood offers.
359+///
360+/// russh 0.51 models this as a list of `MethodKind`, not a bitflag set.
361+fn publickey_only() -> MethodSet {
362+ MethodSet::from(&[MethodKind::PublicKey][..])
363+}
364+
365+/// Write a message to the client's stderr and close the channel with failure.
366+fn deny(session: &mut Session, channel: ChannelId, message: &str) -> Result<()> {
367+ let text = format!("dogfood: {message}\r\n");
368+ let _ = session.extended_data(channel, 1, text.into_bytes().into());
369+ let _ = session.exit_status_request(channel, 1);
370+ let _ = session.close(channel);
371+ Ok(())
372+}
373+
374+#[tokio::main]
375+async fn main() -> Result<()> {
376+ let _ = dotenvy::dotenv();
377+ init_tracing();
378+
379+ let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
380+ let repo_root = std::env::var("REPO_ROOT").unwrap_or_else(|_| "/srv/repos".into());
381+ let bind = std::env::var("SSH_BIND").unwrap_or_else(|_| "0.0.0.0:2222".into());
382+ let host_key_path = std::env::var("SSH_HOST_KEY_PATH")
383+ .unwrap_or_else(|_| "/etc/dogfood/ssh_host_ed25519_key".into());
384+ let hook_binary =
385+ std::env::var("DOGFOOD_HOOK_BINARY").unwrap_or_else(|_| "/usr/local/bin/dogfood-hook".into());
386+
387+ let db = df_db::connect(&database_url, 5)
388+ .await
389+ .context("connecting to the database")?;
390+
391+ let host_key = load_or_create_host_key(&host_key_path)?;
392+
393+ let config = russh::server::Config {
394+ // A host key change looks like an attack to every client, so it is
395+ // generated once and persisted on a volume.
396+ keys: vec![host_key],
397+ auth_rejection_time: std::time::Duration::from_secs(1),
398+ auth_rejection_time_initial: Some(std::time::Duration::from_secs(0)),
399+ inactivity_timeout: Some(INACTIVITY_TIMEOUT),
400+ // A client that connects and never authenticates is holding a slot for
401+ // nothing. russh applies `Limits::rekey_time_limit` to the whole
402+ // pre-auth phase, so this is where the auth deadline lives.
403+ limits: russh::Limits {
404+ rekey_time_limit: AUTH_TIMEOUT,
405+ ..Default::default()
406+ },
407+ methods: publickey_only(),
408+ ..Default::default()
409+ };
410+
411+ let mut server = Server {
412+ limits: limits::ConnectionLimits::new(),
413+ db,
414+ repo_root,
415+ database_url,
416+ hook_binary,
417+ };
418+
419+ tracing::info!("dogfood-ssh listening on {bind}");
420+ server
421+ .run_on_address(Arc::new(config), &bind)
422+ .await
423+ .context("ssh server error")?;
424+
425+ Ok(())
426+}
427+
428+/// Load the host key, generating one on first boot.
429+fn load_or_create_host_key(path: &str) -> Result<russh::keys::PrivateKey> {
430+ let p = std::path::Path::new(path);
431+
432+ if p.exists() {
433+ let data = std::fs::read_to_string(p)
434+ .with_context(|| format!("reading host key {path}"))?;
435+ let key = russh::keys::PrivateKey::from_openssh(&data)
436+ .with_context(|| format!("parsing host key {path}"))?;
437+ tracing::info!("loaded ssh host key from {path}");
438+ return Ok(key);
439+ }
440+
441+ tracing::warn!("no ssh host key at {path}; generating one");
442+ let key = russh::keys::PrivateKey::random(
443+ &mut rand::thread_rng(),
444+ russh::keys::Algorithm::Ed25519,
445+ )
446+ .context("generating host key")?;
447+
448+ if let Some(parent) = p.parent() {
449+ std::fs::create_dir_all(parent).ok();
450+ }
451+ let pem = key
452+ .to_openssh(russh::keys::ssh_key::LineEnding::LF)
453+ .context("serialising host key")?;
454+ std::fs::write(p, pem.as_bytes()).with_context(|| format!("writing host key {path}"))?;
455+
456+ #[cfg(unix)]
457+ {
458+ use std::os::unix::fs::PermissionsExt;
459+ let _ = std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o600));
460+ }
461+
462+ tracing::info!("generated a new ssh host key at {path}");
463+ Ok(key)
464+}
465+
466+fn init_tracing() {
467+ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
468+ let filter =
469+ EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,df_ssh=debug"));
470+ let json = !std::io::IsTerminal::is_terminal(&std::io::stdout());
471+ let registry = tracing_subscriber::registry().with(filter);
472+ if json {
473+ registry.with(tracing_subscriber::fmt::layer().json()).init();
474+ } else {
475+ registry.with(tracing_subscriber::fmt::layer()).init();
476+ }
477+}
Acrates/df-ssh/src/repo.rs+239−0
@@ −0,0 +1,239 @@
1+//! Repository resolution, authorization, and git dispatch for SSH.
2+
3+use std::path::{Path, PathBuf};
4+use std::process::Stdio;
5+
6+use anyhow::Result;
7+use df_auth::permissions::{self, AccessInputs, Viewer};
8+use df_db::models::{OrgRole, RepoRole, Visibility};
9+use russh::server::Session;
10+use russh::ChannelId;
11+use sqlx::PgPool;
12+use tokio::io::AsyncReadExt;
13+use uuid::Uuid;
14+
15+use crate::exec::Service;
16+
17+pub struct Resolved {
18+ pub repo_id: Uuid,
19+ pub can_push: bool,
20+ /// Whether the repository is archived. Distinguished from `can_push` so the
21+ /// client can be told *why* a push was refused.
22+ pub archived: bool,
23+}
24+
25+/// Resolve `owner/repo` and authorize the connected user.
26+///
27+/// Returns `Ok(None)` when the repository does not exist *or* the user may not
28+/// see it — the caller must not distinguish the two (spec §9).
29+pub async fn authorize(
30+ db: &PgPool,
31+ owner: &str,
32+ name: &str,
33+ user_id: Uuid,
34+ _write: bool,
35+) -> Result<Option<Resolved>> {
36+ type Row = (
37+ Uuid,
38+ Visibility,
39+ Option<Uuid>,
40+ Option<RepoRole>,
41+ Option<OrgRole>,
42+ bool,
43+ bool,
44+ );
45+ let row: Option<Row> =
46+ sqlx::query_as(
47+ r#"
48+ SELECT r.id, r.visibility, r.owner_user_id,
49+ c.role AS collaborator_role,
50+ m.role AS org_role,
51+ COALESCE(u.is_admin, false),
52+ r.archived
53+ FROM repos r
54+ LEFT JOIN users ou ON ou.id = r.owner_user_id
55+ LEFT JOIN orgs og ON og.id = r.owner_org_id
56+ LEFT JOIN repo_collaborators c ON c.repo_id = r.id AND c.user_id = $3
57+ LEFT JOIN org_members m ON m.org_id = r.owner_org_id AND m.user_id = $3
58+ LEFT JOIN users u ON u.id = $3
59+ WHERE COALESCE(ou.handle, og.handle) = $1 AND r.name = $2
60+ "#,
61+ )
62+ .bind(owner)
63+ .bind(name)
64+ .bind(user_id)
65+ .fetch_optional(db)
66+ .await?;
67+
68+ let Some((repo_id, visibility, owner_user_id, collab, org, is_admin, archived)) = row else {
69+ return Ok(None);
70+ };
71+
72+ // The same resolver the web uses — one implementation, so SSH and HTTP
73+ // cannot disagree about who may do what (spec §6).
74+ let access = permissions::resolve(AccessInputs {
75+ visibility,
76+ viewer: Some(Viewer {
77+ user_id,
78+ is_site_admin: is_admin,
79+ }),
80+ owner_user_id,
81+ collaborator_role: collab,
82+ org_role: org,
83+ });
84+
85+ if !access.can_read() {
86+ return Ok(None);
87+ }
88+
89+ Ok(Some(Resolved {
90+ repo_id,
91+ // An archived repository is read-only, on every transport. The settings
92+ // page promises this, so it cannot be enforced only in the web UI.
93+ can_push: access.can_push() && !archived,
94+ archived,
95+ }))
96+}
97+
98+/// Sharded path, identical to `GitStore::repo_path`.
99+pub fn repo_path(root: &str, id: Uuid) -> PathBuf {
100+ let hex = id.simple().to_string();
101+ Path::new(root).join(&hex[..2]).join(format!("{hex}.git"))
102+}
103+
104+/// Spawn git, stream its output over the SSH channel, and return its stdin.
105+///
106+/// The caller keeps the returned stdin and feeds it every `data` frame the
107+/// client sends, closing it on EOF. Without that the child blocks forever
108+/// waiting for the pack — git's stdio is a two-way conversation, not a
109+/// request/response.
110+#[allow(clippy::too_many_arguments)]
111+pub async fn run_git(
112+ session: &mut Session,
113+ channel: ChannelId,
114+ service: Service,
115+ dir: &Path,
116+ database_url: &str,
117+ hook_binary: &str,
118+ repo_id: Uuid,
119+ user_id: Uuid,
120+) -> Result<tokio::process::ChildStdin> {
121+ let mut cmd = tokio::process::Command::new("git");
122+ cmd.arg(service.git_subcommand())
123+ .arg(".")
124+ .current_dir(dir)
125+ .stdin(Stdio::piped())
126+ .stdout(Stdio::piped())
127+ .stderr(Stdio::piped())
128+ // A clean environment: nothing from the connection leaks into git, and
129+ // the repository's own config cannot redirect what we run.
130+ .env_clear()
131+ .env("PATH", "/usr/bin:/bin:/usr/local/bin")
132+ .env("HOME", "/nonexistent")
133+ .env("GIT_CONFIG_NOSYSTEM", "1")
134+ .env("GIT_TERMINAL_PROMPT", "0")
135+ // Consumed by the pre-receive hook.
136+ .env("DOGFOOD_REPO_ID", repo_id.to_string())
137+ .env("DOGFOOD_PUSHER_ID", user_id.to_string())
138+ .env("DOGFOOD_HOOK_BINARY", hook_binary)
139+ .env("DATABASE_URL", database_url)
140+ // The check is mandatory: a hook that cannot verify protection must
141+ // refuse the push rather than wave it through.
142+ .env("DOGFOOD_ENFORCE", "1");
143+
144+ let mut child = cmd.spawn()?;
145+
146+ let stdin = child.stdin.take().expect("piped");
147+ let mut stdout = child.stdout.take().expect("piped");
148+ let mut stderr = child.stderr.take().expect("piped");
149+
150+ let handle = session.handle();
151+
152+ let out_task = {
153+ let handle = handle.clone();
154+ tokio::spawn(async move {
155+ let mut buf = vec![0u8; 32 * 1024];
156+ loop {
157+ match stdout.read(&mut buf).await {
158+ Ok(0) | Err(_) => break,
159+ Ok(n) => {
160+ if handle.data(channel, buf[..n].to_vec().into()).await.is_err() {
161+ break;
162+ }
163+ }
164+ }
165+ }
166+ })
167+ };
168+
169+ let err_task = {
170+ let handle = handle.clone();
171+ tokio::spawn(async move {
172+ let mut buf = vec![0u8; 8 * 1024];
173+ loop {
174+ match stderr.read(&mut buf).await {
175+ Ok(0) | Err(_) => break,
176+ Ok(n) => {
177+ // Stream 1 is stderr; this is where the pre-receive
178+ // hook's rejection messages reach the user.
179+ if handle
180+ .extended_data(channel, 1, buf[..n].to_vec().into())
181+ .await
182+ .is_err()
183+ {
184+ break;
185+ }
186+ }
187+ }
188+ }
189+ })
190+ };
191+
192+ // Reap the child and close the channel once it exits. This runs detached so
193+ // `exec_request` can return and the handler can keep delivering client data
194+ // to the stdin we hand back.
195+ tokio::spawn(async move {
196+ let status = match child.wait().await {
197+ Ok(s) => s,
198+ Err(e) => {
199+ tracing::error!("waiting for git failed: {e}");
200+ let _ = handle.close(channel).await;
201+ return;
202+ }
203+ };
204+ let _ = out_task.await;
205+ let _ = err_task.await;
206+
207+ let code = status.code().unwrap_or(1) as u32;
208+ let _ = handle.exit_status_request(channel, code).await;
209+ let _ = handle.eof(channel).await;
210+ let _ = handle.close(channel).await;
211+ });
212+
213+ Ok(stdin)
214+}
215+
216+#[cfg(test)]
217+mod tests {
218+ use super::*;
219+
220+ #[test]
221+ fn repo_paths_match_the_store_layout() {
222+ // These two must never diverge, or SSH serves a different directory
223+ // than the web does.
224+ let id = Uuid::parse_str("0191f0aa-1234-7abc-8def-0123456789ab").unwrap();
225+ assert_eq!(
226+ repo_path("/srv/repos", id),
227+ PathBuf::from("/srv/repos/01/0191f0aa12347abc8def0123456789ab.git")
228+ );
229+ }
230+
231+ #[test]
232+ fn repo_paths_never_escape_the_root() {
233+ for _ in 0..50 {
234+ let p = repo_path("/srv/repos", Uuid::now_v7());
235+ assert!(p.starts_with("/srv/repos"));
236+ assert!(!p.to_string_lossy().contains(".."));
237+ }
238+ }
239+}
Acrates/df-store/Cargo.toml+29−0
@@ −0,0 +1,29 @@
1+[package]
2+name = "df-store"
3+version.workspace = true
4+edition.workspace = true
5+rust-version.workspace = true
6+license.workspace = true
7+
8+[dependencies]
9+# df-index owns the jj metadata format; this crate does not duplicate that
10+# parser. Note the dependency direction: df-index has no Git dependency, so
11+# `gix` still stops here.
12+df-index = { path = "../df-index" }
13+
14+anyhow.workspace = true
15+async-trait.workspace = true
16+chrono.workspace = true
17+# CONSTRAINT (spec §3 rule 1): this is the ONLY crate permitted to depend on
18+# `gix`. Enforced by scripts/check-store-boundary.sh in CI.
19+gix.workspace = true
20+lru.workspace = true
21+similar.workspace = true
22+thiserror.workspace = true
23+tokio.workspace = true
24+tracing.workspace = true
25+uuid.workspace = true
26+
27+[dev-dependencies]
28+tempfile = "3"
29+serde_json.workspace = true
Acrates/df-store/src/git/conflicts.rs+173−0
@@ −0,0 +1,173 @@
1+//! Reading a conflicted revision's sides (spec §4).
2+//!
3+//! > jj represents a conflicted state in the Git backend as a specially
4+//! > structured tree. Detect that structure in the indexer and set
5+//! > `revisions.conflicted`. Store the conflict sides so the conflict viewer can
6+//! > render them without re-walking objects on every request.
7+//!
8+//! jj's conflict representation is an alternating list of trees carried in the
9+//! commit's `jj:trees` header: `side₀ base₀ side₁ base₁ … sideₙ`, always one
10+//! more side than base. The materialised value of a conflicted file is
11+//! `side₀ - base₀ + side₁ - base₁ + …`; a path is in conflict exactly when it is
12+//! not the same in every one of those trees.
13+//!
14+//! This module resolves that into plain strings so the conflict viewer never
15+//! sees a tree id. v1 shows conflicts **read-only** — resolution happens in the
16+//! user's working copy (spec §4), so nothing here writes.
17+
18+use std::collections::BTreeSet;
19+
20+use crate::{ConflictSide, ConflictedFile, Result, RevId, StoreError};
21+
22+/// Never materialise more conflicted files than this into one page.
23+///
24+/// A repository-wide conflict is possible (a rebase across a moved directory)
25+/// and rendering ten thousand files would take the process down, which spec §8
26+/// says a pathological input must never do.
27+const MAX_CONFLICTED_FILES: usize = 200;
28+
29+/// Per-side content cap. Conflict sides are shown inline; a large one is
30+/// reported by size rather than rendered.
31+const MAX_SIDE_BYTES: usize = 512 * 1024;
32+
33+/// Read every conflicted path in `rev`.
34+///
35+/// Returns an empty vector for an unconflicted revision rather than an error —
36+/// "is it conflicted" is answered by [`crate::Revision::conflicted`], and a
37+/// caller asking for the detail of a clean revision is asking a reasonable
38+/// question with a boring answer.
39+pub fn read(repo: &gix::Repository, rev: &RevId) -> Result<Vec<ConflictedFile>> {
40+ let commit = super::convert::find_commit(repo, rev)?;
41+
42+ let Some(trees) = df_index::extract_conflict_trees(commit.data.as_ref()) else {
43+ return Ok(Vec::new());
44+ };
45+
46+ // Sides and bases interleaved back into the order jj records them, tagged so
47+ // the viewer can label each column. The alternating order is what makes
48+ // "side 1 / base 1 / side 2" read correctly in the UI.
49+ let mut columns: Vec<(ConflictSide, String)> = Vec::new();
50+ let mut sides = trees.sides.into_iter();
51+ let mut bases = trees.bases.into_iter();
52+ let mut n = 0usize;
53+ loop {
54+ match sides.next() {
55+ Some(s) => {
56+ columns.push((ConflictSide::Side(n), s));
57+ n += 1;
58+ }
59+ None => break,
60+ }
61+ if let Some(b) = bases.next() {
62+ columns.push((ConflictSide::Base(n - 1), b));
63+ }
64+ }
65+
66+ // Load every tree once. A conflict with three sides walks three trees, not
67+ // three trees per file.
68+ let loaded: Vec<(ConflictSide, gix::Tree<'_>)> = columns
69+ .iter()
70+ .filter_map(|(label, id)| {
71+ let oid = gix::ObjectId::from_hex(id.as_bytes()).ok()?;
72+ let tree = repo.find_object(oid).ok()?.try_into_tree().ok()?;
73+ Some((*label, tree))
74+ })
75+ .collect();
76+
77+ if loaded.len() != columns.len() {
78+ // A tree named by the header is missing from the object database. That
79+ // is real corruption, and inventing a partial conflict view from what
80+ // remains would misrepresent it.
81+ return Err(StoreError::Other(anyhow::anyhow!(
82+ "conflicted revision {rev} names a tree that is not in the repository"
83+ )));
84+ }
85+
86+ // Every path present in any column, with the blob it resolves to there.
87+ let mut per_column: Vec<std::collections::BTreeMap<String, gix::ObjectId>> = Vec::new();
88+ for (_, tree) in &loaded {
89+ per_column.push(flatten(tree)?);
90+ }
91+
92+ let mut paths: BTreeSet<String> = BTreeSet::new();
93+ for m in &per_column {
94+ paths.extend(m.keys().cloned());
95+ }
96+
97+ let mut out = Vec::new();
98+ for path in paths {
99+ // A path is in conflict only where the columns disagree. jj's conflict
100+ // trees carry the *whole* tree, not just the conflicted files, so
101+ // without this check every file in the repository would be reported.
102+ let first = per_column[0].get(&path);
103+ if per_column.iter().all(|m| m.get(&path) == first) {
104+ continue;
105+ }
106+
107+ if out.len() >= MAX_CONFLICTED_FILES {
108+ break;
109+ }
110+
111+ let mut sides = Vec::new();
112+ for (i, (label, _)) in loaded.iter().enumerate() {
113+ let content = match per_column[i].get(&path) {
114+ None => None, // this side deleted the file
115+ Some(oid) => Some(read_blob_text(repo, *oid)),
116+ };
117+ sides.push((*label, content));
118+ }
119+
120+ out.push(ConflictedFile { path, sides });
121+ }
122+
123+ Ok(out)
124+}
125+
126+/// Every blob in a tree, keyed by full path.
127+///
128+/// Recursive, and deliberately skips jj's own conflict bookkeeping entries:
129+/// `.jjconflict-*` is storage detail and must never surface as a file (spec §4).
130+fn flatten(tree: &gix::Tree<'_>) -> Result<std::collections::BTreeMap<String, gix::ObjectId>> {
131+ use gix::traverse::tree::Recorder;
132+
133+ let mut recorder = Recorder::default();
134+ tree.traverse()
135+ .breadthfirst(&mut recorder)
136+ .map_err(|e| StoreError::Other(anyhow::anyhow!("walking conflict tree: {e}")))?;
137+
138+ let mut out = std::collections::BTreeMap::new();
139+ for entry in recorder.records {
140+ if !entry.mode.is_blob_or_symlink() {
141+ continue;
142+ }
143+ let path = entry.filepath.to_string();
144+ if path
145+ .split('/')
146+ .any(df_index::is_conflict_artifact)
147+ {
148+ continue;
149+ }
150+ out.insert(path, entry.oid);
151+ }
152+ Ok(out)
153+}
154+
155+/// A side's content, as text.
156+///
157+/// Binary and oversized sides come back as a description rather than bytes: the
158+/// conflict viewer is a text view, and pushing a megabyte of binary into a
159+/// `<pre>` helps nobody.
160+fn read_blob_text(repo: &gix::Repository, oid: gix::ObjectId) -> String {
161+ let Ok(obj) = repo.find_object(oid) else {
162+ return String::from("(unreadable)");
163+ };
164+ let data = obj.data.as_slice();
165+
166+ if data.len() > MAX_SIDE_BYTES {
167+ return format!("({} bytes — too large to show)", data.len());
168+ }
169+ match std::str::from_utf8(data) {
170+ Ok(s) => s.to_owned(),
171+ Err(_) => format!("(binary, {} bytes)", data.len()),
172+ }
173+}
Acrates/df-store/src/git/convert.rs+89−0
@@ −0,0 +1,89 @@
1+//! Conversion from `gix` types to `df-store` types.
2+//!
3+//! Everything Git-shaped stops here. Callers above the trait see only the
4+//! plain data structures defined in `lib.rs`.
5+
6+use chrono::TimeZone;
7+
8+use crate::{EntryKind, Result, RevId, Revision, Signature, StoreError};
9+
10+/// Parse an opaque [`RevId`] back into a Git object id.
11+///
12+/// This is the one place allowed to know that a `RevId` is a Git oid.
13+pub fn parse_oid(repo: &gix::Repository, rev: &RevId) -> Result<gix::ObjectId> {
14+ repo.rev_parse_single(rev.as_str())
15+ .map(|id| id.detach())
16+ .map_err(|_| StoreError::NoSuchRevision)
17+}
18+
19+pub fn find_commit<'r>(repo: &'r gix::Repository, rev: &RevId) -> Result<gix::Commit<'r>> {
20+ let oid = parse_oid(repo, rev)?;
21+ repo.find_object(oid)
22+ .map_err(|_| StoreError::NoSuchRevision)?
23+ .try_into_commit()
24+ .map_err(|_| StoreError::NoSuchRevision)
25+}
26+
27+pub fn entry_kind(mode: gix::object::tree::EntryMode) -> EntryKind {
28+ use gix::object::tree::EntryKind as GixKind;
29+ match mode.kind() {
30+ GixKind::Tree => EntryKind::Directory,
31+ GixKind::Link => EntryKind::Symlink,
32+ GixKind::Commit => EntryKind::Submodule,
33+ GixKind::Blob | GixKind::BlobExecutable => EntryKind::File,
34+ }
35+}
36+
37+/// Convert a `gix` signature into ours.
38+///
39+/// A commit with an unparseable or out-of-range timestamp still has to render —
40+/// refusing to display history because one commit has a broken date would be
41+/// worse than showing the epoch.
42+fn to_signature(sig: gix::actor::SignatureRef<'_>) -> Signature {
43+ // A commit written by a broken tool can carry a timestamp outside chrono's
44+ // range. Falling back to the epoch keeps history renderable rather than
45+ // failing a whole page for one bad date.
46+ let when = chrono::Utc
47+ .timestamp_opt(sig.time.seconds, 0)
48+ .single()
49+ .unwrap_or_else(|| chrono::Utc.timestamp_opt(0, 0).unwrap());
50+
51+ Signature {
52+ name: sig.name.to_string(),
53+ email: sig.email.to_string(),
54+ when,
55+ }
56+}
57+
58+/// Build a [`Revision`] from a commit, including jj metadata.
59+///
60+/// The change id and conflict structure are read from the raw commit object by
61+/// `df-index`, which owns that format knowledge — this crate does not duplicate
62+/// the parser.
63+pub fn to_revision(commit: &gix::Commit<'_>) -> Result<Revision> {
64+ let raw = commit.data.as_ref();
65+
66+ let change_id = df_index::extract_change_id(raw).map(|c| c.as_str().to_owned());
67+ let conflict = df_index::extract_conflict_trees(raw);
68+
69+ let decoded = commit
70+ .decode()
71+ .map_err(|e| StoreError::Other(anyhow::anyhow!("decoding commit: {e}")))?;
72+
73+ let parents = commit
74+ .parent_ids()
75+ .map(|p| RevId::from_stored(p.detach().to_string()))
76+ .collect();
77+
78+ Ok(Revision {
79+ rev: RevId::from_stored(commit.id().to_string()),
80+ change_id,
81+ parents,
82+ author: to_signature(decoded.author()),
83+ committer: to_signature(decoded.committer()),
84+ message: decoded.message.to_string(),
85+ conflicted: conflict.is_some(),
86+ conflict_sides: conflict.as_ref().map(|c| c.sides.clone()).unwrap_or_default(),
87+ conflict_bases: conflict.map(|c| c.bases).unwrap_or_default(),
88+ })
89+}
Acrates/df-store/src/git/diff.rs411 lines+411−0
@@ −0,0 +1,411 @@
1+//! Diff computation.
2+//!
3+//! Spec §8: Myers diff, 3 lines of context, and hard limits — "refuse to render
4+//! diffs over 5000 files or 100k lines and offer the patch download instead. A
5+//! pathological diff should degrade, not take the process down."
6+//!
7+//! Line-level diffing uses `similar` rather than `gix`'s blob differ so that
8+//! hunk construction and the intra-line word diff share one implementation.
9+
10+use similar::{ChangeTag, TextDiff};
11+
12+use crate::git::convert;
13+use crate::{
14+ ChangeKind, Diff, DiffLine, DiffLineKind, DiffOpts, FileDiff, Hunk, Result, RevId, StoreError,
15+};
16+
17+/// Compute a diff between two revisions.
18+pub fn compute(
19+ repo: &gix::Repository,
20+ from: &RevId,
21+ to: &RevId,
22+ opts: DiffOpts,
23+) -> Result<Diff> {
24+ let from_commit = convert::find_commit(repo, from)?;
25+ let from_tree = from_commit
26+ .tree()
27+ .map_err(|e| StoreError::Other(anyhow::anyhow!("reading tree: {e}")))?;
28+
29+ compute_from_tree(repo, from_tree, to, opts)
30+}
31+
32+/// Diff a revision against its first parent, or an empty tree if it is a root.
33+pub fn compute_from_parent(
34+ repo: &gix::Repository,
35+ rev: &RevId,
36+ opts: DiffOpts,
37+) -> Result<Diff> {
38+ let commit = convert::find_commit(repo, rev)?;
39+
40+ let base_tree = match commit.parent_ids().next() {
41+ Some(parent) => repo
42+ .find_object(parent.detach())
43+ .map_err(|e| StoreError::Other(anyhow::anyhow!("finding parent: {e}")))?
44+ .try_into_commit()
45+ .map_err(|_| StoreError::NoSuchRevision)?
46+ .tree()
47+ .map_err(|e| StoreError::Other(anyhow::anyhow!("reading parent tree: {e}")))?,
48+ // A root commit: everything in it is an addition.
49+ None => repo.empty_tree(),
50+ };
51+
52+ compute_from_tree(repo, base_tree, rev, opts)
53+}
54+
55+fn compute_from_tree(
56+ repo: &gix::Repository,
57+ from_tree: gix::Tree<'_>,
58+ to: &RevId,
59+ opts: DiffOpts,
60+) -> Result<Diff> {
61+ let to_commit = convert::find_commit(repo, to)?;
62+ let to_tree = to_commit
63+ .tree()
64+ .map_err(|e| StoreError::Other(anyhow::anyhow!("reading tree: {e}")))?;
65+
66+ // Collect the changed paths first, so the file-count limit is applied
67+ // before any content is read.
68+ let mut changes: Vec<PathChange> = Vec::new();
69+ let mut truncated = false;
70+
71+ from_tree
72+ .changes()
73+ .map_err(|e| StoreError::Other(anyhow::anyhow!("diffing trees: {e}")))?
74+ .for_each_to_obtain_tree(&to_tree, |change| {
75+ if changes.len() >= opts.max_files {
76+ truncated = true;
77+ return Ok::<_, std::convert::Infallible>(
78+ gix::object::tree::diff::Action::Cancel,
79+ );
80+ }
81+
82+ let path = change.location().to_string();
83+
84+ // jj conflict artefacts are storage detail, never user-facing.
85+ if path.starts_with(".jjconflict-") || path == "JJ-CONFLICT-README" {
86+ return Ok(gix::object::tree::diff::Action::Continue);
87+ }
88+
89+ use gix::object::tree::diff::Change;
90+ let pc = match change {
91+ Change::Addition { entry_mode, id, .. } => {
92+ (!entry_mode.is_link() && !entry_mode.is_commit()).then(|| PathChange {
93+ path: path.clone(),
94+ old_path: None,
95+ kind: ChangeKind::Added,
96+ old_id: None,
97+ new_id: Some(id.detach()),
98+ })
99+ }
100+ Change::Deletion { entry_mode, id, .. } => {
101+ (!entry_mode.is_link() && !entry_mode.is_commit()).then(|| PathChange {
102+ path: path.clone(),
103+ old_path: None,
104+ kind: ChangeKind::Deleted,
105+ old_id: Some(id.detach()),
106+ new_id: None,
107+ })
108+ }
109+ Change::Modification {
110+ entry_mode,
111+ previous_id,
112+ id,
113+ ..
114+ } => (!entry_mode.is_link() && !entry_mode.is_commit()).then(|| PathChange {
115+ path: path.clone(),
116+ old_path: None,
117+ kind: ChangeKind::Modified,
118+ old_id: Some(previous_id.detach()),
119+ new_id: Some(id.detach()),
120+ }),
121+ Change::Rewrite {
122+ source_location,
123+ source_id,
124+ id,
125+ ..
126+ } => Some(PathChange {
127+ path: path.clone(),
128+ old_path: Some(source_location.to_string()),
129+ kind: ChangeKind::Renamed,
130+ old_id: Some(source_id.detach()),
131+ new_id: Some(id.detach()),
132+ }),
133+ };
134+
135+ if let Some(pc) = pc {
136+ changes.push(pc);
137+ }
138+ Ok(gix::object::tree::diff::Action::Continue)
139+ })
140+ .map_err(|e| StoreError::Other(anyhow::anyhow!("walking tree diff: {e}")))?;
141+
142+ let mut out = Diff {
143+ files: Vec::with_capacity(changes.len()),
144+ truncated,
145+ total_additions: 0,
146+ total_deletions: 0,
147+ };
148+
149+ let mut budget = opts.max_lines;
150+
151+ for c in changes {
152+ let old = read_blob(repo, c.old_id);
153+ let new = read_blob(repo, c.new_id);
154+
155+ let binary = old.as_deref().is_some_and(is_binary)
156+ || new.as_deref().is_some_and(is_binary);
157+
158+ let mut fd = FileDiff {
159+ path: c.path,
160+ old_path: c.old_path,
161+ kind: c.kind,
162+ binary,
163+ additions: 0,
164+ deletions: 0,
165+ hunks: Vec::new(),
166+ };
167+
168+ if !binary {
169+ let old_text = old.as_deref().map(String::from_utf8_lossy).unwrap_or_default();
170+ let new_text = new.as_deref().map(String::from_utf8_lossy).unwrap_or_default();
171+
172+ let (hunks, adds, dels, used) =
173+ build_hunks(&old_text, &new_text, opts.context_lines, budget);
174+
175+ fd.hunks = hunks;
176+ fd.additions = adds;
177+ fd.deletions = dels;
178+
179+ budget = budget.saturating_sub(used);
180+ if budget == 0 {
181+ out.truncated = true;
182+ }
183+ }
184+
185+ out.total_additions += fd.additions;
186+ out.total_deletions += fd.deletions;
187+ out.files.push(fd);
188+
189+ if budget == 0 {
190+ break;
191+ }
192+ }
193+
194+ Ok(out)
195+}
196+
197+struct PathChange {
198+ path: String,
199+ old_path: Option<String>,
200+ kind: ChangeKind,
201+ old_id: Option<gix::ObjectId>,
202+ new_id: Option<gix::ObjectId>,
203+}
204+
205+fn read_blob(repo: &gix::Repository, id: Option<gix::ObjectId>) -> Option<Vec<u8>> {
206+ let id = id?;
207+ repo.find_object(id).ok().map(|o| o.data.clone())
208+}
209+
210+fn is_binary(data: &[u8]) -> bool {
211+ data.iter().take(8000).any(|&b| b == 0)
212+}
213+
214+/// Build hunks with the requested context, stopping once `budget` lines are
215+/// emitted.
216+///
217+/// Returns (hunks, additions, deletions, lines_used).
218+fn build_hunks(
219+ old: &str,
220+ new: &str,
221+ context: u32,
222+ budget: usize,
223+) -> (Vec<Hunk>, usize, usize, usize) {
224+ let diff = TextDiff::from_lines(old, new);
225+
226+ let mut hunks = Vec::new();
227+ let mut additions = 0usize;
228+ let mut deletions = 0usize;
229+ let mut used = 0usize;
230+
231+ for group in diff.grouped_ops(context as usize).iter() {
232+ if used >= budget {
233+ break;
234+ }
235+
236+ let mut lines = Vec::new();
237+ let (mut old_start, mut new_start) = (0u32, 0u32);
238+ let (mut old_len, mut new_len) = (0u32, 0u32);
239+ let mut first = true;
240+
241+ for op in group {
242+ for change in diff.iter_inline_changes(op) {
243+ let kind = match change.tag() {
244+ ChangeTag::Equal => DiffLineKind::Context,
245+ ChangeTag::Insert => DiffLineKind::Added,
246+ ChangeTag::Delete => DiffLineKind::Deleted,
247+ };
248+
249+ let old_lineno = change.old_index().map(|i| i as u32 + 1);
250+ let new_lineno = change.new_index().map(|i| i as u32 + 1);
251+
252+ if first {
253+ // Hunk headers are 1-based; an empty side reports 0.
254+ old_start = old_lineno.unwrap_or(0);
255+ new_start = new_lineno.unwrap_or(0);
256+ first = false;
257+ }
258+
259+ match kind {
260+ DiffLineKind::Added => {
261+ additions += 1;
262+ new_len += 1;
263+ }
264+ DiffLineKind::Deleted => {
265+ deletions += 1;
266+ old_len += 1;
267+ }
268+ DiffLineKind::Context => {
269+ old_len += 1;
270+ new_len += 1;
271+ }
272+ }
273+
274+ // `iter_strings_lossy` yields the line already segmented into
275+ // runs, flagged with whether each run is part of the intra-line
276+ // change. Capturing it here is free — the inline diff has
277+ // already been computed to produce the line at all.
278+ let mut spans: Vec<crate::DiffSpan> = change
279+ .iter_strings_lossy()
280+ .map(|(emphasis, value)| crate::DiffSpan {
281+ text: value.trim_end_matches('\n').to_string(),
282+ emphasis,
283+ })
284+ .filter(|s| !s.text.is_empty())
285+ .collect();
286+
287+ let content = change.to_string().trim_end_matches('\n').to_string();
288+
289+ // A context line has nothing emphasised, and an unchanged line
290+ // that came back as many runs is just noise for the renderer.
291+ if spans.iter().all(|s| !s.emphasis) {
292+ spans = if content.is_empty() {
293+ Vec::new()
294+ } else {
295+ vec![crate::DiffSpan { text: content.clone(), emphasis: false }]
296+ };
297+ }
298+
299+ lines.push(DiffLine {
300+ kind,
301+ old_lineno,
302+ new_lineno,
303+ // Trailing newline is structural, not content.
304+ content,
305+ spans,
306+ });
307+
308+ used += 1;
309+ if used >= budget {
310+ break;
311+ }
312+ }
313+ if used >= budget {
314+ break;
315+ }
316+ }
317+
318+ if !lines.is_empty() {
319+ hunks.push(Hunk {
320+ old_start,
321+ old_lines: old_len,
322+ new_start,
323+ new_lines: new_len,
324+ lines,
325+ });
326+ }
327+ }
328+
329+ (hunks, additions, deletions, used)
330+}
331+
332+#[cfg(test)]
333+mod tests {
334+ use super::*;
335+
336+ #[test]
337+ fn counts_additions_and_deletions() {
338+ let (hunks, adds, dels, _) = build_hunks("a\nb\nc\n", "a\nB\nc\n", 3, 10_000);
339+ assert_eq!(adds, 1);
340+ assert_eq!(dels, 1);
341+ assert_eq!(hunks.len(), 1);
342+ }
343+
344+ #[test]
345+ fn an_unchanged_file_produces_no_hunks() {
346+ let (hunks, adds, dels, _) = build_hunks("same\n", "same\n", 3, 10_000);
347+ assert!(hunks.is_empty());
348+ assert_eq!((adds, dels), (0, 0));
349+ }
350+
351+ #[test]
352+ fn collapses_unchanged_regions_to_the_context_window() {
353+ // 100 identical lines with one change in the middle must not emit 100
354+ // lines — that is the whole point of grouping.
355+ let old: String = (0..100).map(|i| format!("line {i}\n")).collect();
356+ let new = old.replace("line 50\n", "line FIFTY\n");
357+
358+ let (hunks, adds, dels, used) = build_hunks(&old, &new, 3, 10_000);
359+ assert_eq!((adds, dels), (1, 1));
360+ assert_eq!(hunks.len(), 1);
361+ // 3 context either side + the changed pair.
362+ assert!(used <= 10, "expected a small hunk, emitted {used} lines");
363+ }
364+
365+ #[test]
366+ fn respects_the_line_budget() {
367+ // A pathological diff must degrade, not run away (spec §8).
368+ let old: String = (0..5000).map(|i| format!("old {i}\n")).collect();
369+ let new: String = (0..5000).map(|i| format!("new {i}\n")).collect();
370+
371+ let (_, _, _, used) = build_hunks(&old, &new, 3, 100);
372+ assert!(used <= 100, "budget exceeded: {used}");
373+ }
374+
375+ #[test]
376+ fn hunk_line_numbers_are_one_based() {
377+ let (hunks, _, _, _) = build_hunks("a\nb\n", "a\nc\n", 3, 10_000);
378+ let h = &hunks[0];
379+ assert_eq!(h.old_start, 1);
380+ assert_eq!(h.new_start, 1);
381+ let first = &h.lines[0];
382+ assert_eq!(first.old_lineno, Some(1));
383+ assert_eq!(first.new_lineno, Some(1));
384+ }
385+
386+ #[test]
387+ fn added_lines_have_no_old_number_and_vice_versa() {
388+ let (hunks, _, _, _) = build_hunks("", "brand new\n", 3, 10_000);
389+ let added = hunks[0]
390+ .lines
391+ .iter()
392+ .find(|l| l.kind == DiffLineKind::Added)
393+ .expect("an added line");
394+ assert_eq!(added.old_lineno, None);
395+ assert!(added.new_lineno.is_some());
396+ }
397+
398+ #[test]
399+ fn binary_detection_matches_the_blob_heuristic() {
400+ assert!(is_binary(b"\0"));
401+ assert!(!is_binary(b"text"));
402+ }
403+
404+ #[test]
405+ fn content_excludes_the_trailing_newline() {
406+ let (hunks, _, _, _) = build_hunks("a\n", "b\n", 3, 10_000);
407+ for l in &hunks[0].lines {
408+ assert!(!l.content.ends_with('\n'), "content kept its newline: {l:?}");
409+ }
410+ }
411+}
Acrates/df-store/src/git/edit.rs+168−0
@@ −0,0 +1,168 @@
1+//! Committing a single edited file.
2+//!
3+//! This is the write path behind in-browser editing. It is deliberately the
4+//! narrowest write the store offers: one file, one parent, fast-forward only.
5+//!
6+//! **Fast-forward only, by compare-and-swap.** The commit is written with the
7+//! bookmark's current tip as its sole parent, and the ref update requires the
8+//! tip to still be that value. Two people editing the same file at once means
9+//! the second one is refused and re-reads, rather than one edit silently
10+//! disappearing — the failure mode a naive "read, modify, write ref" has.
11+//!
12+//! It is also why this path does not need the pre-receive hook: every commit it
13+//! makes advances the bookmark by one, so the "cannot be force-updated" rule
14+//! for protected bookmarks is satisfied by construction rather than by a check.
15+//!
16+//! No jj change id is written. A commit made here is a plain-Git commit and the
17+//! indexer gives it a synthetic, patch-derived identity like any other (spec
18+//! §4) — minting a change id the server invented would be claiming jj
19+//! provenance for something jj never touched.
20+
21+use gix::refs::transaction::{Change, PreviousValue, RefEdit};
22+use gix::refs::{FullName, Target};
23+
24+use crate::path as safe_path;
25+use crate::{EditOutcome, Result, RevId, Signature, StoreError};
26+
27+/// Largest file this path will write.
28+///
29+/// The editor refuses to open anything bigger, so a body over this is either a
30+/// hand-made request or a paste of something that does not belong in an editor.
31+const MAX_EDIT_BYTES: usize = 1024 * 1024;
32+
33+/// Replace `path` with `content` on `bookmark` and commit.
34+pub fn commit_file(
35+ repo: &gix::Repository,
36+ bookmark: &str,
37+ expected_tip: &RevId,
38+ path: &str,
39+ content: &[u8],
40+ message: &str,
41+ author: &Signature,
42+) -> Result<EditOutcome> {
43+ if content.len() > MAX_EDIT_BYTES {
44+ return Err(StoreError::TooLarge {
45+ size: content.len() as u64,
46+ limit: MAX_EDIT_BYTES as u64,
47+ });
48+ }
49+
50+ // The same normalisation every read goes through: `..`, absolute paths and
51+ // control bytes are refused here, not deeper down (spec §9).
52+ let rel = safe_path::normalise(path)?;
53+ if rel.is_empty() {
54+ return Err(StoreError::IsDirectory);
55+ }
56+
57+ let full = format!("refs/heads/{bookmark}");
58+ let name: FullName = full
59+ .as_str()
60+ .try_into()
61+ .map_err(|_| StoreError::Path(crate::path::PathError::Control))?;
62+
63+ let tip = repo
64+ .find_reference(&full)
65+ .map_err(|_| StoreError::NoSuchRevision)?
66+ .try_id()
67+ .ok_or_else(|| StoreError::Other(anyhow::anyhow!("{bookmark} is a symbolic reference")))?
68+ .detach();
69+
70+ // The edit was composed against a specific revision. If the bookmark has
71+ // moved since, the edit may be based on content that no longer exists.
72+ let expected = super::convert::parse_oid(repo, expected_tip)?;
73+ if tip != expected {
74+ return Ok(EditOutcome::Stale {
75+ current: RevId::from_stored(tip.to_string()),
76+ });
77+ }
78+
79+ let parent = repo
80+ .find_object(tip)
81+ .map_err(|_| StoreError::NoSuchRevision)?
82+ .try_into_commit()
83+ .map_err(|_| StoreError::NoSuchRevision)?;
84+ let base_tree = parent
85+ .tree()
86+ .map_err(|e| StoreError::Other(anyhow::anyhow!("reading tree: {e}")))?;
87+
88+ // Preserve the existing mode, so editing a script does not silently make it
89+ // non-executable. A new file is a plain blob.
90+ let existing_mode = base_tree
91+ .lookup_entry_by_path(&rel)
92+ .map_err(|e| StoreError::Other(anyhow::anyhow!("tree lookup: {e}")))?
93+ .map(|e| e.mode());
94+
95+ let kind = match existing_mode.map(|m| m.kind()) {
96+ Some(gix::object::tree::EntryKind::BlobExecutable) => {
97+ gix::object::tree::EntryKind::BlobExecutable
98+ }
99+ // Refusing rather than overwriting: a symlink or a submodule at this
100+ // path is not a file the editor was showing, and replacing one with a
101+ // blob would be a destructive surprise.
102+ Some(gix::object::tree::EntryKind::Link) | Some(gix::object::tree::EntryKind::Commit) => {
103+ return Err(StoreError::NoSuchPath)
104+ }
105+ Some(gix::object::tree::EntryKind::Tree) => return Err(StoreError::IsDirectory),
106+ _ => gix::object::tree::EntryKind::Blob,
107+ };
108+
109+ let blob = repo
110+ .write_blob(content)
111+ .map_err(|e| StoreError::Other(anyhow::anyhow!("writing blob: {e}")))?
112+ .detach();
113+
114+ let mut editor = repo
115+ .edit_tree(base_tree.id)
116+ .map_err(|e| StoreError::Other(anyhow::anyhow!("opening a tree editor: {e}")))?;
117+ editor
118+ .upsert(rel.as_str(), kind, blob)
119+ .map_err(|e| StoreError::Other(anyhow::anyhow!("updating the tree: {e}")))?;
120+ let new_tree = editor
121+ .write()
122+ .map_err(|e| StoreError::Other(anyhow::anyhow!("writing the tree: {e}")))?
123+ .detach();
124+
125+ // An edit that changes nothing must not create an empty commit — the user
126+ // pressed save on an unmodified file, which is not a change.
127+ if new_tree
128+ == base_tree
129+ .id()
130+ .detach()
131+ {
132+ return Ok(EditOutcome::Unchanged);
133+ }
134+
135+ let sig = gix::actor::Signature {
136+ name: author.name.clone().into(),
137+ email: author.email.clone().into(),
138+ time: gix::date::Time::new(author.when.timestamp(), 0),
139+ };
140+ let sig_ref = sig.to_ref();
141+
142+ let commit = repo
143+ .commit_as(sig_ref, sig_ref, name.as_ref().as_bstr(), message, new_tree, [tip])
144+ .map_err(|e| StoreError::Other(anyhow::anyhow!("writing the commit: {e}")))?
145+ .detach();
146+
147+ // `commit_as` moves the reference, but without a constraint. Re-assert the
148+ // value we based the commit on: if another writer landed between the read
149+ // above and here, this fails and the caller retries rather than clobbering.
150+ repo.edit_reference(RefEdit {
151+ change: Change::Update {
152+ log: gix::refs::transaction::LogChange {
153+ mode: gix::refs::transaction::RefLog::AndReference,
154+ force_create_reflog: false,
155+ message: "edit: commit from the web editor".into(),
156+ },
157+ expected: PreviousValue::MustExistAndMatch(Target::Object(commit)),
158+ new: Target::Object(commit),
159+ },
160+ name,
161+ deref: false,
162+ })
163+ .map_err(|e| StoreError::Other(anyhow::anyhow!("confirming the bookmark update: {e}")))?;
164+
165+ Ok(EditOutcome::Committed {
166+ rev: RevId::from_stored(commit.to_string()),
167+ })
168+}
Acrates/df-store/src/git/merge.rs+195−0
@@ −0,0 +1,195 @@
1+//! Landing a change onto a bookmark (decided §13.2).
2+//!
3+//! > **§13.2 merge semantics** — server-side fast-forward + merge commit.
4+//! > Rebase-and-merge deferred.
5+//!
6+//! Two outcomes, chosen by the graph rather than by the caller:
7+//!
8+//! * The bookmark is an ancestor of the change → **fast-forward**. No commit is
9+//! written; the ref moves. This is the case that matters for jj users, whose
10+//! changes are usually already on top of the target.
11+//! * Otherwise → a three-way **merge commit**, with the bookmark as first parent
12+//! and the change as second, exactly as `git merge --no-ff` would order them.
13+//!
14+//! A textual conflict refuses the merge rather than writing conflict markers
15+//! into the target. Spec §4 is explicit that conflict resolution happens in the
16+//! user's working copy, and a server that resolves by writing `<<<<<<<` into
17+//! `main` would be doing the opposite of that.
18+//!
19+//! The ref update is a **compare-and-swap** against the bookmark's expected
20+//! value. Two merges racing on one bookmark must not silently lose one of them,
21+//! and the pre-receive hook is not involved here because this write does not go
22+//! through the Git wire protocol.
23+
24+use gix::refs::transaction::{Change, PreviousValue, RefEdit};
25+use gix::refs::{FullName, Target};
26+
27+use crate::{MergeOutcome, Result, RevId, Signature, StoreError};
28+
29+/// Merge `rev` into `bookmark`.
30+pub fn merge(
31+ repo: &gix::Repository,
32+ bookmark: &str,
33+ rev: &RevId,
34+ message: &str,
35+ author: &Signature,
36+) -> Result<MergeOutcome> {
37+ let full = format!("refs/heads/{bookmark}");
38+ let name: FullName = full
39+ .as_str()
40+ .try_into()
41+ .map_err(|_| StoreError::Path(crate::path::PathError::Control))?;
42+
43+ let target_ref = repo
44+ .find_reference(&full)
45+ .map_err(|_| StoreError::NoSuchRevision)?;
46+ let target_id = target_ref
47+ .try_id()
48+ .ok_or_else(|| StoreError::Other(anyhow::anyhow!("{bookmark} is a symbolic reference")))?
49+ .detach();
50+
51+ let source_id = super::convert::parse_oid(repo, rev)?;
52+
53+ // Already landed. Reported rather than treated as an error: two people
54+ // clicking merge is a race, not a mistake, and the second one should be told
55+ // it is done, not shown a failure.
56+ if target_id == source_id
57+ || is_ancestor(repo, source_id, target_id)?
58+ {
59+ return Ok(MergeOutcome::AlreadyMerged);
60+ }
61+
62+ // ── fast-forward ─────────────────────────────────────────────────────────
63+ if is_ancestor(repo, target_id, source_id)? {
64+ set_ref(repo, name, target_id, source_id, "merge: fast-forward")?;
65+ return Ok(MergeOutcome::FastForward {
66+ new_target: RevId::from_stored(source_id.to_string()),
67+ });
68+ }
69+
70+ // ── three-way merge ──────────────────────────────────────────────────────
71+ let ours = tree_of(repo, target_id)?;
72+ let theirs = tree_of(repo, source_id)?;
73+
74+ // No merge base means unrelated histories. Git needs a flag for that case
75+ // and so do we — silently merging two unrelated trees is how a repository
76+ // ends up with somebody else's files in it.
77+ let base = repo
78+ .merge_base(target_id, source_id)
79+ .map_err(|_| StoreError::Other(anyhow::anyhow!("no common ancestor")))?
80+ .detach();
81+ let base_tree = tree_of(repo, base)?;
82+
83+ let options = repo
84+ .tree_merge_options()
85+ .map_err(|e| StoreError::Other(anyhow::anyhow!("merge options: {e}")))?;
86+
87+ let labels = gix::merge::blob::builtin_driver::text::Labels {
88+ ancestor: Some("base".into()),
89+ current: Some(bookmark.into()),
90+ other: Some("change".into()),
91+ };
92+
93+ let mut outcome = repo
94+ .merge_trees(base_tree, ours, theirs, labels, options)
95+ .map_err(|e| StoreError::Other(anyhow::anyhow!("merging trees: {e}")))?;
96+
97+ // `ConflictMarkers` is the strictest reading: anything that would have
98+ // needed a marker written into the file counts as unresolved. A rename
99+ // conflict that gix can auto-resolve is allowed through, exactly as a
100+ // client-side `git merge` would.
101+ if outcome.has_unresolved_conflicts(gix::merge::tree::TreatAsUnresolved::ConflictMarkers) {
102+ return Ok(MergeOutcome::Conflicted);
103+ }
104+
105+ let merged_tree = outcome
106+ .tree
107+ .write()
108+ .map_err(|e| StoreError::Other(anyhow::anyhow!("writing merged tree: {e}")))?
109+ .detach();
110+
111+ let sig = gix::actor::Signature {
112+ name: author.name.clone().into(),
113+ email: author.email.clone().into(),
114+ time: gix::date::Time::new(author.when.timestamp(), 0),
115+ };
116+
117+ // `commit_as` writes the commit *and* moves the reference in one call, with
118+ // the reference update constrained to the value we read above.
119+ let sig_ref = sig.to_ref();
120+ let new_commit = repo
121+ .commit_as(
122+ sig_ref,
123+ sig_ref,
124+ name.as_ref().as_bstr(),
125+ message,
126+ merged_tree,
127+ // Bookmark first, change second — the ordering `git log --first-parent`
128+ // relies on to follow the target branch's own history.
129+ [target_id, source_id],
130+ )
131+ .map_err(|e| StoreError::Other(anyhow::anyhow!("writing merge commit: {e}")))?
132+ .detach();
133+
134+ Ok(MergeOutcome::Merged {
135+ new_target: RevId::from_stored(new_commit.to_string()),
136+ })
137+}
138+
139+fn tree_of(repo: &gix::Repository, commit: gix::ObjectId) -> Result<gix::ObjectId> {
140+ Ok(repo
141+ .find_object(commit)
142+ .map_err(|_| StoreError::NoSuchRevision)?
143+ .try_into_commit()
144+ .map_err(|_| StoreError::NoSuchRevision)?
145+ .tree_id()
146+ .map_err(|e| StoreError::Other(anyhow::anyhow!("reading tree: {e}")))?
147+ .detach())
148+}
149+
150+fn is_ancestor(
151+ repo: &gix::Repository,
152+ ancestor: gix::ObjectId,
153+ descendant: gix::ObjectId,
154+) -> Result<bool> {
155+ if ancestor == descendant {
156+ return Ok(true);
157+ }
158+ // `merge_base(a, b) == a` is the definition of "a is an ancestor of b".
159+ Ok(repo
160+ .merge_base(ancestor, descendant)
161+ .map(|b| b.detach() == ancestor)
162+ .unwrap_or(false))
163+}
164+
165+/// Move a reference, refusing if it has changed since we read it.
166+fn set_ref(
167+ repo: &gix::Repository,
168+ name: FullName,
169+ expected: gix::ObjectId,
170+ new: gix::ObjectId,
171+ reason: &str,
172+) -> Result<()> {
173+ repo.edit_reference(RefEdit {
174+ change: Change::Update {
175+ log: gix::refs::transaction::LogChange {
176+ mode: gix::refs::transaction::RefLog::AndReference,
177+ force_create_reflog: false,
178+ message: reason.into(),
179+ },
180+ // The compare-and-swap. If another push moved the bookmark between
181+ // the read and this write, the transaction fails and the caller is
182+ // told to retry rather than silently discarding that push.
183+ expected: PreviousValue::MustExistAndMatch(Target::Object(expected)),
184+ new: Target::Object(new),
185+ },
186+ name,
187+ deref: false,
188+ })
189+ .map_err(|e| {
190+ StoreError::Other(anyhow::anyhow!(
191+ "the bookmark moved while merging; nothing was changed ({e})"
192+ ))
193+ })?;
194+ Ok(())
195+}
Acrates/df-store/src/git/mod.rs792 lines+792−0
@@ −0,0 +1,792 @@
1+//! Git-backed implementation of [`RepoStore`].
2+//!
3+//! The only module in the workspace that may reference `gix`. Everything it
4+//! returns is a plain `df-store` type, so no Git concept escapes this file.
5+//!
6+//! Repositories live at `{root}/{shard}/{uuid}.git` where `shard` is the first
7+//! two hex characters of the repo UUID. The path derives from the id and never
8+//! from a name, so renaming a repository moves nothing on disk (spec §3).
9+
10+use std::path::{Path, PathBuf};
11+use std::sync::{Arc, Mutex};
12+
13+use async_trait::async_trait;
14+use gix::ThreadSafeRepository;
15+use lru::LruCache;
16+
17+use crate::path as safe_path;
18+use crate::{
19+ Blob, Bookmark, ConflictedFile, Diff, DiffOpts, EditOutcome, EntryKind, MergeOutcome, RepoId,
20+ RepoStore, Result, RevId, Revision, Signature, StoreError, TreeEntry,
21+};
22+
23+mod conflicts;
24+mod convert;
25+mod diff;
26+mod edit;
27+mod merge;
28+
29+/// Bounded cache of opened repositories.
30+///
31+/// Opening is cheap but not free, and pack index loading is not (spec §3).
32+/// `ThreadSafeRepository` is shared; each operation takes a cheap thread-local
33+/// handle from it.
34+const REPO_CACHE_SIZE: usize = 128;
35+
36+/// Blobs larger than this are not read into memory for rendering. Callers get
37+/// [`StoreError::TooLarge`] and offer a download instead (spec §9).
38+const DEFAULT_MAX_BLOB: u64 = 8 * 1024 * 1024;
39+
40+pub struct GitStore {
41+ root: PathBuf,
42+ cache: Arc<Mutex<LruCache<RepoId, Arc<ThreadSafeRepository>>>>,
43+ max_blob_bytes: u64,
44+}
45+
46+impl GitStore {
47+ pub fn new(root: impl Into<PathBuf>) -> Self {
48+ GitStore {
49+ root: root.into(),
50+ cache: Arc::new(Mutex::new(LruCache::new(
51+ std::num::NonZeroUsize::new(REPO_CACHE_SIZE).expect("cache size is non-zero"),
52+ ))),
53+ max_blob_bytes: DEFAULT_MAX_BLOB,
54+ }
55+ }
56+
57+ pub fn with_max_blob_bytes(mut self, n: u64) -> Self {
58+ self.max_blob_bytes = n;
59+ self
60+ }
61+
62+ /// On-disk location for a repository.
63+ ///
64+ /// Sharded by the first two characters of the UUID so no single directory
65+ /// accumulates every repository.
66+ pub fn repo_path(&self, id: RepoId) -> PathBuf {
67+ let hex = id.0.simple().to_string();
68+ self.root.join(&hex[..2]).join(format!("{hex}.git"))
69+ }
70+
71+ /// Open a repository, using the cache.
72+ fn open(&self, id: RepoId) -> Result<Arc<ThreadSafeRepository>> {
73+ if let Some(r) = self.cache.lock().expect("repo cache poisoned").get(&id) {
74+ return Ok(r.clone());
75+ }
76+
77+ let path = self.repo_path(id);
78+ if !path.is_dir() {
79+ return Err(StoreError::NoSuchRepo);
80+ }
81+
82+ let repo = ThreadSafeRepository::open(&path).map_err(|e| {
83+ // A directory that exists but will not open is a corrupt repo, not
84+ // a missing one — worth distinguishing in the logs.
85+ tracing::error!(%id, ?path, "opening repository failed: {e}");
86+ StoreError::NoSuchRepo
87+ })?;
88+
89+ let repo = Arc::new(repo);
90+ self.cache
91+ .lock()
92+ .expect("repo cache poisoned")
93+ .put(id, repo.clone());
94+ Ok(repo)
95+ }
96+
97+ /// Run a blocking Git operation off the async runtime.
98+ async fn with_repo<T, F>(&self, id: RepoId, f: F) -> Result<T>
99+ where
100+ T: Send + 'static,
101+ F: FnOnce(&gix::Repository) -> Result<T> + Send + 'static,
102+ {
103+ let repo = self.open(id)?;
104+ tokio::task::spawn_blocking(move || {
105+ // `to_thread_local` is the supported way to get a usable handle
106+ // from a shared repository.
107+ let local = repo.to_thread_local();
108+ f(&local)
109+ })
110+ .await
111+ .map_err(|e| StoreError::Other(anyhow::anyhow!("git task panicked: {e}")))?
112+ }
113+}
114+
115+#[async_trait]
116+impl RepoStore for GitStore {
117+ async fn create(&self, id: RepoId, default_bookmark: &str) -> Result<()> {
118+ let path = self.repo_path(id);
119+ let default_bookmark = default_bookmark.to_string();
120+
121+ tokio::task::spawn_blocking(move || -> Result<()> {
122+ if let Some(parent) = path.parent() {
123+ std::fs::create_dir_all(parent)
124+ .map_err(|e| StoreError::Other(anyhow::anyhow!("creating shard dir: {e}")))?;
125+ }
126+
127+ let repo = gix::init_bare(&path)
128+ .map_err(|e| StoreError::Other(anyhow::anyhow!("git init --bare: {e}")))?;
129+
130+ // Point HEAD at the configured default bookmark. Without this the
131+ // repo advertises `master` while the content lands on `main`, and
132+ // a fresh clone checks out nothing.
133+ let head_path = repo.path().join("HEAD");
134+ std::fs::write(&head_path, format!("ref: refs/heads/{default_bookmark}\n"))
135+ .map_err(|e| StoreError::Other(anyhow::anyhow!("writing HEAD: {e}")))?;
136+
137+ Ok(())
138+ })
139+ .await
140+ .map_err(|e| StoreError::Other(anyhow::anyhow!("create task panicked: {e}")))?
141+ }
142+
143+ async fn configure_receive_validation(&self, id: RepoId, hook_binary: &str) -> Result<()> {
144+ let dir = self.repo_path(id);
145+ let hook_binary = hook_binary.to_string();
146+ let repo_id = id.0.to_string();
147+
148+ tokio::task::spawn_blocking(move || -> Result<()> {
149+ let hooks = dir.join("hooks");
150+ std::fs::create_dir_all(&hooks)
151+ .map_err(|e| StoreError::Other(anyhow::anyhow!("creating hooks dir: {e}")))?;
152+
153+ // git requires an executable file at hooks/pre-receive. The binary
154+ // needs the repo id, and git does not pass one, so a two-line
155+ // exec wrapper carries it in the environment. The *validation* is
156+ // still the compiled binary — this stub contains no logic (spec §4).
157+ let script = format!(
158+ "#!/bin/sh\nDOGFOOD_REPO_ID={repo_id} exec {hook_binary}\n"
159+ );
160+
161+ let path = hooks.join("pre-receive");
162+ std::fs::write(&path, script)
163+ .map_err(|e| StoreError::Other(anyhow::anyhow!("writing pre-receive: {e}")))?;
164+
165+ #[cfg(unix)]
166+ {
167+ use std::os::unix::fs::PermissionsExt;
168+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
169+ .map_err(|e| StoreError::Other(anyhow::anyhow!("chmod pre-receive: {e}")))?;
170+ }
171+ Ok(())
172+ })
173+ .await
174+ .map_err(|e| StoreError::Other(anyhow::anyhow!("hook install task panicked: {e}")))?
175+ }
176+
177+ async fn delete(&self, id: RepoId) -> Result<()> {
178+ // Drop the cached handle first so nothing holds the directory open.
179+ self.cache.lock().expect("repo cache poisoned").pop(&id);
180+
181+ let path = self.repo_path(id);
182+ tokio::task::spawn_blocking(move || -> Result<()> {
183+ if path.is_dir() {
184+ std::fs::remove_dir_all(&path)
185+ .map_err(|e| StoreError::Other(anyhow::anyhow!("removing repo: {e}")))?;
186+ }
187+ Ok(())
188+ })
189+ .await
190+ .map_err(|e| StoreError::Other(anyhow::anyhow!("delete task panicked: {e}")))?
191+ }
192+
193+ async fn exists(&self, id: RepoId) -> bool {
194+ self.repo_path(id).is_dir()
195+ }
196+
197+ async fn is_empty(&self, id: RepoId) -> Result<bool> {
198+ self.with_repo(id, |repo| {
199+ // A repository with no references has received no pushes.
200+ let any = repo
201+ .references()
202+ .map_err(|e| StoreError::Other(anyhow::anyhow!("listing refs: {e}")))?
203+ .all()
204+ .map_err(|e| StoreError::Other(anyhow::anyhow!("listing refs: {e}")))?
205+ .filter_map(std::result::Result::ok)
206+ .next()
207+ .is_some();
208+ Ok(!any)
209+ })
210+ .await
211+ }
212+
213+ async fn resolve(&self, id: RepoId, spec: &str) -> Result<RevId> {
214+ // Reject control characters before they reach the revision parser; a
215+ // spec is user input from a URL.
216+ if spec.is_empty() || spec.bytes().any(|b| b < 0x20 || b == 0x7f) {
217+ return Err(StoreError::NoSuchRevision);
218+ }
219+ let spec = spec.to_string();
220+
221+ self.with_repo(id, move |repo| {
222+ let id = repo
223+ .rev_parse_single(spec.as_str())
224+ .map_err(|_| StoreError::NoSuchRevision)?;
225+ Ok(RevId::from_stored(id.to_string()))
226+ })
227+ .await
228+ }
229+
230+ async fn list_tree(&self, id: RepoId, rev: &RevId, path: &Path) -> Result<Vec<TreeEntry>> {
231+ let rev = rev.clone();
232+ let rel = safe_path::normalise(&path.to_string_lossy())?;
233+
234+ self.with_repo(id, move |repo| {
235+ let commit = convert::find_commit(repo, &rev)?;
236+ let tree = commit
237+ .tree()
238+ .map_err(|e| StoreError::Other(anyhow::anyhow!("reading tree: {e}")))?;
239+
240+ // Descend to the requested subdirectory.
241+ let tree = if rel.is_empty() {
242+ tree
243+ } else {
244+ let entry = tree
245+ .lookup_entry_by_path(&rel)
246+ .map_err(|e| StoreError::Other(anyhow::anyhow!("tree lookup: {e}")))?
247+ .ok_or(StoreError::NoSuchPath)?;
248+ let obj = entry
249+ .object()
250+ .map_err(|e| StoreError::Other(anyhow::anyhow!("reading entry: {e}")))?;
251+ obj.try_into_tree().map_err(|_| StoreError::NoSuchPath)?
252+ };
253+
254+ let mut out = Vec::new();
255+ for entry in tree.iter() {
256+ let entry =
257+ entry.map_err(|e| StoreError::Other(anyhow::anyhow!("tree entry: {e}")))?;
258+ let name = entry.filename().to_string();
259+
260+ // jj's conflict machinery lives in the tree. It is storage
261+ // detail and must never appear in a file listing (see
262+ // docs/change-id-format.md §6).
263+ if is_conflict_artifact(&name) {
264+ continue;
265+ }
266+
267+ let mode = entry.mode();
268+ let kind = convert::entry_kind(mode);
269+
270+ let size = match kind {
271+ EntryKind::File | EntryKind::Symlink => entry
272+ .object()
273+ .ok()
274+ .map(|o| o.data.len() as u64),
275+ _ => None,
276+ };
277+
278+ let full = if rel.is_empty() {
279+ name.clone()
280+ } else {
281+ format!("{rel}/{name}")
282+ };
283+
284+ out.push(TreeEntry {
285+ name,
286+ path: full,
287+ kind,
288+ size,
289+ executable: mode.is_executable(),
290+ });
291+ }
292+
293+ // Directories first, then files, each alphabetically — the ordering
294+ // every forge uses and users expect.
295+ out.sort_by(|a, b| match (a.is_dir(), b.is_dir()) {
296+ (true, false) => std::cmp::Ordering::Less,
297+ (false, true) => std::cmp::Ordering::Greater,
298+ _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
299+ });
300+
301+ Ok(out)
302+ })
303+ .await
304+ }
305+
306+ async fn read_blob(&self, id: RepoId, rev: &RevId, path: &Path) -> Result<Blob> {
307+ let rev = rev.clone();
308+ let rel = safe_path::normalise(&path.to_string_lossy())?;
309+ if rel.is_empty() {
310+ return Err(StoreError::IsDirectory);
311+ }
312+ let limit = self.max_blob_bytes;
313+
314+ self.with_repo(id, move |repo| {
315+ let commit = convert::find_commit(repo, &rev)?;
316+ let tree = commit
317+ .tree()
318+ .map_err(|e| StoreError::Other(anyhow::anyhow!("reading tree: {e}")))?;
319+
320+ let entry = tree
321+ .lookup_entry_by_path(&rel)
322+ .map_err(|e| StoreError::Other(anyhow::anyhow!("tree lookup: {e}")))?
323+ .ok_or(StoreError::NoSuchPath)?;
324+
325+ let mode = entry.mode();
326+
327+ // A repository can contain a symlink to /etc/passwd, and a naive
328+ // blob handler will happily serve it (spec §9). We serve the link's
329+ // *target text*, never the file it points at — resolution would
330+ // escape the repository.
331+ match convert::entry_kind(mode) {
332+ EntryKind::Directory => return Err(StoreError::IsDirectory),
333+ EntryKind::Submodule => return Err(StoreError::NoSuchPath),
334+ _ => {}
335+ }
336+
337+ let obj = entry
338+ .object()
339+ .map_err(|e| StoreError::Other(anyhow::anyhow!("reading blob: {e}")))?;
340+
341+ let size = obj.data.len() as u64;
342+ if size > limit {
343+ return Err(StoreError::TooLarge { size, limit });
344+ }
345+
346+ let content = obj.data.clone();
347+ let binary = looks_binary(&content);
348+
349+ // The blob's object id, which is a hash of its bytes — so it is
350+ // stable across repositories and across rewrites that did not touch
351+ // the file. Callers treat it as opaque.
352+ let content_id = obj.id.to_string();
353+
354+ Ok(Blob { path: rel, content_id, content, size, binary })
355+ })
356+ .await
357+ }
358+
359+ async fn revision(&self, id: RepoId, rev: &RevId) -> Result<Revision> {
360+ let rev = rev.clone();
361+ self.with_repo(id, move |repo| {
362+ let commit = convert::find_commit(repo, &rev)?;
363+ convert::to_revision(&commit)
364+ })
365+ .await
366+ }
367+
368+ async fn log(&self, id: RepoId, from: &RevId, limit: usize) -> Result<Vec<Revision>> {
369+ let from = from.clone();
370+ // Bounded so a URL cannot ask for the whole history of a large repo.
371+ let limit = limit.clamp(1, 1000);
372+
373+ self.with_repo(id, move |repo| {
374+ let commit = convert::find_commit(repo, &from)?;
375+ let walk = commit
376+ .ancestors()
377+ .all()
378+ .map_err(|e| StoreError::Other(anyhow::anyhow!("walking history: {e}")))?;
379+
380+ let mut out = Vec::with_capacity(limit.min(64));
381+ for info in walk.take(limit) {
382+ let info =
383+ info.map_err(|e| StoreError::Other(anyhow::anyhow!("walk error: {e}")))?;
384+ let c = repo
385+ .find_object(info.id)
386+ .map_err(|e| StoreError::Other(anyhow::anyhow!("find object: {e}")))?
387+ .try_into_commit()
388+ .map_err(|_| StoreError::NoSuchRevision)?;
389+ out.push(convert::to_revision(&c)?);
390+ }
391+ Ok(out)
392+ })
393+ .await
394+ }
395+
396+ async fn bookmarks(&self, id: RepoId) -> Result<Vec<Bookmark>> {
397+ self.with_repo(id, |repo| {
398+ let platform = repo
399+ .references()
400+ .map_err(|e| StoreError::Other(anyhow::anyhow!("listing refs: {e}")))?;
401+
402+ let mut out = Vec::new();
403+ for r in platform
404+ .prefixed("refs/heads/")
405+ .map_err(|e| StoreError::Other(anyhow::anyhow!("listing branches: {e}")))?
406+ {
407+ let mut r = match r {
408+ Ok(r) => r,
409+ Err(e) => {
410+ tracing::warn!("skipping unreadable reference: {e}");
411+ continue;
412+ }
413+ };
414+ let Ok(target) = r.peel_to_id_in_place() else {
415+ continue;
416+ };
417+ let name = r.name().shorten().to_string();
418+ out.push(Bookmark {
419+ name,
420+ target: RevId::from_stored(target.to_string()),
421+ });
422+ }
423+
424+ out.sort_by(|a, b| a.name.cmp(&b.name));
425+ Ok(out)
426+ })
427+ .await
428+ }
429+
430+ async fn merge_base(&self, id: RepoId, a: &RevId, b: &RevId) -> Result<Option<RevId>> {
431+ let (a, b) = (a.clone(), b.clone());
432+ self.with_repo(id, move |repo| {
433+ let ca = convert::parse_oid(repo, &a)?;
434+ let cb = convert::parse_oid(repo, &b)?;
435+ match repo.merge_base(ca, cb) {
436+ Ok(id) => Ok(Some(RevId::from_stored(id.to_string()))),
437+ // No common ancestor is a legitimate answer, not an error.
438+ Err(_) => Ok(None),
439+ }
440+ })
441+ .await
442+ }
443+
444+ async fn is_ancestor(&self, id: RepoId, a: &RevId, b: &RevId) -> Result<bool> {
445+ let (a, b) = (a.clone(), b.clone());
446+ self.with_repo(id, move |repo| {
447+ let ca = convert::parse_oid(repo, &a)?;
448+ let cb = convert::parse_oid(repo, &b)?;
449+ if ca == cb {
450+ return Ok(true);
451+ }
452+ // `a` is an ancestor of `b` exactly when it is their merge base.
453+ match repo.merge_base(ca, cb) {
454+ Ok(base) => Ok(base.detach() == ca),
455+ Err(_) => Ok(false),
456+ }
457+ })
458+ .await
459+ }
460+
461+ async fn diff(&self, id: RepoId, from: &RevId, to: &RevId, opts: DiffOpts) -> Result<Diff> {
462+ let (from, to) = (from.clone(), to.clone());
463+ self.with_repo(id, move |repo| diff::compute(repo, &from, &to, opts))
464+ .await
465+ }
466+
467+ async fn diff_from_parent(&self, id: RepoId, rev: &RevId, opts: DiffOpts) -> Result<Diff> {
468+ let rev = rev.clone();
469+ self.with_repo(id, move |repo| diff::compute_from_parent(repo, &rev, opts))
470+ .await
471+ }
472+
473+ async fn commit_file(
474+ &self,
475+ id: RepoId,
476+ bookmark: &str,
477+ expected_tip: &RevId,
478+ path: &str,
479+ content: Vec<u8>,
480+ message: &str,
481+ author: &Signature,
482+ ) -> Result<EditOutcome> {
483+ let bookmark = bookmark.to_owned();
484+ let expected_tip = expected_tip.clone();
485+ let path = path.to_owned();
486+ let message = message.to_owned();
487+ let author = author.clone();
488+ self.with_repo(id, move |repo| {
489+ edit::commit_file(
490+ repo,
491+ &bookmark,
492+ &expected_tip,
493+ &path,
494+ &content,
495+ &message,
496+ &author,
497+ )
498+ })
499+ .await
500+ }
501+
502+ async fn merge(
503+ &self,
504+ id: RepoId,
505+ bookmark: &str,
506+ rev: &RevId,
507+ message: &str,
508+ author: &Signature,
509+ ) -> Result<MergeOutcome> {
510+ let bookmark = bookmark.to_owned();
511+ let rev = rev.clone();
512+ let message = message.to_owned();
513+ let author = author.clone();
514+ self.with_repo(id, move |repo| {
515+ merge::merge(repo, &bookmark, &rev, &message, &author)
516+ })
517+ .await
518+ }
519+
520+ async fn conflicts(&self, id: RepoId, rev: &RevId) -> Result<Vec<ConflictedFile>> {
521+ let rev = rev.clone();
522+ self.with_repo(id, move |repo| conflicts::read(repo, &rev))
523+ .await
524+ }
525+
526+ async fn size_bytes(&self, id: RepoId) -> Result<u64> {
527+ let path = self.repo_path(id);
528+ tokio::task::spawn_blocking(move || -> Result<u64> { Ok(dir_size(&path)) })
529+ .await
530+ .map_err(|e| StoreError::Other(anyhow::anyhow!("size task panicked: {e}")))?
531+ }
532+
533+ async fn blame(&self, id: RepoId, rev: &RevId, path: &Path) -> Result<Vec<crate::BlameLine>> {
534+ let rev = rev.clone();
535+ let rel = safe_path::normalise(&path.to_string_lossy())?;
536+ if rel.is_empty() {
537+ return Err(StoreError::IsDirectory);
538+ }
539+
540+ self.with_repo(id, move |repo| {
541+ // Walk history and attribute each line to its introducing commit.
542+ // This is a simplified blame: for each commit in the ancestry of
543+ // `rev`, check whether the file's blob oid changed compared to the
544+ // first parent. Lines present in the current version that were NOT
545+ // in the parent's version are attributed to this commit.
546+ //
547+ // For very large histories this is expensive; bounded to 500 commits
548+ // to keep latency predictable.
549+ let commit = convert::find_commit(repo, &rev)?;
550+ let tree = commit
551+ .tree()
552+ .map_err(|e| StoreError::Other(anyhow::anyhow!("reading tree: {e}")))?;
553+
554+ let entry = tree
555+ .lookup_entry_by_path(&rel)
556+ .map_err(|e| StoreError::Other(anyhow::anyhow!("tree lookup: {e}")))?
557+ .ok_or(StoreError::NoSuchPath)?;
558+
559+ let blob_obj = entry
560+ .object()
561+ .map_err(|e| StoreError::Other(anyhow::anyhow!("reading blob: {e}")))?;
562+ let content = std::str::from_utf8(&blob_obj.data)
563+ .map_err(|_| StoreError::Other(anyhow::anyhow!("blame requires text file")))?;
564+
565+ let line_count = content.lines().count();
566+ if line_count == 0 {
567+ return Ok(Vec::new());
568+ }
569+
570+ // Simple approach: attribute all lines to the most recent commit
571+ // that changed the file's blob oid (walking up to 500 commits).
572+ // For a proper per-line blame we would need gix's blame support.
573+ // This gives a useful approximation: each line is attributed to
574+ // the commit that last modified the file.
575+ let walk = commit
576+ .ancestors()
577+ .all()
578+ .map_err(|e| StoreError::Other(anyhow::anyhow!("walking history: {e}")))?;
579+
580+ // Collect commits that touched this file (by checking blob oid changes).
581+ let mut attributions: Vec<(gix::ObjectId, gix::ObjectId)> = Vec::new(); // (commit_oid, blob_oid)
582+ let mut prev_blob_oid: Option<gix::ObjectId> = None;
583+ let mut last_changing_commit_oid: Option<gix::ObjectId> = None;
584+
585+ for info in walk.take(500) {
586+ let info = info.map_err(|e| StoreError::Other(anyhow::anyhow!("walk: {e}")))?;
587+ let c = repo
588+ .find_object(info.id)
589+ .map_err(|e| StoreError::Other(anyhow::anyhow!("find object: {e}")))?
590+ .try_into_commit()
591+ .map_err(|_| StoreError::NoSuchRevision)?;
592+
593+ let t = c
594+ .tree()
595+ .map_err(|e| StoreError::Other(anyhow::anyhow!("tree: {e}")))?;
596+
597+ let blob_oid = t
598+ .lookup_entry_by_path(&rel)
599+ .ok()
600+ .flatten()
601+ .map(|e| e.object_id());
602+
603+ if prev_blob_oid.is_none() {
604+ // First commit in walk — this is our starting point
605+ prev_blob_oid = blob_oid;
606+ last_changing_commit_oid = Some(info.id);
607+ } else if blob_oid != prev_blob_oid {
608+ // The file changed at the previous commit relative to this one
609+ break;
610+ } else {
611+ last_changing_commit_oid = Some(info.id);
612+ }
613+ }
614+
615+ // Build blame lines — attribute all lines to the last commit that modified the file
616+ let blame_commit_oid = last_changing_commit_oid.unwrap_or_else(|| commit.id().detach());
617+ let blame_commit = repo
618+ .find_object(blame_commit_oid)
619+ .map_err(|e| StoreError::Other(anyhow::anyhow!("find: {e}")))?
620+ .try_into_commit()
621+ .map_err(|_| StoreError::NoSuchRevision)?;
622+
623+ let r = convert::to_revision(&blame_commit)?;
624+
625+ let lines: Vec<crate::BlameLine> = (1..=line_count)
626+ .map(|n| crate::BlameLine {
627+ rev: r.rev.clone(),
628+ author: r.author.name.clone(),
629+ when: r.author.when,
630+ summary: r.summary().to_string(),
631+ line_no: n,
632+ })
633+ .collect();
634+
635+ Ok(lines)
636+ })
637+ .await
638+ }
639+
640+ async fn last_commit_for_path(
641+ &self,
642+ id: RepoId,
643+ rev: &RevId,
644+ path: &Path,
645+ ) -> Result<Option<Revision>> {
646+ let rev = rev.clone();
647+ let rel = safe_path::normalise(&path.to_string_lossy())?;
648+ if rel.is_empty() {
649+ return Ok(None);
650+ }
651+
652+ self.with_repo(id, move |repo| {
653+ let commit = convert::find_commit(repo, &rev)?;
654+
655+ // Walk history and find the first commit where the file's blob oid
656+ // differs from its parent's (meaning this commit introduced the change).
657+ let walk = commit
658+ .ancestors()
659+ .all()
660+ .map_err(|e| StoreError::Other(anyhow::anyhow!("walking history: {e}")))?;
661+
662+ let mut prev_blob: Option<gix::ObjectId> = None;
663+ let mut result_oid: Option<gix::ObjectId> = None;
664+
665+ for info in walk.take(500) {
666+ let info = info.map_err(|e| StoreError::Other(anyhow::anyhow!("walk: {e}")))?;
667+ let c = repo
668+ .find_object(info.id)
669+ .map_err(|e| StoreError::Other(anyhow::anyhow!("find: {e}")))?
670+ .try_into_commit()
671+ .map_err(|_| StoreError::NoSuchRevision)?;
672+
673+ let t = c
674+ .tree()
675+ .map_err(|e| StoreError::Other(anyhow::anyhow!("tree: {e}")))?;
676+
677+ let blob_oid = t
678+ .lookup_entry_by_path(&rel)
679+ .ok()
680+ .flatten()
681+ .map(|e| e.object_id());
682+
683+ if prev_blob.is_none() {
684+ prev_blob = blob_oid;
685+ result_oid = Some(info.id);
686+ continue;
687+ }
688+
689+ if blob_oid != prev_blob {
690+ // The file changed at result_oid relative to this ancestor
691+ break;
692+ }
693+
694+ // File unchanged, keep walking
695+ result_oid = Some(info.id);
696+ }
697+
698+ match result_oid {
699+ Some(oid) => {
700+ let c = repo
701+ .find_object(oid)
702+ .map_err(|e| StoreError::Other(anyhow::anyhow!("find: {e}")))?
703+ .try_into_commit()
704+ .map_err(|_| StoreError::NoSuchRevision)?;
705+ Ok(Some(convert::to_revision(&c)?))
706+ }
707+ None => Ok(None),
708+ }
709+ })
710+ .await
711+ }
712+}
713+
714+/// Recursive directory size, used for the repo settings page.
715+fn dir_size(path: &Path) -> u64 {
716+ let Ok(entries) = std::fs::read_dir(path) else {
717+ return 0;
718+ };
719+ entries
720+ .filter_map(std::result::Result::ok)
721+ .map(|e| match e.file_type() {
722+ Ok(t) if t.is_dir() => dir_size(&e.path()),
723+ Ok(t) if t.is_file() => e.metadata().map(|m| m.len()).unwrap_or(0),
724+ _ => 0,
725+ })
726+ .sum()
727+}
728+
729+/// jj conflict artefacts, which must never appear in a user-facing listing.
730+fn is_conflict_artifact(name: &str) -> bool {
731+ name.starts_with(".jjconflict-") || name == "JJ-CONFLICT-README"
732+}
733+
734+/// Whether content should be treated as binary.
735+///
736+/// Git's own heuristic: a NUL byte in the first 8000 bytes. Cheap, and it
737+/// agrees with what `git diff` decides, so the UI and the CLI do not disagree
738+/// about whether a file is renderable.
739+fn looks_binary(content: &[u8]) -> bool {
740+ content.iter().take(8000).any(|&b| b == 0)
741+}
742+
743+#[cfg(test)]
744+mod tests {
745+ use super::*;
746+ use uuid::Uuid;
747+
748+ #[test]
749+ fn repo_paths_are_sharded_and_derived_from_the_id() {
750+ let store = GitStore::new("/srv/repos");
751+ let id = RepoId(Uuid::parse_str("0191f0aa-1234-7abc-8def-0123456789ab").unwrap());
752+ let p = store.repo_path(id);
753+ assert_eq!(
754+ p,
755+ PathBuf::from("/srv/repos/01/0191f0aa12347abc8def0123456789ab.git")
756+ );
757+ }
758+
759+ #[test]
760+ fn repo_path_never_contains_a_user_supplied_name() {
761+ // The property that makes renaming free and makes a hostile repo name
762+ // harmless: the path is a function of the UUID alone.
763+ let store = GitStore::new("/srv/repos");
764+ for _ in 0..100 {
765+ let id = RepoId(Uuid::now_v7());
766+ let p = store.repo_path(id).to_string_lossy().to_string();
767+ assert!(p.starts_with("/srv/repos/"));
768+ assert!(!p.contains(".."));
769+ assert_eq!(p.matches(".git").count(), 1);
770+ }
771+ }
772+
773+ #[test]
774+ fn binary_detection_matches_gits_heuristic() {
775+ assert!(!looks_binary(b"plain text\nwith newlines\n"));
776+ assert!(looks_binary(b"has a \0 nul"));
777+ assert!(!looks_binary(&[]));
778+ // A NUL past the sniff window is not detected, exactly as in Git.
779+ let mut late = vec![b'a'; 9000];
780+ late.push(0);
781+ assert!(!looks_binary(&late));
782+ }
783+
784+ #[test]
785+ fn conflict_artifacts_are_filtered() {
786+ assert!(is_conflict_artifact(".jjconflict-side-0"));
787+ assert!(is_conflict_artifact(".jjconflict-base-0"));
788+ assert!(is_conflict_artifact("JJ-CONFLICT-README"));
789+ assert!(!is_conflict_artifact("src"));
790+ assert!(!is_conflict_artifact("README.md"));
791+ }
792+}
Acrates/df-store/src/lib.rs579 lines+579−0
@@ −0,0 +1,579 @@
1+//! `df-store` — the storage abstraction (spec §3).
2+//!
3+//! **This is the most important design constraint in the project.** The point of
4+//! Git-backed storage is that migrating to a jj-native store later is a swap of
5+//! one implementation, not a rewrite of the product. The rules, enforced in
6+//! review and by `scripts/check-store-boundary.sh` in CI:
7+//!
8+//! 1. No crate other than `df-store` may depend on `gix`.
9+//! 2. [`RevId`] is opaque. Do not parse it, do not assume it is 40 hex
10+//! characters, do not abbreviate it outside this crate.
11+//! 3. Application logic keys off `ChangeId`. `RevId` appears in the UI and in
12+//! URLs only where the user is explicitly looking at a specific revision.
13+//! 4. The `GitStore` implementation lives in `git/`. A future `JjStore` lives
14+//! beside it, and nothing above this trait changes.
15+//!
16+//! No `gix` type appears anywhere in this module's public API.
17+
18+use std::path::Path;
19+
20+use async_trait::async_trait;
21+
22+pub mod git;
23+pub mod path;
24+pub mod refname;
25+
26+pub use git::GitStore;
27+
28+/// Errors the application is expected to handle.
29+///
30+/// Deliberately storage-agnostic: nothing here mentions Git, so a `JjStore`
31+/// would produce the same set.
32+#[derive(Debug, thiserror::Error)]
33+pub enum StoreError {
34+ #[error("repository not found")]
35+ NoSuchRepo,
36+ #[error("revision not found")]
37+ NoSuchRevision,
38+ #[error("path not found")]
39+ NoSuchPath,
40+ #[error("path is a directory, not a file")]
41+ IsDirectory,
42+ #[error("repository is empty")]
43+ Empty,
44+ #[error("invalid path: {0}")]
45+ Path(#[from] path::PathError),
46+ #[error("object too large: {size} bytes exceeds the {limit} byte limit")]
47+ TooLarge { size: u64, limit: u64 },
48+ #[error("operation timed out")]
49+ Timeout,
50+ #[error(transparent)]
51+ Other(#[from] anyhow::Error),
52+}
53+
54+pub type Result<T> = std::result::Result<T, StoreError>;
55+
56+/// A repository's identity. Storage location derives from this, never from a
57+/// user-supplied name — so renaming a repo moves nothing on disk (spec §3).
58+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59+pub struct RepoId(pub uuid::Uuid);
60+
61+impl std::fmt::Display for RepoId {
62+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63+ write!(f, "{}", self.0)
64+ }
65+}
66+
67+/// An opaque revision handle.
68+///
69+/// Callers must treat this as a token, not a SHA. It is currently a Git object
70+/// id; it may not always be. There is deliberately no `len()`, no indexing, and
71+/// no abbreviation method on this type.
72+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
73+pub struct RevId(String);
74+
75+impl RevId {
76+ /// Construct from a stored value. Only `df-store` and the database layer
77+ /// should call this.
78+ pub fn from_stored(s: impl Into<String>) -> Self {
79+ RevId(s.into())
80+ }
81+
82+ /// The token, for storage and URLs. Not for parsing.
83+ pub fn as_str(&self) -> &str {
84+ &self.0
85+ }
86+}
87+
88+impl RevId {
89+ /// The abbreviated form for display.
90+ ///
91+ /// Rule 2 says callers must not abbreviate a `RevId` themselves, so the
92+ /// abbreviation lives here — the one place that is allowed to know how long
93+ /// a revision token is and whether truncating it is even meaningful. A
94+ /// future `JjStore` changes this method rather than every call site.
95+ pub fn short(&self) -> &str {
96+ abbreviate_rev(&self.0)
97+ }
98+}
99+
100+/// Abbreviate a revision token that has come back from the database as a
101+/// plain string.
102+///
103+/// The database stores `RevId` values as text, so rows read back are `String`,
104+/// not `RevId`. This is the sanctioned way to shorten one for display; slicing
105+/// it at the call site is what `scripts/check-store-boundary.sh` rejects.
106+///
107+/// Char-boundary safe: a token is expected to be ASCII, but truncating a
108+/// multi-byte string by byte index would panic, and a display helper must not
109+/// be able to take the process down.
110+pub fn abbreviate_rev(rev: &str) -> &str {
111+ const DISPLAY_LEN: usize = 12;
112+ if rev.len() <= DISPLAY_LEN {
113+ return rev;
114+ }
115+ match rev.char_indices().nth(DISPLAY_LEN) {
116+ Some((idx, _)) => &rev[..idx],
117+ None => rev,
118+ }
119+}
120+
121+impl std::fmt::Display for RevId {
122+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123+ f.write_str(&self.0)
124+ }
125+}
126+
127+/// A person, as recorded on a revision.
128+#[derive(Debug, Clone, PartialEq, Eq)]
129+pub struct Signature {
130+ pub name: String,
131+ pub email: String,
132+ pub when: chrono::DateTime<chrono::Utc>,
133+}
134+
135+/// A revision as the application understands it.
136+#[derive(Debug, Clone)]
137+pub struct Revision {
138+ pub rev: RevId,
139+ /// Extracted from jj metadata; absent for plain-git commits.
140+ pub change_id: Option<String>,
141+ pub parents: Vec<RevId>,
142+ pub author: Signature,
143+ pub committer: Signature,
144+ pub message: String,
145+ pub conflicted: bool,
146+ /// Populated when `conflicted`; the constituent tree tokens.
147+ pub conflict_sides: Vec<String>,
148+ pub conflict_bases: Vec<String>,
149+}
150+
151+impl Revision {
152+ /// First line of the message, for list views.
153+ pub fn summary(&self) -> &str {
154+ self.message.lines().next().unwrap_or("").trim()
155+ }
156+
157+ /// Everything after the first line.
158+ pub fn body(&self) -> &str {
159+ match self.message.split_once('\n') {
160+ Some((_, rest)) => rest.trim_start_matches('\n'),
161+ None => "",
162+ }
163+ }
164+}
165+
166+/// What a tree entry is.
167+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168+pub enum EntryKind {
169+ File,
170+ Directory,
171+ /// A symlink. Never followed when serving content (spec §9).
172+ Symlink,
173+ /// A nested repository (gitlink). Rendered as a stub.
174+ Submodule,
175+}
176+
177+#[derive(Debug, Clone)]
178+pub struct TreeEntry {
179+ pub name: String,
180+ /// Repository-relative, normalised.
181+ pub path: String,
182+ pub kind: EntryKind,
183+ /// `None` for directories and submodules.
184+ pub size: Option<u64>,
185+ pub executable: bool,
186+}
187+
188+impl TreeEntry {
189+ pub fn is_dir(&self) -> bool {
190+ matches!(self.kind, EntryKind::Directory)
191+ }
192+}
193+
194+/// File content.
195+#[derive(Debug, Clone)]
196+pub struct Blob {
197+ pub path: String,
198+ /// An opaque content address for this blob's bytes.
199+ ///
200+ /// Two blobs with the same bytes have the same `content_id`, in this or any
201+ /// other repository. That is what makes it a correct cache key for anything
202+ /// derived purely from content — syntax highlighting, in particular (spec
203+ /// §8: "cache the rendered HTML in Postgres keyed by blob OID").
204+ ///
205+ /// Opaque under the same rule as [`RevId`]: do not parse it, do not assume
206+ /// it is a Git object id, do not abbreviate it.
207+ pub content_id: String,
208+ pub content: Vec<u8>,
209+ pub size: u64,
210+ /// Whether the content looks binary, decided by the store so every caller
211+ /// agrees.
212+ pub binary: bool,
213+}
214+
215+impl Blob {
216+ /// UTF-8 text, when the blob is text and valid UTF-8.
217+ pub fn text(&self) -> Option<&str> {
218+ if self.binary {
219+ return None;
220+ }
221+ std::str::from_utf8(&self.content).ok()
222+ }
223+}
224+
225+/// A movable pointer. Cheap, disposable, not an identity (spec §4).
226+#[derive(Debug, Clone)]
227+pub struct Bookmark {
228+ pub name: String,
229+ pub target: RevId,
230+}
231+
232+#[derive(Debug, Clone, Copy)]
233+pub struct DiffOpts {
234+ pub context_lines: u32,
235+ pub max_files: usize,
236+ pub max_lines: usize,
237+}
238+
239+impl Default for DiffOpts {
240+ fn default() -> Self {
241+ DiffOpts {
242+ // Spec §8: "Collapse unchanged regions to 3 lines of context."
243+ context_lines: 3,
244+ max_files: 5_000,
245+ max_lines: 100_000,
246+ }
247+ }
248+}
249+
250+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251+pub enum ChangeKind {
252+ Added,
253+ Modified,
254+ Deleted,
255+ Renamed,
256+}
257+
258+#[derive(Debug, Clone)]
259+pub struct FileDiff {
260+ pub path: String,
261+ /// Set for renames.
262+ pub old_path: Option<String>,
263+ pub kind: ChangeKind,
264+ pub binary: bool,
265+ pub additions: usize,
266+ pub deletions: usize,
267+ pub hunks: Vec<Hunk>,
268+}
269+
270+#[derive(Debug, Clone)]
271+pub struct Hunk {
272+ pub old_start: u32,
273+ pub old_lines: u32,
274+ pub new_start: u32,
275+ pub new_lines: u32,
276+ pub lines: Vec<DiffLine>,
277+}
278+
279+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280+pub enum DiffLineKind {
281+ Context,
282+ Added,
283+ Deleted,
284+}
285+
286+#[derive(Debug, Clone)]
287+pub struct DiffLine {
288+ pub kind: DiffLineKind,
289+ pub old_lineno: Option<u32>,
290+ pub new_lineno: Option<u32>,
291+ pub content: String,
292+ /// The line broken into runs, with the runs that actually differ from the
293+ /// paired line marked (spec §8: "word-level intra-line diffing for changed
294+ /// lines").
295+ ///
296+ /// Always covers the whole line: concatenating `spans` reproduces
297+ /// `content`. Context lines get a single unemphasised span, so a renderer
298+ /// can use `spans` uniformly and never has to fall back to `content`.
299+ pub spans: Vec<DiffSpan>,
300+}
301+
302+/// A run within a diff line.
303+#[derive(Debug, Clone, PartialEq, Eq)]
304+pub struct DiffSpan {
305+ pub text: String,
306+ /// Whether this run is part of what changed on this line.
307+ pub emphasis: bool,
308+}
309+
310+#[derive(Debug, Clone, Default)]
311+pub struct Diff {
312+ pub files: Vec<FileDiff>,
313+ /// Set when the diff exceeded a limit and was not fully rendered. The UI
314+ /// offers the patch download instead of taking the process down (spec §8).
315+ pub truncated: bool,
316+ pub total_additions: usize,
317+ pub total_deletions: usize,
318+}
319+
320+/// Which column of a conflict a piece of content came from.
321+///
322+/// jj's representation is `side₀ base₀ side₁ …`, and the labels matter to a
323+/// reader: a base is the common ancestor the sides diverged from, not another
324+/// candidate resolution.
325+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326+pub enum ConflictSide {
327+ Side(usize),
328+ Base(usize),
329+}
330+
331+impl ConflictSide {
332+ /// A short label for the UI.
333+ pub fn label(&self) -> String {
334+ match self {
335+ ConflictSide::Side(n) => format!("side {}", n + 1),
336+ ConflictSide::Base(n) => format!("base {}", n + 1),
337+ }
338+ }
339+
340+ pub fn is_base(&self) -> bool {
341+ matches!(self, ConflictSide::Base(_))
342+ }
343+}
344+
345+/// One file in conflict, with the content each column holds.
346+#[derive(Debug, Clone)]
347+pub struct ConflictedFile {
348+ pub path: String,
349+ /// One entry per column, in jj's order. `None` means that column does not
350+ /// contain the file at all — a delete/modify conflict, which is exactly the
351+ /// case a viewer that only rendered text would silently misreport.
352+ pub sides: Vec<(ConflictSide, Option<String>)>,
353+}
354+
355+/// What happened when a change was landed.
356+#[derive(Debug, Clone, PartialEq, Eq)]
357+pub enum MergeOutcome {
358+ /// The bookmark was already at or past this revision. Idempotent, so two
359+ /// people clicking merge is a race rather than an error.
360+ AlreadyMerged,
361+ /// The bookmark was an ancestor, so it simply moved. No commit written.
362+ FastForward { new_target: RevId },
363+ /// A merge commit was written, with the bookmark as first parent.
364+ Merged { new_target: RevId },
365+ /// The three-way merge produced a textual conflict.
366+ ///
367+ /// Nothing was written. Spec §4: resolution happens in the user's working
368+ /// copy, so the server must never resolve by writing conflict markers into
369+ /// the target bookmark.
370+ Conflicted,
371+}
372+
373+/// What happened when a single-file edit was committed.
374+#[derive(Debug, Clone, PartialEq, Eq)]
375+pub enum EditOutcome {
376+ Committed { rev: RevId },
377+ /// The content is identical to what is already there. No commit is made —
378+ /// pressing save on an unmodified file is not a change.
379+ Unchanged,
380+ /// The bookmark moved since the edit was composed. Nothing was written;
381+ /// the caller shows the conflict rather than overwriting somebody's work.
382+ Stale { current: RevId },
383+}
384+
385+/// A single line of blame output.
386+#[derive(Debug, Clone)]
387+pub struct BlameLine {
388+ /// The revision that last touched this line.
389+ pub rev: RevId,
390+ /// Author of that revision.
391+ pub author: String,
392+ /// When the revision was authored.
393+ pub when: chrono::DateTime<chrono::Utc>,
394+ /// First line of the commit message.
395+ pub summary: String,
396+ /// 1-based line number in the final file.
397+ pub line_no: usize,
398+}
399+
400+/// Everything the application knows about repository storage.
401+///
402+/// No `gix` type appears in this trait. No caller may depend on the underlying
403+/// store being Git.
404+#[async_trait]
405+pub trait RepoStore: Send + Sync {
406+ async fn create(&self, id: RepoId, default_bookmark: &str) -> Result<()>;
407+
408+ /// Ensure push-time validation is wired up for this repository.
409+ ///
410+ /// Deliberately named for the intent rather than the mechanism: the Git
411+ /// implementation installs a `pre-receive` hook, but a jj-native store
412+ /// would enforce the same rules some other way, and nothing above this
413+ /// trait should have to care which.
414+ async fn configure_receive_validation(&self, id: RepoId, hook_binary: &str) -> Result<()>;
415+ async fn delete(&self, id: RepoId) -> Result<()>;
416+ /// Whether the repository exists on disk.
417+ async fn exists(&self, id: RepoId) -> bool;
418+ /// Whether the repository has no commits yet.
419+ async fn is_empty(&self, id: RepoId) -> Result<bool>;
420+
421+ async fn list_tree(&self, id: RepoId, rev: &RevId, path: &Path) -> Result<Vec<TreeEntry>>;
422+ async fn read_blob(&self, id: RepoId, rev: &RevId, path: &Path) -> Result<Blob>;
423+ async fn diff(&self, id: RepoId, from: &RevId, to: &RevId, opts: DiffOpts) -> Result<Diff>;
424+
425+ /// Diff a revision against its first parent.
426+ ///
427+ /// A root commit has no parent, so it is diffed against an empty tree —
428+ /// which is what makes every commit, including the first, yield a patch.
429+ /// The empty-tree handling lives here rather than in the caller because it
430+ /// is a storage detail; nothing above this trait should know such a thing
431+ /// exists.
432+ async fn diff_from_parent(&self, id: RepoId, rev: &RevId, opts: DiffOpts) -> Result<Diff>;
433+ async fn log(&self, id: RepoId, from: &RevId, limit: usize) -> Result<Vec<Revision>>;
434+ async fn revision(&self, id: RepoId, rev: &RevId) -> Result<Revision>;
435+ async fn bookmarks(&self, id: RepoId) -> Result<Vec<Bookmark>>;
436+ async fn merge_base(&self, id: RepoId, a: &RevId, b: &RevId) -> Result<Option<RevId>>;
437+ async fn is_ancestor(&self, id: RepoId, a: &RevId, b: &RevId) -> Result<bool>;
438+
439+ /// Resolve a bookmark name or revision token to a concrete revision.
440+ async fn resolve(&self, id: RepoId, spec: &str) -> Result<RevId>;
441+
442+ /// Replace one file on `bookmark` and commit the result.
443+ ///
444+ /// The write path behind in-browser editing. `expected_tip` is the revision
445+ /// the edit was composed against: if the bookmark has moved, nothing is
446+ /// written and [`EditOutcome::Stale`] comes back, so a concurrent edit is
447+ /// reported rather than silently overwritten.
448+ ///
449+ /// Always a fast-forward by construction — one new commit whose parent is
450+ /// the current tip.
451+ #[allow(clippy::too_many_arguments)]
452+ async fn commit_file(
453+ &self,
454+ id: RepoId,
455+ bookmark: &str,
456+ expected_tip: &RevId,
457+ path: &str,
458+ content: Vec<u8>,
459+ message: &str,
460+ author: &Signature,
461+ ) -> Result<EditOutcome>;
462+
463+ /// Land `rev` onto `bookmark`.
464+ ///
465+ /// Fast-forwards where possible and writes a merge commit otherwise. The
466+ /// reference update is a compare-and-swap, so a concurrent push cannot be
467+ /// silently discarded. Refuses on conflict without writing anything.
468+ async fn merge(
469+ &self,
470+ id: RepoId,
471+ bookmark: &str,
472+ rev: &RevId,
473+ message: &str,
474+ author: &Signature,
475+ ) -> Result<MergeOutcome>;
476+
477+ /// The conflicted files in a revision, with each side's content.
478+ ///
479+ /// Empty for an unconflicted revision. Expressed in terms of *files and
480+ /// sides* rather than trees so the conflict viewer never learns that the
481+ /// underlying store represents a conflict as a list of trees (spec §3
482+ /// rule 4).
483+ async fn conflicts(&self, id: RepoId, rev: &RevId) -> Result<Vec<ConflictedFile>>;
484+
485+ /// Per-line blame for a file at a given revision.
486+ ///
487+ /// Returns one [`BlameLine`] per line of the file. Blame is expensive;
488+ /// callers should only invoke this when the user explicitly requests it
489+ /// (e.g. via a toggle).
490+ async fn blame(&self, id: RepoId, rev: &RevId, path: &Path) -> Result<Vec<BlameLine>>;
491+
492+ /// The most recent revision that modified a specific file path.
493+ ///
494+ /// Walks history from `rev` and returns the first commit whose diff
495+ /// against its parent touches `path`. Returns `None` if the path was
496+ /// never modified (should not happen for paths that exist).
497+ async fn last_commit_for_path(
498+ &self,
499+ id: RepoId,
500+ rev: &RevId,
501+ path: &Path,
502+ ) -> Result<Option<Revision>>;
503+
504+ /// Total on-disk size, for the repo settings page and quota reporting.
505+ async fn size_bytes(&self, id: RepoId) -> Result<u64>;
506+}
507+
508+#[cfg(test)]
509+mod tests {
510+ use super::*;
511+
512+ #[test]
513+ fn revision_summary_and_body_split_on_the_first_line() {
514+ let r = Revision {
515+ rev: RevId::from_stored("x"),
516+ change_id: None,
517+ parents: vec![],
518+ author: Signature {
519+ name: "a".into(),
520+ email: "b".into(),
521+ when: chrono::Utc::now(),
522+ },
523+ committer: Signature {
524+ name: "a".into(),
525+ email: "b".into(),
526+ when: chrono::Utc::now(),
527+ },
528+ message: "the summary\n\nthe body\nmore body\n".into(),
529+ conflicted: false,
530+ conflict_sides: vec![],
531+ conflict_bases: vec![],
532+ };
533+ assert_eq!(r.summary(), "the summary");
534+ assert_eq!(r.body(), "the body\nmore body\n");
535+ }
536+
537+ #[test]
538+ fn a_single_line_message_has_no_body() {
539+ let mut r = Revision {
540+ rev: RevId::from_stored("x"),
541+ change_id: None,
542+ parents: vec![],
543+ author: Signature { name: "a".into(), email: "b".into(), when: chrono::Utc::now() },
544+ committer: Signature { name: "a".into(), email: "b".into(), when: chrono::Utc::now() },
545+ message: "only a summary".into(),
546+ conflicted: false,
547+ conflict_sides: vec![],
548+ conflict_bases: vec![],
549+ };
550+ assert_eq!(r.summary(), "only a summary");
551+ assert_eq!(r.body(), "");
552+ r.message = String::new();
553+ assert_eq!(r.summary(), "");
554+ }
555+
556+ #[test]
557+ fn abbreviation_lives_in_the_store_and_is_boundary_safe() {
558+ assert_eq!(abbreviate_rev("0123456789abcdef0123"), "0123456789ab");
559+ assert_eq!(abbreviate_rev("short"), "short");
560+ assert_eq!(abbreviate_rev(""), "");
561+ // A multi-byte token must not panic on truncation.
562+ let multi = "\u{e9}".repeat(20);
563+ let out = abbreviate_rev(&multi);
564+ assert_eq!(out.chars().count(), 12);
565+ assert_eq!(RevId::from_stored("0123456789abcdef").short(), "0123456789ab");
566+ }
567+
568+ #[test]
569+ fn binary_blobs_never_yield_text() {
570+ let b = Blob {
571+ path: "x".into(),
572+ content_id: "cid".into(),
573+ content: b"hello".to_vec(),
574+ size: 5,
575+ binary: true,
576+ };
577+ assert_eq!(b.text(), None, "a blob marked binary must not be rendered as text");
578+ }
579+}
Acrates/df-store/src/path.rs+271−0
@@ −0,0 +1,271 @@
1+//! Path normalisation and containment.
2+//!
3+//! Spec §9: "Every path from a URL or a tree walk is normalized and verified to
4+//! be inside the repo root before use. Reject `..`, absolute paths, and NUL
5+//! bytes at the parser."
6+//!
7+//! These are pure functions with dense tests, because a mistake here is a
8+//! read-anything-on-the-host bug and because every one of the cases below has
9+//! been a real CVE in a real forge.
10+
11+use std::path::{Component, Path, PathBuf};
12+
13+/// Why a path was rejected.
14+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15+pub enum PathError {
16+ /// Contains `..`, or otherwise escapes the root.
17+ Traversal,
18+ /// Begins at the filesystem root.
19+ Absolute,
20+ /// Contains a NUL byte or other control character.
21+ Control,
22+ /// Longer than we are willing to handle.
23+ TooLong,
24+}
25+
26+impl std::fmt::Display for PathError {
27+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28+ let s = match self {
29+ PathError::Traversal => "path escapes the repository root",
30+ PathError::Absolute => "path must be relative",
31+ PathError::Control => "path contains control characters",
32+ PathError::TooLong => "path is too long",
33+ };
34+ f.write_str(s)
35+ }
36+}
37+
38+impl std::error::Error for PathError {}
39+
40+/// Git's own limit is generous; this is the point past which a path is not a
41+/// real file but an attempt to exhaust something.
42+const MAX_PATH_LEN: usize = 4096;
43+const MAX_COMPONENTS: usize = 64;
44+
45+/// Normalise a repository-relative path from a URL.
46+///
47+/// Returns a clean, forward-slash-separated path with no leading slash, no `.`
48+/// or `..` components, and no empty segments. Rejects anything that would
49+/// escape the repository root rather than silently clamping it — a clamped
50+/// path is a path the user did not ask for, and answering a different question
51+/// is worse than refusing.
52+pub fn normalise(raw: &str) -> Result<String, PathError> {
53+ if raw.len() > MAX_PATH_LEN {
54+ return Err(PathError::TooLong);
55+ }
56+
57+ // NUL and other C0 controls: a NUL truncates the path in any C API it
58+ // reaches, and escapes reach terminals and log lines (spec §9).
59+ if raw.bytes().any(|b| b < 0x20 || b == 0x7f) {
60+ return Err(PathError::Control);
61+ }
62+
63+ // Reject Windows-style separators outright rather than translating them.
64+ // A repository may legitimately contain a file with a backslash in its
65+ // name, and quietly rewriting the request would serve the wrong file.
66+ if raw.starts_with('/') || raw.starts_with('\\') {
67+ return Err(PathError::Absolute);
68+ }
69+ // A Windows drive prefix is absolute on some platforms.
70+ if raw.len() >= 2 && raw.as_bytes()[1] == b':' {
71+ return Err(PathError::Absolute);
72+ }
73+
74+ let mut out: Vec<&str> = Vec::new();
75+ for segment in raw.split('/') {
76+ match segment {
77+ // Collapse `//` and trailing `/`.
78+ "" | "." => continue,
79+ ".." => return Err(PathError::Traversal),
80+ s => {
81+ // `.git` inside a tree is not inherently dangerous to *read*,
82+ // but it is never a path the browser should follow, and Git
83+ // itself refuses to track it.
84+ out.push(s);
85+ }
86+ }
87+ }
88+
89+ if out.len() > MAX_COMPONENTS {
90+ return Err(PathError::TooLong);
91+ }
92+
93+ Ok(out.join("/"))
94+}
95+
96+/// Join a normalised relative path onto a root and verify containment.
97+///
98+/// Belt and braces over [`normalise`]: this is the check that still holds if a
99+/// caller constructs a path some other way. It is purely lexical — it does not
100+/// touch the filesystem — so it is safe to call before the path exists.
101+pub fn contain(root: &Path, relative: &str) -> Result<PathBuf, PathError> {
102+ let clean = normalise(relative)?;
103+ let joined = root.join(&clean);
104+
105+ // Re-verify by walking components, so a `..` introduced by any means is
106+ // caught even if `normalise` were bypassed.
107+ let mut depth: i32 = 0;
108+ for c in Path::new(&clean).components() {
109+ match c {
110+ Component::Normal(_) => depth += 1,
111+ Component::CurDir => {}
112+ Component::ParentDir => {
113+ depth -= 1;
114+ if depth < 0 {
115+ return Err(PathError::Traversal);
116+ }
117+ }
118+ Component::RootDir | Component::Prefix(_) => return Err(PathError::Absolute),
119+ }
120+ }
121+
122+ Ok(joined)
123+}
124+
125+/// Split a normalised path into its components.
126+pub fn segments(path: &str) -> Vec<&str> {
127+ path.split('/').filter(|s| !s.is_empty()).collect()
128+}
129+
130+/// Breadcrumb trail for a path: each entry is (label, cumulative path).
131+pub fn breadcrumbs(path: &str) -> Vec<(&str, String)> {
132+ let mut acc = String::new();
133+ let mut out = Vec::new();
134+ for seg in segments(path) {
135+ if !acc.is_empty() {
136+ acc.push('/');
137+ }
138+ acc.push_str(seg);
139+ out.push((seg, acc.clone()));
140+ }
141+ out
142+}
143+
144+#[cfg(test)]
145+mod tests {
146+ use super::*;
147+
148+ #[test]
149+ fn normalises_ordinary_paths() {
150+ assert_eq!(normalise("src/main.rs").unwrap(), "src/main.rs");
151+ assert_eq!(normalise("README.md").unwrap(), "README.md");
152+ assert_eq!(normalise("").unwrap(), "");
153+ }
154+
155+ #[test]
156+ fn collapses_redundant_separators_and_dots() {
157+ assert_eq!(normalise("src//main.rs").unwrap(), "src/main.rs");
158+ assert_eq!(normalise("./src/./main.rs").unwrap(), "src/main.rs");
159+ assert_eq!(normalise("src/").unwrap(), "src");
160+ assert_eq!(normalise("a///b////c").unwrap(), "a/b/c");
161+ }
162+
163+ // ─── traversal (spec §9) ─────────────────────────────────────────────────
164+
165+ #[test]
166+ fn rejects_traversal_in_every_position() {
167+ for bad in [
168+ "..",
169+ "../etc/passwd",
170+ "src/../../etc/passwd",
171+ "a/b/../../..",
172+ "./..",
173+ "src/..",
174+ "a/../../b",
175+ ] {
176+ assert_eq!(
177+ normalise(bad),
178+ Err(PathError::Traversal),
179+ "must reject traversal: {bad:?}"
180+ );
181+ }
182+ }
183+
184+ #[test]
185+ fn rejects_absolute_paths() {
186+ for bad in ["/etc/passwd", "/", "\\windows\\system32", "C:/Windows"] {
187+ assert!(
188+ matches!(normalise(bad), Err(PathError::Absolute)),
189+ "must reject absolute: {bad:?}"
190+ );
191+ }
192+ }
193+
194+ #[test]
195+ fn rejects_nul_and_control_bytes() {
196+ assert_eq!(normalise("a\0b"), Err(PathError::Control));
197+ assert_eq!(normalise("a\nb"), Err(PathError::Control));
198+ assert_eq!(normalise("a\rb"), Err(PathError::Control));
199+ // ANSI escapes reach terminals and log lines.
200+ assert_eq!(normalise("a\x1b[31mb"), Err(PathError::Control));
201+ assert_eq!(normalise("a\x7fb"), Err(PathError::Control));
202+ }
203+
204+ #[test]
205+ fn does_not_rewrite_backslashes_inside_a_name() {
206+ // A file may legitimately be named with a backslash. Translating it to
207+ // a separator would serve a different file than the one requested.
208+ assert_eq!(normalise("weird\\name.txt").unwrap(), "weird\\name.txt");
209+ }
210+
211+ #[test]
212+ fn url_encoded_traversal_is_not_decoded_here() {
213+ // %2e%2e is decoded by the HTTP layer before reaching us. If it somehow
214+ // arrives literally it is just an ordinary filename, not traversal —
215+ // documenting that the decode must happen upstream, exactly once.
216+ assert_eq!(normalise("%2e%2e/etc").unwrap(), "%2e%2e/etc");
217+ }
218+
219+ #[test]
220+ fn rejects_absurd_lengths() {
221+ assert_eq!(normalise(&"a".repeat(5000)), Err(PathError::TooLong));
222+ let deep = (0..100).map(|_| "a").collect::<Vec<_>>().join("/");
223+ assert_eq!(normalise(&deep), Err(PathError::TooLong));
224+ }
225+
226+ // ─── containment ─────────────────────────────────────────────────────────
227+
228+ #[test]
229+ fn contain_keeps_paths_under_the_root() {
230+ let root = Path::new("/srv/repos/ab/repo.git");
231+ assert_eq!(
232+ contain(root, "src/main.rs").unwrap(),
233+ PathBuf::from("/srv/repos/ab/repo.git/src/main.rs")
234+ );
235+ }
236+
237+ #[test]
238+ fn contain_rejects_escapes() {
239+ let root = Path::new("/srv/repos/ab/repo.git");
240+ assert_eq!(contain(root, "../../../etc/passwd"), Err(PathError::Traversal));
241+ assert_eq!(contain(root, "/etc/passwd"), Err(PathError::Absolute));
242+ }
243+
244+ #[test]
245+ fn contained_paths_always_start_with_the_root() {
246+ // The property that matters, asserted over a range of inputs.
247+ let root = Path::new("/srv/repos/ab/repo.git");
248+ for p in ["a", "a/b", "a/b/c.txt", "", "./a", "a//b"] {
249+ let joined = contain(root, p).unwrap();
250+ assert!(
251+ joined.starts_with(root),
252+ "{p:?} produced {joined:?}, which is outside {root:?}"
253+ );
254+ }
255+ }
256+
257+ // ─── breadcrumbs ─────────────────────────────────────────────────────────
258+
259+ #[test]
260+ fn breadcrumbs_accumulate() {
261+ assert_eq!(
262+ breadcrumbs("src/routes/mod.rs"),
263+ vec![
264+ ("src", "src".to_string()),
265+ ("routes", "src/routes".to_string()),
266+ ("mod.rs", "src/routes/mod.rs".to_string()),
267+ ]
268+ );
269+ assert!(breadcrumbs("").is_empty());
270+ }
271+}
Acrates/df-store/src/refname.rs+300−0
@@ −0,0 +1,300 @@
1+//! Reference name validation (spec §9).
2+//!
3+//! > Validate against the Git ref format rules plus a stricter allowlist in the
4+//! > pre-receive hook. Reject refs containing `..`, ending in `.lock`, starting
5+//! > with `-`, or containing control characters or ANSI escapes. Ref names reach
6+//! > terminals and log lines; escape sequences in them are an injection vector.
7+//!
8+//! Shared by the pre-receive hook and the repository settings form so a name
9+//! that one accepts the other cannot reject.
10+
11+#[derive(Debug, Clone, PartialEq, Eq)]
12+pub enum RefError {
13+ Empty,
14+ TooLong,
15+ /// Control characters, DEL, or an ANSI escape.
16+ Control,
17+ /// `..`, a leading `-`, a trailing `.lock`, and friends.
18+ Malformed(&'static str),
19+ /// A character outside the permitted set.
20+ Character(char),
21+ /// Not under a namespace Dogfood accepts on push.
22+ Namespace,
23+}
24+
25+impl std::fmt::Display for RefError {
26+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27+ match self {
28+ RefError::Empty => write!(f, "reference name is empty"),
29+ RefError::TooLong => write!(f, "reference name is too long"),
30+ RefError::Control => write!(
31+ f,
32+ "reference name contains control characters or escape sequences"
33+ ),
34+ RefError::Malformed(why) => write!(f, "reference name {why}"),
35+ // The offending character is rendered by its Unicode escape rather
36+ // than literally: this message is printed to the pusher's terminal,
37+ // and echoing the raw byte is the injection we are rejecting.
38+ RefError::Character(c) => {
39+ write!(f, "reference name contains a forbidden character U+{:04X}", *c as u32)
40+ }
41+ RefError::Namespace => write!(
42+ f,
43+ "only refs/heads/* and refs/tags/* may be pushed to Dogfood"
44+ ),
45+ }
46+ }
47+}
48+
49+/// Longest ref we will accept. Git itself allows more; this is the point past
50+/// which a name is not a branch but an attempt to exhaust something.
51+const MAX_REF_LEN: usize = 255;
52+
53+/// Validate the short name of a bookmark (no `refs/heads/` prefix).
54+pub fn validate_bookmark(name: &str) -> Result<(), RefError> {
55+ if name.is_empty() {
56+ return Err(RefError::Empty);
57+ }
58+ if name.len() > MAX_REF_LEN {
59+ return Err(RefError::TooLong);
60+ }
61+
62+ // Control characters, DEL, and anything that could carry an ANSI escape.
63+ // Checked first, so later messages never quote a raw escape byte.
64+ for c in name.chars() {
65+ if c.is_control() || c == '\u{7f}' {
66+ return Err(RefError::Control);
67+ }
68+ }
69+
70+ // Git's documented rules (git-check-ref-format), plus our own.
71+ if name.contains("..") {
72+ return Err(RefError::Malformed("contains `..`"));
73+ }
74+ if name.ends_with(".lock") {
75+ return Err(RefError::Malformed("ends with `.lock`"));
76+ }
77+ if name.starts_with('-') {
78+ // A leading hyphen is read as an option by every CLI that receives it.
79+ return Err(RefError::Malformed("starts with `-`"));
80+ }
81+ if name.starts_with('/') || name.ends_with('/') || name.contains("//") {
82+ return Err(RefError::Malformed("has an empty path component"));
83+ }
84+ if name.starts_with('.') || name.contains("/.") {
85+ return Err(RefError::Malformed("has a component starting with `.`"));
86+ }
87+ if name.ends_with('.') {
88+ return Err(RefError::Malformed("ends with `.`"));
89+ }
90+ if name.contains("@{") {
91+ return Err(RefError::Malformed("contains `@{`"));
92+ }
93+ if name == "@" {
94+ return Err(RefError::Malformed("is `@`"));
95+ }
96+ if name.contains(".lock/") {
97+ return Err(RefError::Malformed("has a component ending in `.lock`"));
98+ }
99+
100+ // Stricter than Git: an explicit allowlist. Git permits most bytes, which
101+ // means a ref name can carry UTF-8 that renders as something else entirely
102+ // in a terminal or a URL.
103+ for c in name.chars() {
104+ let ok = c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-' | '+');
105+ if !ok {
106+ return Err(RefError::Character(c));
107+ }
108+ }
109+
110+ Ok(())
111+}
112+
113+/// Validate a fully-qualified ref as it arrives on the wire.
114+///
115+/// Only `refs/heads/*` and `refs/tags/*` may be pushed. Notably this rejects
116+/// `refs/dogfood/*` and anything else a client might invent, so a push cannot
117+/// create refs the UI never shows and GC never reasons about.
118+pub fn validate_pushed_ref(full: &str) -> Result<(), RefError> {
119+ if full.is_empty() {
120+ return Err(RefError::Empty);
121+ }
122+ if full.len() > MAX_REF_LEN {
123+ return Err(RefError::TooLong);
124+ }
125+ for c in full.chars() {
126+ if c.is_control() || c == '\u{7f}' {
127+ return Err(RefError::Control);
128+ }
129+ }
130+
131+ let short = full
132+ .strip_prefix("refs/heads/")
133+ .or_else(|| full.strip_prefix("refs/tags/"))
134+ .ok_or(RefError::Namespace)?;
135+
136+ validate_bookmark(short)
137+}
138+
139+#[cfg(test)]
140+mod tests {
141+ use super::*;
142+
143+ #[test]
144+ fn accepts_ordinary_bookmark_names() {
145+ for name in [
146+ "main",
147+ "feature/thing",
148+ "release-1.2",
149+ "v1.0.0",
150+ "user/x_y",
151+ "a+b",
152+ "9lives",
153+ ] {
154+ assert_eq!(validate_bookmark(name), Ok(()), "should accept {name:?}");
155+ }
156+ }
157+
158+ // ─── the §9 list, verbatim ───────────────────────────────────────────────
159+
160+ #[test]
161+ fn rejects_double_dot() {
162+ assert!(matches!(
163+ validate_bookmark("a..b"),
164+ Err(RefError::Malformed(_))
165+ ));
166+ }
167+
168+ #[test]
169+ fn rejects_dot_lock_suffix() {
170+ assert!(matches!(
171+ validate_bookmark("main.lock"),
172+ Err(RefError::Malformed(_))
173+ ));
174+ assert!(matches!(
175+ validate_bookmark("a.lock/b"),
176+ Err(RefError::Malformed(_))
177+ ));
178+ }
179+
180+ #[test]
181+ fn rejects_leading_hyphen() {
182+ // Otherwise the name is read as an option by anything it is passed to.
183+ assert!(matches!(
184+ validate_bookmark("-rf"),
185+ Err(RefError::Malformed(_))
186+ ));
187+ }
188+
189+ #[test]
190+ fn rejects_control_characters_and_ansi_escapes() {
191+ // "Ref names reach terminals and log lines; escape sequences in them
192+ // are an injection vector."
193+ for bad in [
194+ "main\n",
195+ "main\r",
196+ "main\0",
197+ "ma\tin",
198+ "main\x1b[31mRED",
199+ "main\x1b]0;title\x07",
200+ "main\u{7f}",
201+ ] {
202+ assert_eq!(
203+ validate_bookmark(bad),
204+ Err(RefError::Control),
205+ "must reject {bad:?}"
206+ );
207+ }
208+ }
209+
210+ #[test]
211+ fn the_error_message_never_echoes_the_offending_byte() {
212+ // Printing the raw character back to the pusher's terminal would be the
213+ // very injection this rejects.
214+ let msg = RefError::Character('\u{202e}').to_string();
215+ assert!(!msg.contains('\u{202e}'), "message echoed the byte: {msg:?}");
216+ assert!(msg.contains("U+202E"), "got {msg}");
217+ }
218+
219+ #[test]
220+ fn rejects_git_format_violations() {
221+ for bad in [
222+ "/leading", "trailing/", "a//b", ".hidden", "a/.hidden", "trailing.",
223+ "main@{1}", "@",
224+ ] {
225+ assert!(
226+ matches!(validate_bookmark(bad), Err(RefError::Malformed(_))),
227+ "must reject {bad:?}, got {:?}",
228+ validate_bookmark(bad)
229+ );
230+ }
231+ }
232+
233+ #[test]
234+ fn rejects_characters_outside_the_allowlist() {
235+ // Stricter than Git on purpose: these are all legal refs to Git.
236+ for bad in ["main branch", "main~1", "main^", "main:x", "main?", "main*",
237+ "main[1]", "main\\x", "café", "main'", "main\"", "main;rm -rf"] {
238+ assert!(
239+ matches!(
240+ validate_bookmark(bad),
241+ Err(RefError::Character(_)) | Err(RefError::Malformed(_))
242+ ),
243+ "must reject {bad:?}, got {:?}",
244+ validate_bookmark(bad)
245+ );
246+ }
247+ }
248+
249+ #[test]
250+ fn rejects_a_homoglyph_or_bidi_override() {
251+ // U+202E flips rendering order and can make a ref display as a
252+ // different name than it is.
253+ assert!(validate_bookmark("main\u{202e}drow").is_err());
254+ }
255+
256+ #[test]
257+ fn rejects_empty_and_overlong() {
258+ assert_eq!(validate_bookmark(""), Err(RefError::Empty));
259+ assert_eq!(validate_bookmark(&"a".repeat(256)), Err(RefError::TooLong));
260+ assert_eq!(validate_bookmark(&"a".repeat(255)), Ok(()));
261+ }
262+
263+ // ─── fully-qualified refs ────────────────────────────────────────────────
264+
265+ #[test]
266+ fn accepts_heads_and_tags() {
267+ assert_eq!(validate_pushed_ref("refs/heads/main"), Ok(()));
268+ assert_eq!(validate_pushed_ref("refs/tags/v1.0"), Ok(()));
269+ assert_eq!(validate_pushed_ref("refs/heads/feature/x"), Ok(()));
270+ }
271+
272+ #[test]
273+ fn rejects_other_namespaces() {
274+ // A push must not be able to create refs the UI never shows.
275+ for bad in [
276+ "refs/dogfood/secret",
277+ "refs/remotes/origin/main",
278+ "refs/notes/commits",
279+ "HEAD",
280+ "main",
281+ "refs/",
282+ ] {
283+ assert_eq!(
284+ validate_pushed_ref(bad),
285+ Err(RefError::Namespace),
286+ "must reject namespace {bad:?}"
287+ );
288+ }
289+ }
290+
291+ #[test]
292+ fn qualified_refs_inherit_every_short_name_rule() {
293+ assert!(validate_pushed_ref("refs/heads/a..b").is_err());
294+ assert!(validate_pushed_ref("refs/heads/main.lock").is_err());
295+ assert_eq!(
296+ validate_pushed_ref("refs/heads/main\x1b[31m"),
297+ Err(RefError::Control)
298+ );
299+ }
300+}
Acrates/df-store/tests/git_store.rs707 lines+707−0
@@ −0,0 +1,707 @@
1+//! `GitStore` against the real fixture corpus.
2+//!
3+//! Fixtures are copied into the store's own sharded layout, so these exercise
4+//! the same code path a pushed repository takes — including `repo_path`
5+//! sharding, not just the Git plumbing.
6+//!
7+//! Skipped with a warning when the corpus is absent; `DF_REQUIRE_FIXTURES=1`
8+//! turns that into a failure so CI cannot silently skip everything.
9+
10+use std::path::{Path, PathBuf};
11+
12+use df_store::{EntryKind, GitStore, RepoId, RepoStore, RevId, StoreError};
13+use uuid::Uuid;
14+
15+struct Fixture {
16+ _tmp: tempfile::TempDir,
17+ store: GitStore,
18+ id: RepoId,
19+}
20+
21+fn corpus() -> PathBuf {
22+ Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/repos")
23+}
24+
25+/// Copy a fixture repository into a fresh store and return a handle to it.
26+fn load(case: &str) -> Option<Fixture> {
27+ let src = corpus().join(format!("{case}.git"));
28+ if !src.is_dir() {
29+ if std::env::var_os("DF_REQUIRE_FIXTURES").is_some() {
30+ panic!("DF_REQUIRE_FIXTURES is set but {case}.git is missing — run fixtures/gen.sh");
31+ }
32+ eprintln!("warning: fixture {case}.git absent, test skipped");
33+ return None;
34+ }
35+
36+ let tmp = tempfile::tempdir().expect("tempdir");
37+ let store = GitStore::new(tmp.path());
38+ let id = RepoId(Uuid::now_v7());
39+
40+ let dest = store.repo_path(id);
41+ std::fs::create_dir_all(dest.parent().unwrap()).expect("shard dir");
42+
43+ let status = std::process::Command::new("cp")
44+ .arg("-r")
45+ .arg(&src)
46+ .arg(&dest)
47+ .status()
48+ .expect("cp");
49+ assert!(status.success(), "copying fixture {case} failed");
50+
51+ Some(Fixture { _tmp: tmp, store, id })
52+}
53+
54+async fn head(f: &Fixture) -> RevId {
55+ f.store
56+ .resolve(f.id, "main")
57+ .await
58+ .expect("resolving main")
59+}
60+
61+#[tokio::test]
62+async fn creates_and_deletes_a_repository() {
63+ let tmp = tempfile::tempdir().unwrap();
64+ let store = GitStore::new(tmp.path());
65+ let id = RepoId(Uuid::now_v7());
66+
67+ assert!(!store.exists(id).await);
68+ store.create(id, "main").await.expect("create");
69+ assert!(store.exists(id).await);
70+ assert!(store.is_empty(id).await.expect("is_empty"), "a fresh repo has no refs");
71+
72+ // HEAD must point at the configured default bookmark, or a fresh clone
73+ // checks out nothing.
74+ let head = std::fs::read_to_string(store.repo_path(id).join("HEAD")).unwrap();
75+ assert_eq!(head.trim(), "ref: refs/heads/main");
76+
77+ store.delete(id).await.expect("delete");
78+ assert!(!store.exists(id).await);
79+}
80+
81+#[tokio::test]
82+async fn operations_on_a_missing_repo_report_no_such_repo() {
83+ let tmp = tempfile::tempdir().unwrap();
84+ let store = GitStore::new(tmp.path());
85+ let id = RepoId(Uuid::now_v7());
86+
87+ assert!(matches!(
88+ store.bookmarks(id).await,
89+ Err(StoreError::NoSuchRepo)
90+ ));
91+}
92+
93+#[tokio::test]
94+async fn lists_bookmarks() {
95+ let Some(f) = load("stack") else { return };
96+ let marks = f.store.bookmarks(f.id).await.expect("bookmarks");
97+ let names: Vec<&str> = marks.iter().map(|b| b.name.as_str()).collect();
98+ assert!(names.contains(&"main"), "got {names:?}");
99+ assert!(names.contains(&"top"), "got {names:?}");
100+}
101+
102+#[tokio::test]
103+async fn lists_a_tree_and_reads_a_blob() {
104+ let Some(f) = load("basic") else { return };
105+ let rev = head(&f).await;
106+
107+ let entries = f
108+ .store
109+ .list_tree(f.id, &rev, Path::new(""))
110+ .await
111+ .expect("list_tree");
112+ assert!(!entries.is_empty());
113+
114+ let file = entries
115+ .iter()
116+ .find(|e| e.kind == EntryKind::File)
117+ .expect("at least one file");
118+
119+ let blob = f
120+ .store
121+ .read_blob(f.id, &rev, Path::new(&file.path))
122+ .await
123+ .expect("read_blob");
124+ assert!(blob.text().is_some(), "fixture files are text");
125+ assert_eq!(blob.size, blob.content.len() as u64);
126+}
127+
128+#[tokio::test]
129+async fn directories_sort_before_files() {
130+ let Some(f) = load("hostile") else { return };
131+ let rev = f.store.resolve(f.id, "main").await.expect("resolve");
132+ let entries = f.store.list_tree(f.id, &rev, Path::new("")).await.unwrap();
133+
134+ let first_file = entries.iter().position(|e| !e.is_dir());
135+ let last_dir = entries.iter().rposition(|e| e.is_dir());
136+ if let (Some(ff), Some(ld)) = (first_file, last_dir) {
137+ assert!(ld < ff, "directories must sort before files: {entries:#?}");
138+ }
139+}
140+
141+// ─── path safety (spec §9) ───────────────────────────────────────────────────
142+
143+#[tokio::test]
144+async fn traversal_out_of_the_repo_is_refused() {
145+ let Some(f) = load("basic") else { return };
146+ let rev = head(&f).await;
147+
148+ for bad in ["../../../etc/passwd", "/etc/passwd", "a/../../../etc/passwd"] {
149+ let r = f.store.read_blob(f.id, &rev, Path::new(bad)).await;
150+ assert!(
151+ matches!(r, Err(StoreError::Path(_))),
152+ "path {bad:?} must be refused, got {r:?}"
153+ );
154+ }
155+}
156+
157+#[tokio::test]
158+async fn a_symlink_escaping_the_repo_serves_its_target_text_not_the_file() {
159+ // "A repository can contain a symlink to /etc/passwd and a naive blob
160+ // handler will happily serve it" (spec §9). We must return the *link text*,
161+ // never the contents of the file it points at.
162+ let Some(f) = load("hostile") else { return };
163+ let rev = f.store.resolve(f.id, "main").await.expect("resolve");
164+
165+ let entries = f.store.list_tree(f.id, &rev, Path::new("")).await.unwrap();
166+ let links: Vec<_> = entries
167+ .iter()
168+ .filter(|e| e.kind == EntryKind::Symlink)
169+ .collect();
170+ assert!(!links.is_empty(), "the hostile fixture must contain symlinks");
171+
172+ for link in links {
173+ let blob = f
174+ .store
175+ .read_blob(f.id, &rev, Path::new(&link.path))
176+ .await
177+ .expect("reading a symlink must succeed, returning its target text");
178+
179+ let text = String::from_utf8_lossy(&blob.content);
180+ // The blob holds the *target path*, which is short. If we had followed
181+ // the link we would be holding /etc/passwd or /etc/shadow.
182+ assert!(
183+ text == "/etc/passwd" || text == "../../../../etc/shadow",
184+ "symlink {} yielded unexpected content {text:?}",
185+ link.path
186+ );
187+ assert!(
188+ !text.contains("root:"),
189+ "SECURITY: symlink was followed off-repo — served {text:?}"
190+ );
191+ }
192+}
193+
194+#[tokio::test]
195+async fn reading_a_directory_as_a_blob_is_refused() {
196+ let Some(f) = load("hostile") else { return };
197+ let rev = f.store.resolve(f.id, "main").await.unwrap();
198+ let r = f.store.read_blob(f.id, &rev, Path::new("a")).await;
199+ assert!(matches!(r, Err(StoreError::IsDirectory)), "got {r:?}");
200+}
201+
202+#[tokio::test]
203+async fn a_missing_path_is_reported_as_missing() {
204+ let Some(f) = load("basic") else { return };
205+ let rev = head(&f).await;
206+ let r = f
207+ .store
208+ .read_blob(f.id, &rev, Path::new("does/not/exist.txt"))
209+ .await;
210+ assert!(matches!(r, Err(StoreError::NoSuchPath)), "got {r:?}");
211+}
212+
213+// ─── jj semantics ────────────────────────────────────────────────────────────
214+
215+#[tokio::test]
216+async fn revisions_carry_the_jj_change_id() {
217+ let Some(f) = load("basic") else { return };
218+ let rev = head(&f).await;
219+ let r = f.store.revision(f.id, &rev).await.expect("revision");
220+
221+ let change = r.change_id.expect("a jj commit must expose its change id");
222+ assert_eq!(change.len(), 32);
223+ assert!(change.bytes().all(|b| (b'k'..=b'z').contains(&b)));
224+ assert!(!r.conflicted);
225+}
226+
227+#[tokio::test]
228+async fn plain_git_revisions_have_no_change_id() {
229+ let Some(f) = load("plain-git") else { return };
230+ let rev = f.store.resolve(f.id, "main").await.expect("resolve");
231+ let r = f.store.revision(f.id, &rev).await.expect("revision");
232+ assert_eq!(r.change_id, None);
233+}
234+
235+#[tokio::test]
236+async fn conflicted_revisions_expose_their_sides_and_hide_the_artifacts() {
237+ let Some(f) = load("conflict") else { return };
238+ let rev = f.store.resolve(f.id, "main").await.expect("resolve");
239+
240+ let r = f.store.revision(f.id, &rev).await.expect("revision");
241+ assert!(r.conflicted, "the fixture head is a conflicted merge");
242+ assert_eq!(
243+ r.conflict_sides.len(),
244+ r.conflict_bases.len() + 1,
245+ "sides == bases + 1"
246+ );
247+
248+ // The .jjconflict-* trees are storage detail and must not surface.
249+ let entries = f.store.list_tree(f.id, &rev, Path::new("")).await.unwrap();
250+ for e in &entries {
251+ assert!(
252+ !e.name.starts_with(".jjconflict-") && e.name != "JJ-CONFLICT-README",
253+ "conflict artefact leaked into the listing: {}",
254+ e.name
255+ );
256+ }
257+ assert!(
258+ entries.iter().any(|e| e.name == "c.txt"),
259+ "the real file must still be listed: {entries:#?}"
260+ );
261+}
262+
263+#[tokio::test]
264+async fn log_walks_history_and_respects_its_limit() {
265+ let Some(f) = load("stack") else { return };
266+ let rev = f.store.resolve(f.id, "top").await.expect("resolve top");
267+
268+ let all = f.store.log(f.id, &rev, 100).await.expect("log");
269+ assert!(all.len() >= 5, "stack fixture has a base plus four changes");
270+
271+ let two = f.store.log(f.id, &rev, 2).await.expect("log");
272+ assert_eq!(two.len(), 2);
273+ assert_eq!(two[0].rev, rev, "log starts at the requested revision");
274+}
275+
276+#[tokio::test]
277+async fn ancestry_and_merge_base() {
278+ let Some(f) = load("stack") else { return };
279+ let bottom = f.store.resolve(f.id, "main").await.unwrap();
280+ let top = f.store.resolve(f.id, "top").await.unwrap();
281+
282+ assert!(f.store.is_ancestor(f.id, &bottom, &top).await.unwrap());
283+ assert!(
284+ !f.store.is_ancestor(f.id, &top, &bottom).await.unwrap(),
285+ "ancestry is directional"
286+ );
287+ assert!(
288+ f.store.is_ancestor(f.id, &top, &top).await.unwrap(),
289+ "a revision is its own ancestor"
290+ );
291+
292+ let base = f.store.merge_base(f.id, &bottom, &top).await.unwrap();
293+ assert_eq!(base, Some(bottom));
294+}
295+
296+#[tokio::test]
297+async fn diff_reports_changed_files_with_line_counts() {
298+ let Some(f) = load("whitespace") else { return };
299+ let before = f.store.resolve(f.id, "main").await.unwrap();
300+ let after = f.store.resolve(f.id, "reindented").await.unwrap();
301+
302+ let d = f
303+ .store
304+ .diff(f.id, &before, &after, Default::default())
305+ .await
306+ .expect("diff");
307+
308+ assert_eq!(d.files.len(), 1, "one file changed: {:#?}", d.files);
309+ assert_eq!(d.files[0].path, "m.rs");
310+ assert!(d.total_additions > 0 && d.total_deletions > 0);
311+ assert!(!d.truncated);
312+}
313+
314+#[tokio::test]
315+async fn diff_of_a_revision_against_itself_is_empty() {
316+ let Some(f) = load("basic") else { return };
317+ let rev = head(&f).await;
318+ let d = f
319+ .store
320+ .diff(f.id, &rev, &rev, Default::default())
321+ .await
322+ .expect("diff");
323+ assert!(d.files.is_empty());
324+ assert_eq!((d.total_additions, d.total_deletions), (0, 0));
325+}
326+
327+#[tokio::test]
328+async fn an_unknown_revision_is_reported_not_panicked() {
329+ let Some(f) = load("basic") else { return };
330+ let bogus = RevId::from_stored("0000000000000000000000000000000000000000");
331+ assert!(matches!(
332+ f.store.revision(f.id, &bogus).await,
333+ Err(StoreError::NoSuchRevision)
334+ ));
335+ assert!(matches!(
336+ f.store.resolve(f.id, "no-such-bookmark").await,
337+ Err(StoreError::NoSuchRevision)
338+ ));
339+}
340+
341+#[tokio::test]
342+async fn revision_specs_containing_control_characters_are_refused() {
343+ let Some(f) = load("basic") else { return };
344+ for bad in ["main\n", "main\0", "\x1b[31m"] {
345+ assert!(
346+ matches!(f.store.resolve(f.id, bad).await, Err(StoreError::NoSuchRevision)),
347+ "spec {bad:?} must be refused"
348+ );
349+ }
350+}
351+
352+#[tokio::test]
353+async fn size_bytes_reports_something_plausible() {
354+ let Some(f) = load("basic") else { return };
355+ let n = f.store.size_bytes(f.id).await.expect("size");
356+ assert!(n > 0, "a populated repository occupies disk");
357+}
358+
359+// ─── conflicts (spec §4, M4) ─────────────────────────────────────────────────
360+
361+/// Read a manifest value for a fixture case.
362+fn manifest(case: &str, key: &str) -> Option<String> {
363+ let raw = std::fs::read_to_string(corpus().join("manifest.json")).ok()?;
364+ let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
365+ v.get("cases")?.get(case)?.get(key)?.as_str().map(str::to_owned)
366+}
367+
368+#[tokio::test]
369+async fn a_conflicted_revision_reports_its_sides() {
370+ let Some(f) = load("conflict") else { return };
371+ let Some(rev) = manifest("conflict", "conflicted_commit") else {
372+ panic!("the conflict fixture must record its conflicted commit");
373+ };
374+ let rev = RevId::from_stored(rev);
375+
376+ let revision = f.store.revision(f.id, &rev).await.expect("revision");
377+ assert!(revision.conflicted, "the fixture's commit is supposed to be conflicted");
378+
379+ let files = f.store.conflicts(f.id, &rev).await.expect("conflicts");
380+ assert!(!files.is_empty(), "a conflicted revision must report at least one file");
381+
382+ for file in &files {
383+ // jj's representation is side/base/side…, so there is always an odd
384+ // number of columns and at least one base to compare against.
385+ assert!(
386+ file.sides.len() >= 3,
387+ "{}: expected at least side/base/side, got {}",
388+ file.path,
389+ file.sides.len()
390+ );
391+ assert!(
392+ file.sides.iter().any(|(l, _)| l.is_base()),
393+ "{}: a conflict without a base cannot be read by a reviewer",
394+ file.path
395+ );
396+ // jj's own bookkeeping must never surface as a conflicted file.
397+ assert!(
398+ !file.path.contains(".jjconflict"),
399+ "storage detail leaked into the conflict view: {}",
400+ file.path
401+ );
402+ }
403+}
404+
405+#[tokio::test]
406+async fn an_unconflicted_revision_has_no_conflicts_rather_than_an_error() {
407+ let Some(f) = load("basic") else { return };
408+ let head = head(&f).await;
409+ let files = f.store.conflicts(f.id, &head).await.expect("conflicts");
410+ assert!(files.is_empty());
411+}
412+
413+// ─── word-level diffing (spec §8) ────────────────────────────────────────────
414+
415+#[tokio::test]
416+async fn changed_lines_carry_word_level_spans() {
417+ let Some(f) = load("basic") else { return };
418+ let head = head(&f).await;
419+
420+ let diff = f
421+ .store
422+ .diff_from_parent(f.id, &head, df_store::DiffOpts::default())
423+ .await
424+ .expect("diff");
425+
426+ let mut saw_span = false;
427+ for file in &diff.files {
428+ for hunk in &file.hunks {
429+ for line in &hunk.lines {
430+ // The invariant every renderer relies on: spans reconstruct the
431+ // line exactly, so a view can render spans and never content.
432+ let rebuilt: String = line.spans.iter().map(|s| s.text.as_str()).collect();
433+ assert_eq!(
434+ rebuilt, line.content,
435+ "spans must reconstruct the line exactly"
436+ );
437+
438+ if matches!(line.kind, df_store::DiffLineKind::Context) {
439+ assert!(
440+ line.spans.iter().all(|s| !s.emphasis),
441+ "a context line has nothing to emphasise"
442+ );
443+ }
444+ if line.spans.iter().any(|s| s.emphasis) {
445+ saw_span = true;
446+ }
447+ }
448+ }
449+ }
450+
451+ // Not asserted unconditionally: whether any line in the fixture is a
452+ // *modification* rather than a pure add depends on the fixture. The
453+ // reconstruction invariant above is the one that must always hold.
454+ if !saw_span {
455+ eprintln!("note: no intra-line changes in this fixture's head diff");
456+ }
457+}
458+
459+// ─── push validation (spec §4, §9) ───────────────────────────────────────────
460+
461+/// A repository without this hook accepts anything: no ref-name allowlist and
462+/// no protected bookmarks. Git only runs `hooks/pre-receive`, and only if it is
463+/// executable — a hook that is present but not `+x` is silently skipped, which
464+/// is the same failure with a more confusing cause.
465+#[tokio::test]
466+async fn receive_validation_installs_an_executable_pre_receive_hook() {
467+ let tmp = tempfile::tempdir().unwrap();
468+ let store = GitStore::new(tmp.path());
469+ let id = RepoId(Uuid::now_v7());
470+ store.create(id, "main").await.expect("create");
471+
472+ store
473+ .configure_receive_validation(id, "/usr/local/bin/dogfood-hook")
474+ .await
475+ .expect("installing the hook");
476+
477+ let hook = store.repo_path(id).join("hooks").join("pre-receive");
478+ assert!(hook.is_file(), "git only runs hooks/pre-receive");
479+
480+ #[cfg(unix)]
481+ {
482+ use std::os::unix::fs::PermissionsExt;
483+ let mode = std::fs::metadata(&hook).unwrap().permissions().mode();
484+ assert!(
485+ mode & 0o111 != 0,
486+ "a non-executable hook is skipped silently: mode {mode:o}"
487+ );
488+ }
489+
490+ let body = std::fs::read_to_string(&hook).unwrap();
491+ assert!(
492+ body.contains("/usr/local/bin/dogfood-hook"),
493+ "the hook must exec the compiled binary, not implement checks in shell: {body}"
494+ );
495+ assert!(
496+ body.contains(&id.0.to_string()),
497+ "the binary needs the repository id and git does not pass one: {body}"
498+ );
499+}
500+
501+/// Installing twice must be a no-op, because it runs on every boot.
502+#[tokio::test]
503+async fn receive_validation_is_idempotent() {
504+ let tmp = tempfile::tempdir().unwrap();
505+ let store = GitStore::new(tmp.path());
506+ let id = RepoId(Uuid::now_v7());
507+ store.create(id, "main").await.expect("create");
508+
509+ store.configure_receive_validation(id, "/hook").await.unwrap();
510+ let first = std::fs::read_to_string(store.repo_path(id).join("hooks/pre-receive")).unwrap();
511+
512+ store.configure_receive_validation(id, "/hook").await.unwrap();
513+ let second = std::fs::read_to_string(store.repo_path(id).join("hooks/pre-receive")).unwrap();
514+
515+ assert_eq!(first, second);
516+}
517+
518+// ─── in-browser editing ──────────────────────────────────────────────────────
519+
520+async fn tip(f: &Fixture) -> RevId {
521+ f.store.resolve(f.id, "main").await.expect("resolving main")
522+}
523+
524+#[tokio::test]
525+async fn committing_an_edited_file_advances_the_bookmark() {
526+ let Some(f) = load("basic") else { return };
527+ let before = tip(&f).await;
528+
529+ let author = df_store::Signature {
530+ name: "Alice".into(),
531+ email: "alice@example.com".into(),
532+ when: chrono::Utc::now(),
533+ };
534+
535+ // Pick a file that exists, so this exercises replacement rather than
536+ // creation.
537+ let entries = f
538+ .store
539+ .list_tree(f.id, &before, Path::new(""))
540+ .await
541+ .expect("tree");
542+ let target = entries
543+ .iter()
544+ .find(|e| !e.is_dir())
545+ .expect("the fixture has a file")
546+ .path
547+ .clone();
548+
549+ let outcome = f
550+ .store
551+ .commit_file(
552+ f.id,
553+ "main",
554+ &before,
555+ &target,
556+ b"edited by the web editor\n".to_vec(),
557+ "edit: change a file",
558+ &author,
559+ )
560+ .await
561+ .expect("commit_file");
562+
563+ let rev = match outcome {
564+ df_store::EditOutcome::Committed { rev } => rev,
565+ other => panic!("expected a commit, got {other:?}"),
566+ };
567+
568+ assert_ne!(rev, before, "a new commit must be written");
569+ assert_eq!(tip(&f).await, rev, "the bookmark must point at it");
570+
571+ // The content is what we wrote…
572+ let blob = f
573+ .store
574+ .read_blob(f.id, &rev, Path::new(&target))
575+ .await
576+ .expect("read back");
577+ assert_eq!(blob.text(), Some("edited by the web editor\n"));
578+
579+ // …and the parent is the old tip, so this is a fast-forward and the
580+ // protected-bookmark rule is satisfied by construction.
581+ let revision = f.store.revision(f.id, &rev).await.expect("revision");
582+ assert_eq!(revision.parents, vec![before]);
583+
584+ // A web edit is a plain-git commit: no invented jj change id (spec §4).
585+ assert_eq!(revision.change_id, None);
586+}
587+
588+#[tokio::test]
589+async fn committing_an_unchanged_file_writes_nothing() {
590+ let Some(f) = load("basic") else { return };
591+ let before = tip(&f).await;
592+
593+ let entries = f.store.list_tree(f.id, &before, Path::new("")).await.unwrap();
594+ let target = entries.iter().find(|e| !e.is_dir()).unwrap().path.clone();
595+ let existing = f
596+ .store
597+ .read_blob(f.id, &before, Path::new(&target))
598+ .await
599+ .unwrap()
600+ .content;
601+
602+ let author = df_store::Signature {
603+ name: "A".into(),
604+ email: "a@b.c".into(),
605+ when: chrono::Utc::now(),
606+ };
607+
608+ let outcome = f
609+ .store
610+ .commit_file(f.id, "main", &before, &target, existing, "noop", &author)
611+ .await
612+ .expect("commit_file");
613+
614+ assert_eq!(outcome, df_store::EditOutcome::Unchanged);
615+ assert_eq!(tip(&f).await, before, "the bookmark must not move");
616+}
617+
618+/// The concurrent-edit case. Without this the second writer silently discards
619+/// the first one's commit.
620+#[tokio::test]
621+async fn an_edit_against_a_stale_revision_is_refused() {
622+ let Some(f) = load("basic") else { return };
623+ let original = tip(&f).await;
624+
625+ let author = df_store::Signature {
626+ name: "A".into(),
627+ email: "a@b.c".into(),
628+ when: chrono::Utc::now(),
629+ };
630+
631+ // Somebody else lands a commit first.
632+ f.store
633+ .commit_file(
634+ f.id,
635+ "main",
636+ &original,
637+ "shared.txt",
638+ b"first writer\n".to_vec(),
639+ "first",
640+ &author,
641+ )
642+ .await
643+ .expect("first edit");
644+
645+ let moved = tip(&f).await;
646+ assert_ne!(moved, original);
647+
648+ // The second writer composed their edit against the original revision.
649+ let outcome = f
650+ .store
651+ .commit_file(
652+ f.id,
653+ "main",
654+ &original,
655+ "shared.txt",
656+ b"second writer\n".to_vec(),
657+ "second",
658+ &author,
659+ )
660+ .await
661+ .expect("commit_file");
662+
663+ match outcome {
664+ df_store::EditOutcome::Stale { current } => assert_eq!(current, moved),
665+ other => panic!("a stale edit must be refused, got {other:?}"),
666+ }
667+
668+ assert_eq!(tip(&f).await, moved, "nothing may be written");
669+ assert_eq!(
670+ f.store
671+ .read_blob(f.id, &moved, Path::new("shared.txt"))
672+ .await
673+ .unwrap()
674+ .text(),
675+ Some("first writer\n"),
676+ "the first writer's content must survive"
677+ );
678+}
679+
680+#[tokio::test]
681+async fn the_editor_write_path_refuses_traversal_and_oversized_content() {
682+ let Some(f) = load("basic") else { return };
683+ let before = tip(&f).await;
684+ let author = df_store::Signature {
685+ name: "A".into(),
686+ email: "a@b.c".into(),
687+ when: chrono::Utc::now(),
688+ };
689+
690+ for path in ["../escape.txt", "/etc/passwd", "a/../../b.txt", ""] {
691+ let e = f
692+ .store
693+ .commit_file(f.id, "main", &before, path, b"x".to_vec(), "m", &author)
694+ .await;
695+ assert!(e.is_err(), "{path} was accepted by the write path");
696+ }
697+
698+ let huge = vec![b'a'; 2 * 1024 * 1024];
699+ assert!(matches!(
700+ f.store
701+ .commit_file(f.id, "main", &before, "big.txt", huge, "m", &author)
702+ .await,
703+ Err(StoreError::TooLarge { .. })
704+ ));
705+
706+ assert_eq!(tip(&f).await, before, "no refused edit may move the bookmark");
707+}
Acrates/df-web/Cargo.toml+50−0
@@ −0,0 +1,50 @@
1+[package]
2+name = "df-web"
3+version.workspace = true
4+edition.workspace = true
5+rust-version.workspace = true
6+license.workspace = true
7+
8+[[bin]]
9+name = "dogfood-web"
10+path = "src/main.rs"
11+
12+[dependencies]
13+df-db = { path = "../df-db" }
14+df-auth = { path = "../df-auth" }
15+df-store = { path = "../df-store" }
16+df-render = { path = "../df-render" }
17+
18+anyhow.workspace = true
19+base64.workspace = true
20+axum.workspace = true
21+axum-extra.workspace = true
22+chrono.workspace = true
23+dotenvy.workspace = true
24+hex.workspace = true
25+hmac.workspace = true
26+maud.workspace = true
27+percent-encoding.workspace = true
28+rand.workspace = true
29+serde.workspace = true
30+serde_json.workspace = true
31+sha2.workspace = true
32+sqlx.workspace = true
33+subtle.workspace = true
34+tokio.workspace = true
35+tower.workspace = true
36+tower-http.workspace = true
37+tracing.workspace = true
38+tracing-subscriber.workspace = true
39+uuid.workspace = true
40+
41+# Parsing `_csrf` out of a urlencoded body on the no-JavaScript path.
42+# Streaming the request body into `git` on the push path.
43+futures = { workspace = true }
44+
45+form_urlencoded = "1"
46+# `axum-extra`'s cookie builder takes `time` types for expiry.
47+time = "0.3"
48+
49+[dev-dependencies]
50+async-trait.workspace = true
Acrates/df-web/assets/.gitignore+2−0
@@ −0,0 +1,2 @@
1+# Produced by the esbuild stage in docker/Dockerfile, not by hand.
2+editor.min.js
Acrates/df-web/assets/app.css2540 lines+2540−0
@@ −0,0 +1,2540 @@
1+/*
2+ * Dogfood design tokens and base styles.
3+ *
4+ * Ported from the Next.js design prototype (app/globals.css and
5+ * app/design/page.tsx), so the Maud UI matches the design that was signed off.
6+ *
7+ * Current revision carries the vermilion-brand redesign: a warm near-black
8+ * neutral ramp, `--brand` as the single personality accent, and self-hosted
9+ * IBM Plex / JetBrains Mono. `--action` is still the link colour — the two are
10+ * not interchangeable, and the design is careful about which is which.
11+ *
12+ * NOTE — deviation from spec §1, recorded deliberately: the spec chose Tailwind.
13+ * This is hand-authored CSS instead, because Tailwind would put a Node
14+ * toolchain in the release image for what is currently a small, stable set of
15+ * styles. The tokens below are the same ones the prototype defines, so moving
16+ * to Tailwind later is a mechanical change rather than a redesign.
17+ */
18+
19+:root {
20+ color-scheme: dark;
21+
22+ /* neutrals — warm near-black, dark is primary */
23+ --bg: #12100e;
24+ --surface: #1a1815;
25+ --surface-raised: #23201c;
26+ --border: #2f2b26;
27+ --border-strong: #423d36;
28+ --text: #ece7df;
29+ --text-dim: #a39c90;
30+ --text-faint: #6f6a5f;
31+
32+ /* brand — vermilion, the personality. Reserved for the wordmark, primary
33+ actions, and the one accent per section heading; it loses its force the
34+ moment it is used for ordinary body links. */
35+ --brand: #ff4d1f;
36+ --brand-ink: #12100e;
37+ --grid-line: rgb(255 255 255 / 0.022);
38+ --glow: rgb(255 77 31 / 0.14);
39+
40+ /* accents */
41+ --identity: #e6a92e;
42+ --action: #4fa3c7;
43+
44+ /* state */
45+ --open: #5aa86a;
46+ --merged: #5b86bd;
47+ --abandoned: #7c7568;
48+ --conflict: #db5a86;
49+ --danger: #e0603f;
50+
51+ /* diff */
52+ --diff-add-bg: #16281c;
53+ --diff-add-text: #82cf98;
54+ --diff-del-bg: #331c17;
55+ --diff-del-text: #e79a86;
56+ --diff-conflict-bg: #33202a;
57+ --diff-conflict-text: #e59ab5;
58+
59+ /* syntax highlighting (spec §8), warmed to sit on the near-black ground */
60+ --hl-comment: #7a7264;
61+ --hl-keyword: #e0799f;
62+ --hl-string: #96c47e;
63+ --hl-number: #e6a92e;
64+ --hl-function: #6fb3d1;
65+ --hl-type: #e0b060;
66+ --hl-constant: #e6a92e;
67+ --hl-variable: #ece7df;
68+ --hl-operator: #a39c90;
69+ --hl-tag: #e0799f;
70+ --hl-attribute: #6fb3d1;
71+ --hl-punctuation: #7c7568;
72+
73+ /* shape */
74+ --radius: 3px;
75+ --radius-sm: 2px;
76+
77+ /* Each stack keeps its system fallbacks after the hosted face, so a failed
78+ or blocked font request degrades to a near-enough system font rather than
79+ to a default serif. */
80+ --font-sans: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system,
81+ "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
82+ --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo,
83+ Consolas, "Liberation Mono", monospace;
84+ --font-condensed: "IBM Plex Sans Condensed", ui-sans-serif,
85+ "Roboto Condensed", "Arial Narrow", system-ui, sans-serif;
86+
87+ /* type scale, base 14 */
88+ --text-xs: 11px;
89+ --text-sm: 12.5px;
90+ --text-base: 14px;
91+ --text-md: 16px;
92+ --text-lg: 20px;
93+ --text-xl: 28px;
94+ --text-2xl: 40px;
95+ --text-3xl: 60px;
96+}
97+
98+:root.light,
99+:root[data-theme="light"] {
100+ color-scheme: light;
101+
102+ --bg: #f4f1ea;
103+ --surface: #fffdf9;
104+ --surface-raised: #fffdf9;
105+ --border: #e2ddd1;
106+ --border-strong: #c7c1b1;
107+ --text: #1c1813;
108+ --text-dim: #5f594e;
109+ --text-faint: #8b8477;
110+
111+ --brand: #e23c11;
112+ --brand-ink: #fffdf9;
113+ --grid-line: rgb(0 0 0 / 0.025);
114+ --glow: rgb(226 60 17 / 0.1);
115+
116+ --identity: #a06f0e;
117+ --action: #226b83;
118+
119+ --open: #2d7d3c;
120+ --merged: #3f5f9e;
121+ --abandoned: #6b6355;
122+ --conflict: #b23566;
123+ --danger: #b23c22;
124+
125+ --diff-add-bg: #e2f2e6;
126+ --diff-add-text: #1c6b31;
127+ --diff-del-bg: #fae6e0;
128+ --diff-del-text: #9e3b25;
129+ --diff-conflict-bg: #f7e5ec;
130+ --diff-conflict-text: #9e2b58;
131+
132+ --hl-comment: #837c6e;
133+ --hl-keyword: #a03060;
134+ --hl-string: #376b2c;
135+ --hl-number: #8a6510;
136+ --hl-function: #226b83;
137+ --hl-type: #855c14;
138+ --hl-constant: #8a6510;
139+ --hl-variable: #1c1813;
140+ --hl-operator: #5f594e;
141+ --hl-tag: #a03060;
142+ --hl-attribute: #226b83;
143+ --hl-punctuation: #8b8477;
144+}
145+
146+/* ─── fonts ──────────────────────────────────────────────────────────────── */
147+
148+/* Self-hosted rather than pulled from a CDN: the CSP is `font-src 'self'`, and
149+ a forge should not phone a third party on every page load.
150+ All Latin subsets — see `serve_font` in main.rs for why.
151+ `font-display: swap` so text is readable while the face is still in flight;
152+ the fallback stacks are chosen to be close enough in width that the swap
153+ does not reflow the page badly. */
154+
155+@font-face {
156+ font-family: "IBM Plex Sans";
157+ font-style: normal;
158+ font-weight: 400;
159+ font-display: swap;
160+ src: url("/assets/fonts/plex-sans-400.woff2") format("woff2");
161+}
162+
163+@font-face {
164+ font-family: "IBM Plex Sans";
165+ font-style: normal;
166+ font-weight: 500;
167+ font-display: swap;
168+ src: url("/assets/fonts/plex-sans-500.woff2") format("woff2");
169+}
170+
171+@font-face {
172+ font-family: "IBM Plex Sans";
173+ font-style: normal;
174+ font-weight: 600;
175+ font-display: swap;
176+ src: url("/assets/fonts/plex-sans-600.woff2") format("woff2");
177+}
178+
179+@font-face {
180+ font-family: "IBM Plex Sans Condensed";
181+ font-style: normal;
182+ font-weight: 500;
183+ font-display: swap;
184+ src: url("/assets/fonts/plex-condensed-500.woff2") format("woff2");
185+}
186+
187+@font-face {
188+ font-family: "IBM Plex Sans Condensed";
189+ font-style: normal;
190+ font-weight: 600;
191+ font-display: swap;
192+ src: url("/assets/fonts/plex-condensed-600.woff2") format("woff2");
193+}
194+
195+@font-face {
196+ font-family: "IBM Plex Sans Condensed";
197+ font-style: normal;
198+ font-weight: 700;
199+ font-display: swap;
200+ src: url("/assets/fonts/plex-condensed-700.woff2") format("woff2");
201+}
202+
203+@font-face {
204+ font-family: "JetBrains Mono";
205+ font-style: normal;
206+ font-weight: 400;
207+ font-display: swap;
208+ src: url("/assets/fonts/jetbrains-mono-400.woff2") format("woff2");
209+}
210+
211+@font-face {
212+ font-family: "JetBrains Mono";
213+ font-style: normal;
214+ font-weight: 500;
215+ font-display: swap;
216+ src: url("/assets/fonts/jetbrains-mono-500.woff2") format("woff2");
217+}
218+
219+*,
220+*::before,
221+*::after {
222+ box-sizing: border-box;
223+}
224+
225+html,
226+body {
227+ margin: 0;
228+ padding: 0;
229+}
230+
231+body {
232+ background: var(--bg);
233+ color: var(--text);
234+ font-family: var(--font-sans);
235+ font-size: 14px;
236+ line-height: 1.5;
237+ -webkit-font-smoothing: antialiased;
238+}
239+
240+::selection {
241+ background: color-mix(in srgb, var(--brand) 32%, transparent);
242+ color: var(--text);
243+}
244+
245+a,
246+button,
247+input,
248+select,
249+textarea,
250+[role="button"],
251+[tabindex] {
252+ transition:
253+ background-color 80ms ease-out,
254+ border-color 80ms ease-out,
255+ color 80ms ease-out,
256+ box-shadow 120ms ease-out,
257+ opacity 80ms ease-out;
258+}
259+
260+a {
261+ color: var(--action);
262+ text-decoration: none;
263+}
264+
265+a:hover {
266+ text-decoration: underline;
267+}
268+
269+code,
270+pre,
271+.mono {
272+ font-family: var(--font-mono);
273+ font-size: 12.5px;
274+}
275+
276+/* ─── layout ─────────────────────────────────────────────────────────────── */
277+
278+/* Sticky so the wordmark and search stay reachable down a long diff.
279+ `backdrop-filter` is an enhancement — the 85% background already keeps the
280+ text legible on browsers that ignore it. */
281+.masthead {
282+ position: sticky;
283+ top: 0;
284+ z-index: 30;
285+ border-bottom: 1px solid var(--border);
286+ background: color-mix(in srgb, var(--bg) 85%, transparent);
287+ backdrop-filter: blur(6px);
288+}
289+
290+.masthead-rule {
291+ display: block;
292+ width: 100%;
293+ height: 2px;
294+ background: var(--brand);
295+}
296+
297+.masthead-inner,
298+.wrap {
299+ max-width: 1180px;
300+ margin: 0 auto;
301+ padding: 0 20px;
302+}
303+
304+.masthead-inner {
305+ display: flex;
306+ align-items: center;
307+ gap: 16px;
308+ height: 48px;
309+}
310+
311+.brand {
312+ display: flex;
313+ align-items: center;
314+ gap: 8px;
315+ flex-shrink: 0;
316+ color: var(--text);
317+ font-family: var(--font-condensed);
318+ font-size: var(--text-md);
319+ font-weight: 700;
320+ text-transform: uppercase;
321+ letter-spacing: 0.02em;
322+}
323+
324+.brand:hover {
325+ text-decoration: none;
326+ color: var(--brand);
327+}
328+
329+.brand-mark {
330+ color: var(--brand);
331+ flex-shrink: 0;
332+}
333+
334+.masthead nav {
335+ display: flex;
336+ gap: 14px;
337+ margin-left: auto;
338+ align-items: center;
339+}
340+
341+.masthead nav a {
342+ color: var(--text-dim);
343+}
344+
345+.masthead nav a:hover {
346+ color: var(--text);
347+ text-decoration: none;
348+}
349+
350+main {
351+ padding: 28px 0 64px;
352+}
353+
354+/* ─── typography ─────────────────────────────────────────────────────────── */
355+
356+h1 {
357+ font-size: 20px;
358+ font-weight: 600;
359+ letter-spacing: -0.01em;
360+ margin: 0 0 6px;
361+}
362+
363+h2 {
364+ font-size: 15px;
365+ font-weight: 600;
366+ margin: 0 0 10px;
367+}
368+
369+.lede {
370+ color: var(--text-dim);
371+ margin: 0 0 24px;
372+ max-width: 60ch;
373+}
374+
375+.dim {
376+ color: var(--text-dim);
377+}
378+
379+.faint {
380+ color: var(--text-faint);
381+}
382+
383+.label-condensed {
384+ font-family: var(--font-condensed);
385+ font-weight: 500;
386+ font-size: var(--text-xs);
387+ line-height: 16px;
388+ letter-spacing: 0.08em;
389+ text-transform: uppercase;
390+ color: var(--text-dim);
391+}
392+
393+/* Section headings in the new design lead with a brand slash. It is
394+ decorative, so it is a pseudo-element rather than markup — a screen reader
395+ should hear "awaiting your review", not "slash awaiting your review". */
396+.label-condensed.slash::before {
397+ content: "/";
398+ color: var(--brand);
399+ margin-right: 0.4em;
400+}
401+
402+/* Big expressive display type. Condensed, tight, and set at a line-height
403+ below 1 — it only works at large sizes, so it is never applied below 20px. */
404+.display {
405+ font-family: var(--font-condensed);
406+ font-weight: 700;
407+ letter-spacing: -0.01em;
408+ line-height: 0.98;
409+}
410+
411+/* Marker-highlight on a single keyword in a headline. `box-decoration-break`
412+ keeps the highlight intact when the phrase wraps across lines. */
413+.mark {
414+ background: var(--brand);
415+ color: var(--brand-ink);
416+ padding: 0 0.18em;
417+ box-decoration-break: clone;
418+ -webkit-box-decoration-break: clone;
419+}
420+
421+.tnum {
422+ font-variant-numeric: tabular-nums;
423+}
424+
425+/* Faint engineering grid behind the page. Pure decoration, and deliberately
426+ near-invisible — at 2.2% it reads as texture rather than as a table. */
427+.grid-field {
428+ background-image:
429+ linear-gradient(var(--grid-line) 1px, transparent 1px),
430+ linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
431+ background-size: 44px 44px;
432+}
433+
434+/* Warm radial glow, anchored to the top-left of whatever it is applied to. */
435+.brand-glow {
436+ background: radial-gradient(120% 120% at 0% 0%, var(--glow), transparent 60%);
437+}
438+
439+/* Horizontal mono ticker strip along the bottom of the hero. */
440+.ticker-rule {
441+ border-top: 1px solid var(--border);
442+ border-bottom: 1px solid var(--border);
443+}
444+
445+.float-shadow {
446+ box-shadow: 0 6px 20px rgb(0 0 0 / 0.35);
447+}
448+
449+:root.light .float-shadow,
450+:root[data-theme="light"] .float-shadow {
451+ box-shadow: 0 6px 20px rgb(0 0 0 / 0.1);
452+}
453+
454+/* Brand hover accent for links that are not already brand-coloured. */
455+.link-brand {
456+ text-underline-offset: 3px;
457+ text-decoration-thickness: 1px;
458+}
459+
460+.link-brand:hover {
461+ color: var(--brand);
462+ text-decoration-line: underline;
463+ text-decoration-color: var(--brand);
464+}
465+
466+/* ─── surfaces ───────────────────────────────────────────────────────────── */
467+
468+.panel {
469+ background: var(--surface);
470+ border: 1px solid var(--border);
471+ border-radius: var(--radius);
472+ padding: 20px;
473+}
474+
475+.panel + .panel {
476+ margin-top: 16px;
477+}
478+
479+.stack {
480+ display: flex;
481+ flex-direction: column;
482+ gap: 16px;
483+}
484+
485+.row {
486+ display: flex;
487+ align-items: center;
488+ gap: 10px;
489+}
490+
491+/* ─── controls ───────────────────────────────────────────────────────────── */
492+
493+.btn {
494+ display: inline-flex;
495+ align-items: center;
496+ gap: 6px;
497+ padding: 6px 14px;
498+ border-radius: var(--radius);
499+ border: 1px solid var(--border-strong);
500+ background: var(--surface-raised);
501+ color: var(--text);
502+ font: inherit;
503+ font-size: 13px;
504+ cursor: pointer;
505+}
506+
507+.btn:hover {
508+ border-color: var(--text-faint);
509+ text-decoration: none;
510+}
511+
512+/* Brand, not action: the design reserves vermilion for the one primary move on
513+ a page. Ordinary links stay `--action` so the accent keeps its weight. */
514+.btn-primary {
515+ background: var(--brand);
516+ border-color: var(--brand);
517+ color: var(--brand-ink);
518+ font-family: var(--font-condensed);
519+ font-weight: 600;
520+ text-transform: uppercase;
521+ letter-spacing: 0.03em;
522+}
523+
524+.btn-primary:hover {
525+ filter: brightness(1.08);
526+}
527+
528+.btn-danger {
529+ border-color: var(--danger);
530+ color: var(--danger);
531+}
532+
533+/* Sign out is destructive enough to warn about but too routine to shout, so it
534+ sits quiet in the masthead and only reddens on approach. */
535+.btn-quiet-danger {
536+ background: transparent;
537+ border-color: transparent;
538+ color: var(--text-dim);
539+}
540+
541+.btn-quiet-danger:hover {
542+ border-color: color-mix(in srgb, var(--danger) 50%, transparent);
543+ color: var(--danger);
544+}
545+
546+input[type="text"],
547+input[type="password"],
548+textarea {
549+ width: 100%;
550+ padding: 7px 10px;
551+ background: var(--bg);
552+ border: 1px solid var(--border-strong);
553+ border-radius: var(--radius);
554+ color: var(--text);
555+ font: inherit;
556+ font-size: 13px;
557+}
558+
559+input:focus,
560+textarea:focus,
561+.btn:focus-visible,
562+a:focus-visible {
563+ outline: 2px solid var(--brand);
564+ outline-offset: 1px;
565+}
566+
567+.field {
568+ margin-bottom: 14px;
569+}
570+
571+.field label {
572+ display: block;
573+ margin-bottom: 5px;
574+ font-size: 12px;
575+ color: var(--text-dim);
576+}
577+
578+.hint {
579+ margin-top: 5px;
580+ font-size: 12px;
581+ color: var(--text-faint);
582+}
583+
584+/* ─── chips and states ───────────────────────────────────────────────────── */
585+
586+.chip {
587+ display: inline-flex;
588+ align-items: center;
589+ padding: 1px 7px;
590+ border-radius: var(--radius-sm);
591+ border: 1px solid var(--border-strong);
592+ font-family: var(--font-mono);
593+ font-size: 11.5px;
594+ color: var(--text-dim);
595+}
596+
597+/* The change-identity chip. jj change ids are the product's central concept,
598+ so they get the one strong accent colour in the palette. */
599+.chip-change {
600+ color: var(--identity);
601+ border-color: color-mix(in srgb, var(--identity) 40%, transparent);
602+ background: color-mix(in srgb, var(--identity) 10%, transparent);
603+}
604+
605+.badge {
606+ display: inline-flex;
607+ align-items: center;
608+ padding: 2px 8px;
609+ border-radius: 999px;
610+ font-size: 11.5px;
611+ font-weight: 500;
612+ color: var(--brand-ink);
613+}
614+
615+.badge-open { background: var(--open); }
616+.badge-merged { background: var(--merged); }
617+.badge-abandoned { background: var(--abandoned); }
618+.badge-conflict { background: var(--conflict); }
619+
620+/* ─── notices ────────────────────────────────────────────────────────────── */
621+
622+.banner {
623+ padding: 12px 16px;
624+ border-radius: var(--radius);
625+ border: 1px solid var(--border-strong);
626+ background: var(--surface);
627+ margin-bottom: 20px;
628+}
629+
630+.banner-error {
631+ border-color: color-mix(in srgb, var(--danger) 50%, transparent);
632+ background: color-mix(in srgb, var(--danger) 12%, transparent);
633+}
634+
635+.banner-ok {
636+ border-color: color-mix(in srgb, var(--open) 50%, transparent);
637+ background: color-mix(in srgb, var(--open) 12%, transparent);
638+}
639+
640+/* ─── empty states ───────────────────────────────────────────────────────── */
641+
642+.empty {
643+ text-align: center;
644+ padding: 56px 20px;
645+ color: var(--text-dim);
646+ border: 1px dashed var(--border-strong);
647+ border-radius: var(--radius);
648+}
649+
650+.empty h2 {
651+ color: var(--text);
652+}
653+
654+/* ─── code blocks ────────────────────────────────────────────────────────── */
655+
656+.clone-box {
657+ display: flex;
658+ align-items: center;
659+ gap: 8px;
660+ background: var(--bg);
661+ border: 1px solid var(--border);
662+ border-radius: var(--radius);
663+ padding: 8px 10px;
664+ overflow-x: auto;
665+}
666+
667+.clone-box code {
668+ white-space: nowrap;
669+ color: var(--text-dim);
670+}
671+
672+footer {
673+ border-top: 1px solid var(--border);
674+ margin-top: 48px;
675+ padding: 20px 0;
676+ color: var(--text-faint);
677+ font-size: 12px;
678+}
679+
680+footer .wrap {
681+ display: flex;
682+ gap: 8px 16px;
683+ align-items: center;
684+ flex-wrap: wrap;
685+}
686+
687+footer a {
688+ color: var(--text-faint);
689+}
690+
691+.footer-mark {
692+ font-family: var(--font-condensed);
693+ font-weight: 700;
694+ text-transform: uppercase;
695+ letter-spacing: 0.04em;
696+ color: var(--text-dim);
697+}
698+
699+/* Pushed to the far end rather than floated, so it stays put when the row
700+ wraps on a narrow screen. */
701+.footer-end {
702+ margin-left: auto;
703+}
704+
705+/* Respect a user's reduced-motion preference (spec §11 accessibility pass). */
706+@media (prefers-reduced-motion: reduce) {
707+ *,
708+ *::before,
709+ *::after {
710+ animation-duration: 0.01ms !important;
711+ transition-duration: 0.01ms !important;
712+ }
713+}
714+
715+.live-indicator {
716+ display: inline-flex;
717+ align-items: center;
718+ gap: 6px;
719+}
720+
721+.live-dot {
722+ width: 6px;
723+ height: 6px;
724+ border-radius: 50%;
725+ background: var(--merged);
726+ animation: live-pulse 2s ease-in-out infinite;
727+}
728+
729+@keyframes live-pulse {
730+ 0%, 100% { opacity: 1; }
731+ 50% { opacity: 0.35; }
732+}
733+
734+
735+/* ─── landing and dashboard ────────────────────────────────────────────────── */
736+
737+.hero {
738+ border: 1px solid var(--border);
739+ border-radius: var(--radius);
740+ background: var(--surface);
741+ overflow: hidden;
742+}
743+
744+.hero-body {
745+ padding: 40px 40px 36px;
746+}
747+
748+.hero-eyebrow {
749+ display: flex;
750+ align-items: center;
751+ gap: 8px;
752+ margin-bottom: 20px;
753+}
754+
755+.pill-brand {
756+ display: inline-flex;
757+ align-items: center;
758+ height: 20px;
759+ padding: 0 6px;
760+ border-radius: var(--radius);
761+ background: var(--brand);
762+ color: var(--brand-ink);
763+ font-family: var(--font-mono);
764+ font-size: var(--text-xs);
765+ font-weight: 500;
766+}
767+
768+.hero-title {
769+ font-size: var(--text-2xl);
770+ max-width: 20ch;
771+ margin: 0;
772+ text-wrap: balance;
773+}
774+
775+.hero-lede {
776+ max-width: 54ch;
777+ margin: 24px 0 0;
778+ font-size: var(--text-md);
779+ line-height: 1.55;
780+ color: var(--text-dim);
781+}
782+
783+.hero-actions {
784+ display: flex;
785+ flex-wrap: wrap;
786+ gap: 8px;
787+ margin-top: 28px;
788+}
789+
790+.btn-lg {
791+ height: 38px;
792+ padding: 0 20px;
793+}
794+
795+.hero .clone-box {
796+ margin-top: 24px;
797+ max-width: 54ch;
798+}
799+
800+.ticker {
801+ display: flex;
802+ flex-wrap: wrap;
803+ gap: 4px 20px;
804+ padding: 10px 40px;
805+}
806+
807+.ticker-item {
808+ display: flex;
809+ align-items: center;
810+ gap: 8px;
811+ font-family: var(--font-mono);
812+ font-size: var(--text-xs);
813+ color: var(--text-faint);
814+ white-space: nowrap;
815+}
816+
817+.ticker-dot {
818+ color: var(--brand);
819+}
820+
821+@media (max-width: 700px) {
822+ .hero-body {
823+ padding: 28px 20px 24px;
824+ }
825+ .ticker {
826+ padding: 10px 20px;
827+ }
828+ .hero-title {
829+ font-size: var(--text-xl);
830+ }
831+}
832+
833+/* Two-column body shared by the landing page and the dashboard. */
834+.landing-columns {
835+ display: flex;
836+ flex-direction: column;
837+ gap: 32px;
838+ margin-top: 40px;
839+}
840+
841+.landing-main {
842+ min-width: 0;
843+ flex: 1;
844+ display: flex;
845+ flex-direction: column;
846+ gap: 32px;
847+}
848+
849+.landing-aside {
850+ width: 100%;
851+ flex-shrink: 0;
852+ display: flex;
853+ flex-direction: column;
854+ gap: 32px;
855+}
856+
857+@media (min-width: 900px) {
858+ .landing-columns {
859+ flex-direction: row;
860+ }
861+ .landing-aside {
862+ width: 320px;
863+ }
864+}
865+
866+.section-head {
867+ display: flex;
868+ align-items: baseline;
869+ justify-content: space-between;
870+ gap: 12px;
871+ border-bottom: 1px solid var(--border);
872+ padding-bottom: 8px;
873+}
874+
875+.section-note {
876+ margin: 8px 0 12px;
877+}
878+
879+/* ─── feed ─────────────────────────────────────────────────────────────────── */
880+
881+.feed {
882+ list-style: none;
883+ margin: 8px 0 0;
884+ padding: 0;
885+ border: 1px solid var(--border);
886+ border-radius: var(--radius);
887+}
888+
889+.feed-row {
890+ position: relative;
891+ display: flex;
892+ align-items: flex-start;
893+ gap: 12px;
894+ padding: 12px 12px 12px 16px;
895+ border-bottom: 1px solid var(--border);
896+ overflow: hidden;
897+}
898+
899+.feed-row:last-child {
900+ border-bottom: none;
901+}
902+
903+/* A hover rail rather than a background wash: it marks the row without
904+ changing the contrast of the text sitting on it. */
905+.feed-row::before {
906+ content: "";
907+ position: absolute;
908+ inset-block: 0;
909+ left: 0;
910+ width: 2px;
911+ background: var(--brand);
912+ opacity: 0;
913+ transition: opacity 80ms ease-out;
914+}
915+
916+.feed-row:hover::before {
917+ opacity: 1;
918+}
919+
920+.feed-main {
921+ min-width: 0;
922+ flex: 1;
923+}
924+
925+.feed-title {
926+ display: block;
927+ color: var(--text);
928+ font-weight: 500;
929+ overflow: hidden;
930+ text-overflow: ellipsis;
931+ white-space: nowrap;
932+}
933+
934+.feed-title:hover {
935+ color: var(--brand);
936+ text-decoration: none;
937+}
938+
939+.feed-meta {
940+ display: flex;
941+ flex-wrap: wrap;
942+ align-items: center;
943+ gap: 4px 8px;
944+ margin-top: 4px;
945+ font-size: var(--text-xs);
946+ color: var(--text-faint);
947+}
948+
949+.feed-actor {
950+ color: var(--text-dim);
951+ font-weight: 500;
952+}
953+
954+.feed-side {
955+ display: flex;
956+ flex-shrink: 0;
957+ flex-direction: column;
958+ align-items: flex-end;
959+ gap: 4px;
960+}
961+
962+/* ─── activity ─────────────────────────────────────────────────────────────── */
963+
964+.activity {
965+ list-style: none;
966+ margin: 4px 0 0;
967+ padding: 0;
968+}
969+
970+.activity-row {
971+ display: flex;
972+ flex-wrap: wrap;
973+ align-items: baseline;
974+ gap: 0 6px;
975+ padding: 7px 0;
976+ border-bottom: 1px dashed var(--border);
977+ font-size: var(--text-sm);
978+}
979+
980+.activity-row:last-child {
981+ border-bottom: none;
982+}
983+
984+.activity-actor {
985+ color: var(--text);
986+ font-weight: 500;
987+}
988+
989+.activity-when {
990+ margin-left: auto;
991+ font-size: var(--text-xs);
992+}
993+
994+/* ─── comparison list ──────────────────────────────────────────────────────── */
995+
996+.compare {
997+ list-style: none;
998+ margin: 12px 0 0;
999+ padding: 0;
1000+ display: flex;
1001+ flex-direction: column;
1002+ gap: 10px;
1003+}
1004+
1005+.compare-item {
1006+ display: flex;
1007+ flex-direction: column;
1008+ gap: 4px;
1009+ padding: 12px;
1010+ border: 1px solid var(--border);
1011+ border-radius: var(--radius);
1012+ background: var(--surface);
1013+}
1014+
1015+.compare-them,
1016+.compare-us {
1017+ display: flex;
1018+ align-items: baseline;
1019+ gap: 8px;
1020+ font-size: var(--text-sm);
1021+}
1022+
1023+.compare-them {
1024+ color: var(--text-faint);
1025+ text-decoration: line-through;
1026+ text-decoration-color: color-mix(in srgb, var(--conflict) 60%, transparent);
1027+}
1028+
1029+.compare-us {
1030+ color: var(--text);
1031+}
1032+
1033+.compare-sign {
1034+ font-family: var(--font-mono);
1035+ flex-shrink: 0;
1036+}
1037+
1038+.compare-them .compare-sign {
1039+ color: var(--conflict);
1040+}
1041+
1042+.compare-us .compare-sign {
1043+ color: var(--merged);
1044+}
1045+
1046+/* ─── repo cards ───────────────────────────────────────────────────────────── */
1047+
1048+.repo-cards {
1049+ display: flex;
1050+ flex-direction: column;
1051+ gap: 8px;
1052+ margin-top: 12px;
1053+}
1054+
1055+.repo-card {
1056+ position: relative;
1057+ display: flex;
1058+ flex-direction: column;
1059+ gap: 6px;
1060+ padding: 12px 12px 12px 16px;
1061+ border: 1px solid var(--border);
1062+ border-radius: var(--radius);
1063+ background: var(--surface);
1064+ color: var(--text);
1065+ overflow: hidden;
1066+}
1067+
1068+.repo-card:hover {
1069+ border-color: var(--border-strong);
1070+ text-decoration: none;
1071+}
1072+
1073+.repo-card-rail {
1074+ position: absolute;
1075+ inset-block: 0;
1076+ left: 0;
1077+ width: 2px;
1078+ background: var(--brand);
1079+ opacity: 0;
1080+ transition: opacity 80ms ease-out;
1081+}
1082+
1083+.repo-card:hover .repo-card-rail {
1084+ opacity: 1;
1085+}
1086+
1087+.repo-card-name {
1088+ display: flex;
1089+ align-items: center;
1090+ gap: 6px;
1091+ font-size: var(--text-sm);
1092+}
1093+
1094+.repo-card-repo {
1095+ font-weight: 500;
1096+}
1097+
1098+.repo-card:hover .repo-card-repo {
1099+ color: var(--brand);
1100+}
1101+
1102+.repo-card-desc {
1103+ font-size: var(--text-xs);
1104+ color: var(--text-dim);
1105+}
1106+
1107+.repo-card-new {
1108+ padding: 9px 12px;
1109+ border: 1px dashed var(--border);
1110+ border-radius: var(--radius);
1111+ color: var(--text-dim);
1112+ font-size: var(--text-sm);
1113+ text-align: center;
1114+}
1115+
1116+.repo-card-new:hover {
1117+ border-color: var(--brand);
1118+ color: var(--brand);
1119+ text-decoration: none;
1120+}
1121+
1122+/* ─── call to action band ──────────────────────────────────────────────────── */
1123+
1124+.cta-band {
1125+ margin-top: 48px;
1126+ padding: 44px 24px;
1127+ border: 1px solid var(--border);
1128+ border-radius: var(--radius);
1129+ background: var(--surface);
1130+ text-align: center;
1131+}
1132+
1133+.cta-title {
1134+ margin: 0 auto;
1135+ max-width: 22ch;
1136+ font-size: var(--text-xl);
1137+ text-wrap: balance;
1138+}
1139+
1140+.cta-lede {
1141+ margin: 16px auto 0;
1142+ max-width: 48ch;
1143+ font-size: var(--text-md);
1144+ color: var(--text-dim);
1145+}
1146+
1147+.cta-actions {
1148+ justify-content: center;
1149+}
1150+
1151+/* ─── dashboard header ─────────────────────────────────────────────────────── */
1152+
1153+.dash-head {
1154+ display: flex;
1155+ flex-wrap: wrap;
1156+ align-items: flex-end;
1157+ justify-content: space-between;
1158+ gap: 12px;
1159+ padding-bottom: 16px;
1160+ border-bottom: 1px solid var(--border);
1161+}
1162+
1163+.dash-title {
1164+ font-size: var(--text-lg);
1165+ margin: 0 0 4px;
1166+}
1167+
1168+.dash-name {
1169+ color: var(--brand);
1170+}
1171+
1172+/* The dashboard's own column block already sits under a header rule. */
1173+.dash-head + .landing-columns {
1174+ margin-top: 24px;
1175+}
1176+
1177+/* ─── design system pages ──────────────────────────────────────────────────── */
1178+
1179+.design-head,
1180+.rationale > header {
1181+ padding-bottom: 20px;
1182+ border-bottom: 1px solid var(--border);
1183+ margin-bottom: 8px;
1184+}
1185+
1186+.design-title {
1187+ margin: 0 0 6px;
1188+ font-size: var(--text-xl);
1189+}
1190+
1191+.design-section {
1192+ padding: 28px 0;
1193+ border-bottom: 1px solid var(--border);
1194+}
1195+
1196+.design-section:last-child {
1197+ border-bottom: none;
1198+}
1199+
1200+.design-section-title {
1201+ margin: 0 0 16px;
1202+ font-size: var(--text-md);
1203+}
1204+
1205+.design-row {
1206+ display: flex;
1207+ flex-wrap: wrap;
1208+ align-items: center;
1209+ gap: 10px;
1210+ margin-bottom: 12px;
1211+}
1212+
1213+.swatch-group-title {
1214+ margin: 16px 0 8px;
1215+ font-family: var(--font-condensed);
1216+ font-size: var(--text-xs);
1217+ font-weight: 500;
1218+ letter-spacing: 0.08em;
1219+ text-transform: uppercase;
1220+ color: var(--text-dim);
1221+}
1222+
1223+.swatches {
1224+ display: grid;
1225+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
1226+ gap: 8px;
1227+}
1228+
1229+.swatch {
1230+ display: flex;
1231+ align-items: center;
1232+ gap: 10px;
1233+ padding: 8px 10px;
1234+ border: 1px solid var(--border);
1235+ border-radius: var(--radius);
1236+ background: var(--surface);
1237+ font-size: var(--text-xs);
1238+}
1239+
1240+/* Bordered so a swatch whose colour is close to the surface it sits on is
1241+ still legible as a swatch. */
1242+.swatch-chip {
1243+ width: 22px;
1244+ height: 22px;
1245+ flex-shrink: 0;
1246+ border: 1px solid var(--border-strong);
1247+ border-radius: var(--radius-sm);
1248+}
1249+
1250+.swatch-name {
1251+ font-size: var(--text-xs);
1252+}
1253+
1254+.type-specimens > * {
1255+ margin: 0 0 12px;
1256+}
1257+
1258+.rationale {
1259+ max-width: 68ch;
1260+}
1261+
1262+.rationale section {
1263+ margin-top: 28px;
1264+}
1265+
1266+.rationale h2 {
1267+ font-size: var(--text-md);
1268+ margin-bottom: 8px;
1269+}
1270+
1271+.rationale p {
1272+ line-height: 1.6;
1273+ margin: 0 0 12px;
1274+}
1275+
1276+/* ─── repository header ────────────────────────────────────────────────────── */
1277+
1278+.repo-head {
1279+ margin-bottom: 20px;
1280+}
1281+
1282+.repo-title {
1283+ display: flex;
1284+ align-items: baseline;
1285+ gap: 6px;
1286+ margin: 0;
1287+ font-family: var(--font-condensed);
1288+ font-size: var(--text-lg);
1289+}
1290+
1291+.repo-owner {
1292+ font-weight: 600;
1293+ color: var(--text-dim);
1294+}
1295+
1296+.repo-owner:hover {
1297+ color: var(--text);
1298+ text-decoration: none;
1299+}
1300+
1301+.repo-slash {
1302+ color: var(--brand);
1303+ user-select: none;
1304+}
1305+
1306+.repo-name {
1307+ font-weight: 700;
1308+ color: var(--text);
1309+}
1310+
1311+.repo-name:hover {
1312+ color: var(--brand);
1313+ text-decoration: none;
1314+}
1315+
1316+.repo-desc {
1317+ margin: 8px 0 0;
1318+}
1319+
1320+.repo-tabs {
1321+ margin-top: 14px;
1322+ margin-bottom: 0;
1323+}
1324+
1325+/* Pushed to the end of the tab strip rather than floated, so it keeps its
1326+ place in the tab order. */
1327+.subtabs-end {
1328+ margin-left: auto;
1329+}
1330+
1331+/* ─── sign in ──────────────────────────────────────────────────────────────── */
1332+
1333+.signin {
1334+ max-width: 380px;
1335+ margin: 48px auto;
1336+ display: flex;
1337+ flex-direction: column;
1338+ gap: 24px;
1339+}
1340+
1341+.signin-intro {
1342+ display: flex;
1343+ flex-direction: column;
1344+ gap: 8px;
1345+}
1346+
1347+.signin-eyebrow {
1348+ color: var(--brand);
1349+}
1350+
1351+.signin-title {
1352+ margin: 0;
1353+ font-size: var(--text-xl);
1354+ text-wrap: balance;
1355+}
1356+
1357+.btn-block {
1358+ display: flex;
1359+ justify-content: center;
1360+ width: 100%;
1361+ height: 38px;
1362+}
1363+
1364+/* A control that is present but not offered. Not a `<button disabled>` because
1365+ it is a `<span>` — there is nothing to submit, and nothing to focus. */
1366+.is-disabled {
1367+ opacity: 0.5;
1368+ cursor: not-allowed;
1369+}
1370+
1371+.is-disabled:hover {
1372+ border-color: var(--border-strong);
1373+}
1374+
1375+.signin-or {
1376+ display: flex;
1377+ align-items: center;
1378+ gap: 12px;
1379+}
1380+
1381+.signin-rule {
1382+ flex: 1;
1383+ height: 1px;
1384+ background: var(--border);
1385+}
1386+
1387+.signin-foot {
1388+ margin: 0;
1389+ text-align: center;
1390+}
1391+
1392+/* ─── segmented control ────────────────────────────────────────────────────── */
1393+
1394+/* Two links styled as one control. Links rather than buttons because each half
1395+ is a real, addressable URL — see `blob_view`. */
1396+.segmented {
1397+ display: inline-flex;
1398+ overflow: hidden;
1399+ border: 1px solid var(--border);
1400+ border-radius: var(--radius);
1401+}
1402+
1403+.segmented-item {
1404+ padding: 3px 10px;
1405+ font-size: var(--text-xs);
1406+ font-weight: 500;
1407+ color: var(--text-dim);
1408+}
1409+
1410+.segmented-item + .segmented-item {
1411+ border-left: 1px solid var(--border);
1412+}
1413+
1414+.segmented-item:hover {
1415+ background: var(--surface-raised);
1416+ color: var(--text);
1417+ text-decoration: none;
1418+}
1419+
1420+.segmented-item.is-on {
1421+ background: var(--brand);
1422+ color: var(--brand-ink);
1423+}
1424+
1425+.segmented-item.is-on:hover {
1426+ background: var(--brand);
1427+ color: var(--brand-ink);
1428+}
1429+
1430+/* ─── file listing ─────────────────────────────────────────────────────────── */
1431+
1432+.avatar {
1433+ display: inline-flex;
1434+ align-items: center;
1435+ justify-content: center;
1436+ flex-shrink: 0;
1437+ width: 20px;
1438+ height: 20px;
1439+ border: 1px solid var(--border-strong);
1440+ border-radius: var(--radius);
1441+ background: var(--surface);
1442+ color: var(--text-dim);
1443+ font-family: var(--font-mono);
1444+ font-size: var(--text-xs);
1445+ font-weight: 500;
1446+ text-transform: uppercase;
1447+ user-select: none;
1448+}
1449+
1450+.tree-crumbs {
1451+ margin-bottom: 14px;
1452+ flex-wrap: wrap;
1453+}
1454+
1455+/* The tip-of-branch bar. Sits directly on top of the listing and shares its
1456+ border, so the two read as one object. */
1457+.commit-bar {
1458+ display: flex;
1459+ align-items: center;
1460+ gap: 10px;
1461+ padding: 8px 12px;
1462+ border: 1px solid var(--border);
1463+ border-radius: var(--radius) var(--radius) 0 0;
1464+ background: var(--surface);
1465+ font-size: var(--text-sm);
1466+}
1467+
1468+.commit-bar-author {
1469+ font-weight: 500;
1470+ flex-shrink: 0;
1471+}
1472+
1473+.commit-bar-msg {
1474+ min-width: 0;
1475+ flex: 1;
1476+ color: var(--text-dim);
1477+ overflow: hidden;
1478+ text-overflow: ellipsis;
1479+ white-space: nowrap;
1480+}
1481+
1482+.commit-bar-chip,
1483+.commit-bar-when {
1484+ flex-shrink: 0;
1485+}
1486+
1487+@media (max-width: 620px) {
1488+ .commit-bar-chip {
1489+ display: none;
1490+ }
1491+}
1492+
1493+.filelist {
1494+ border: 1px solid var(--border);
1495+ border-top: none;
1496+ border-radius: 0 0 var(--radius) var(--radius);
1497+ overflow: hidden;
1498+}
1499+
1500+/* When there is no commit bar above it, the listing is the whole object and
1501+ needs its own top edge back. */
1502+.filelist-standalone {
1503+ border-top: 1px solid var(--border);
1504+ border-radius: var(--radius);
1505+}
1506+
1507+.filelist table {
1508+ width: 100%;
1509+ border-collapse: collapse;
1510+}
1511+
1512+.filelist tr {
1513+ border-bottom: 1px solid var(--border);
1514+}
1515+
1516+.filelist tr:last-child {
1517+ border-bottom: none;
1518+}
1519+
1520+.filelist tr:hover {
1521+ background: var(--surface);
1522+}
1523+
1524+.filelist td {
1525+ padding: 6px 12px 6px 0;
1526+ vertical-align: middle;
1527+}
1528+
1529+.filelist-icon {
1530+ width: 1px;
1531+ padding-left: 12px !important;
1532+ padding-right: 8px !important;
1533+ line-height: 0;
1534+}
1535+
1536+.icon-dir {
1537+ color: var(--brand);
1538+}
1539+
1540+.icon-file {
1541+ color: var(--text-faint);
1542+}
1543+
1544+.filelist-name {
1545+ max-width: 0;
1546+}
1547+
1548+.filelist-name a {
1549+ color: var(--text);
1550+ overflow: hidden;
1551+ text-overflow: ellipsis;
1552+ white-space: nowrap;
1553+ display: inline-block;
1554+ max-width: 100%;
1555+ vertical-align: bottom;
1556+}
1557+
1558+.filelist-name a.is-dir {
1559+ font-weight: 500;
1560+}
1561+
1562+.filelist tr:hover .filelist-name a {
1563+ color: var(--brand);
1564+ text-decoration: underline;
1565+}
1566+
1567+.filelist-note {
1568+ margin-left: 8px;
1569+ font-size: var(--text-xs);
1570+}
1571+
1572+.filelist-size {
1573+ width: 100px;
1574+ text-align: right;
1575+ white-space: nowrap;
1576+ font-size: var(--text-xs);
1577+}
1578+
1579+/* ─── readme ───────────────────────────────────────────────────────────────── */
1580+
1581+.readme {
1582+ margin-top: 24px;
1583+ border: 1px solid var(--border);
1584+ border-radius: var(--radius);
1585+}
1586+
1587+.readme-head {
1588+ display: flex;
1589+ align-items: center;
1590+ gap: 8px;
1591+ padding: 8px 16px;
1592+ border-bottom: 1px solid var(--border);
1593+ background: var(--surface);
1594+ line-height: 1;
1595+}
1596+
1597+.readme-body {
1598+ padding: 16px 20px;
1599+}
1600+
1601+.readme-body > :first-child {
1602+ margin-top: 0;
1603+}
1604+
1605+.readme-body > :last-child {
1606+ margin-bottom: 0;
1607+}
1608+
1609+/* Typography for rendered markdown: READMEs, issue/change descriptions, and
1610+ comments all go through the same comrak pipeline and land here via
1611+ `PreEscaped`, so one set of tag selectors styles every one of them. */
1612+.markdown-body h1,
1613+.markdown-body h2,
1614+.markdown-body h3,
1615+.markdown-body h4,
1616+.markdown-body h5,
1617+.markdown-body h6 {
1618+ font-family: var(--font-condensed);
1619+ font-weight: 600;
1620+ color: var(--text);
1621+ line-height: 1.25;
1622+}
1623+
1624+.markdown-body h1 {
1625+ margin: 24px 0 12px;
1626+ font-size: var(--text-xl);
1627+ font-weight: 700;
1628+}
1629+
1630+.markdown-body h2 {
1631+ margin: 28px 0 8px;
1632+ padding-bottom: 6px;
1633+ border-bottom: 1px solid var(--border);
1634+ font-size: var(--text-lg);
1635+}
1636+
1637+.markdown-body h3 {
1638+ margin: 20px 0 8px;
1639+ font-size: var(--text-md);
1640+}
1641+
1642+.markdown-body h4,
1643+.markdown-body h5,
1644+.markdown-body h6 {
1645+ margin: 16px 0 6px;
1646+ font-size: var(--text-base);
1647+ font-weight: 500;
1648+}
1649+
1650+.markdown-body p {
1651+ margin: 12px 0;
1652+ line-height: 1.6;
1653+ color: var(--text-dim);
1654+}
1655+
1656+.markdown-body a {
1657+ color: var(--text);
1658+ text-decoration: underline;
1659+ text-decoration-color: var(--border-strong);
1660+ text-underline-offset: 2px;
1661+}
1662+
1663+.markdown-body a:hover {
1664+ text-decoration-color: var(--brand);
1665+ color: var(--brand);
1666+}
1667+
1668+.markdown-body strong {
1669+ font-weight: 600;
1670+ color: var(--text);
1671+}
1672+
1673+.markdown-body code {
1674+ padding: 0.1em 0.35em;
1675+ border: 1px solid var(--border);
1676+ border-radius: var(--radius-sm);
1677+ background: var(--surface-raised);
1678+ color: var(--brand);
1679+ font-family: var(--font-mono);
1680+ font-size: 0.85em;
1681+}
1682+
1683+.markdown-body pre {
1684+ margin: 16px 0;
1685+ padding: 12px;
1686+ overflow-x: auto;
1687+ border: 1px solid var(--border);
1688+ border-radius: var(--radius);
1689+ background: var(--surface);
1690+}
1691+
1692+.markdown-body pre code {
1693+ padding: 0;
1694+ border: none;
1695+ background: none;
1696+ color: var(--text-dim);
1697+ font-size: var(--text-sm);
1698+ line-height: 1.6;
1699+}
1700+
1701+.markdown-body blockquote {
1702+ margin: 16px 0;
1703+ padding-left: 16px;
1704+ border-left: 2px solid var(--brand);
1705+ color: var(--text-dim);
1706+ font-style: italic;
1707+}
1708+
1709+.markdown-body hr {
1710+ margin: 24px 0;
1711+ border: none;
1712+ border-top: 1px solid var(--border);
1713+}
1714+
1715+.markdown-body ul,
1716+.markdown-body ol {
1717+ margin: 12px 0;
1718+ padding-left: 22px;
1719+ color: var(--text-dim);
1720+ line-height: 1.6;
1721+}
1722+
1723+.markdown-body li + li {
1724+ margin-top: 4px;
1725+}
1726+
1727+.markdown-body li:has(> input[type="checkbox"]) {
1728+ list-style: none;
1729+ margin-left: -22px;
1730+}
1731+
1732+.markdown-body table {
1733+ width: 100%;
1734+ margin: 16px 0;
1735+ border: 1px solid var(--border);
1736+ border-radius: var(--radius);
1737+ border-collapse: collapse;
1738+ font-size: var(--text-sm);
1739+}
1740+
1741+.markdown-body th {
1742+ padding: 6px 10px;
1743+ border-bottom: 1px solid var(--border);
1744+ background: var(--surface);
1745+ text-align: left;
1746+ font-family: var(--font-condensed);
1747+ font-weight: 600;
1748+ text-transform: uppercase;
1749+ letter-spacing: 0.03em;
1750+ color: var(--text-dim);
1751+}
1752+
1753+.markdown-body td {
1754+ padding: 6px 10px;
1755+ border-bottom: 1px solid var(--border);
1756+ color: var(--text-dim);
1757+ vertical-align: top;
1758+}
1759+
1760+.markdown-body tr:last-child td {
1761+ border-bottom: none;
1762+}
1763+
1764+.markdown-body img {
1765+ max-width: 100%;
1766+}
1767+
1768+/* ─── code listings ────────────────────────────────────────────────────────── */
1769+
1770+.codeblock {
1771+ overflow-x: auto;
1772+ background: var(--bg);
1773+ border: 1px solid var(--border);
1774+ border-radius: var(--radius);
1775+}
1776+
1777+.codetable {
1778+ border-collapse: collapse;
1779+ width: 100%;
1780+}
1781+
1782+.codetable .lineno {
1783+ text-align: right;
1784+ padding: 0 10px;
1785+ user-select: none;
1786+ width: 1%;
1787+ vertical-align: top;
1788+}
1789+
1790+.codetable .lineno a {
1791+ color: inherit;
1792+}
1793+
1794+.codetable .codeline {
1795+ padding: 0 10px;
1796+ white-space: pre;
1797+}
1798+
1799+/* The link target for a permalinked line. `:target` needs no JavaScript, which
1800+ is what keeps line links working with scripting disabled (spec §7). */
1801+.codetable tr:target {
1802+ background: color-mix(in srgb, var(--identity) 14%, transparent);
1803+}
1804+
1805+/* ─── syntax tokens ────────────────────────────────────────────────────────── */
1806+
1807+/* Class names come from df-render's HIGHLIGHT_NAMES table: `function.builtin`
1808+ emits both `hl-function` and `hl-function-builtin`, so a family can be themed
1809+ once here and refined where it is worth distinguishing. */
1810+
1811+.hl-comment { color: var(--hl-comment); font-style: italic; }
1812+.hl-keyword { color: var(--hl-keyword); }
1813+.hl-string { color: var(--hl-string); }
1814+.hl-number,
1815+.hl-boolean,
1816+.hl-character { color: var(--hl-number); }
1817+.hl-constant { color: var(--hl-constant); }
1818+.hl-function { color: var(--hl-function); }
1819+.hl-constructor { color: var(--hl-type); }
1820+.hl-type { color: var(--hl-type); }
1821+.hl-variable { color: var(--hl-variable); }
1822+.hl-variable-parameter { color: var(--hl-variable); font-style: italic; }
1823+.hl-property { color: var(--hl-attribute); }
1824+.hl-operator { color: var(--hl-operator); }
1825+.hl-punctuation { color: var(--hl-punctuation); }
1826+.hl-tag { color: var(--hl-tag); }
1827+.hl-attribute { color: var(--hl-attribute); }
1828+.hl-label,
1829+.hl-module { color: var(--hl-type); }
1830+.hl-escape,
1831+.hl-string-escape,
1832+.hl-string-special { color: var(--hl-number); }
1833+.hl-embedded { color: var(--hl-variable); }
1834+
1835+/* Builtins read as slightly more emphatic than user-defined names of the same
1836+ kind — the distinction jj's own CLI colours make, and worth keeping. */
1837+.hl-keyword,
1838+.hl-function-builtin,
1839+.hl-type-builtin,
1840+.hl-variable-builtin,
1841+.hl-constant-builtin {
1842+ font-weight: 500;
1843+}
1844+
1845+/* In a high-contrast context, colour alone must not be the only signal.
1846+ Comments and strings keep their shape cues; everything else falls back to
1847+ the body colour rather than a low-contrast tint (spec §11 accessibility). */
1848+@media (prefers-contrast: more) {
1849+ [class^="hl-"],
1850+ [class*=" hl-"] {
1851+ color: var(--text);
1852+ }
1853+ .hl-comment { color: var(--text-dim); font-style: italic; }
1854+ .hl-string { color: var(--text); font-style: italic; }
1855+ .hl-keyword { font-weight: 700; }
1856+}
1857+
1858+
1859+/* ─── settings ─────────────────────────────────────────────────────────────── */
1860+
1861+.subtabs {
1862+ display: flex;
1863+ gap: 4px;
1864+ border-bottom: 1px solid var(--border);
1865+ margin-bottom: 16px;
1866+ overflow-x: auto;
1867+}
1868+
1869+.subtabs a {
1870+ padding: 8px 12px;
1871+ color: var(--text-dim);
1872+ border-bottom: 2px solid transparent;
1873+ white-space: nowrap;
1874+}
1875+
1876+.subtabs a:hover {
1877+ color: var(--text);
1878+}
1879+
1880+.subtabs a.active {
1881+ color: var(--text);
1882+ border-bottom-color: var(--brand);
1883+}
1884+
1885+.listing {
1886+ width: 100%;
1887+ border-collapse: collapse;
1888+ margin: 12px 0;
1889+}
1890+
1891+.listing th {
1892+ text-align: left;
1893+ font-weight: 500;
1894+ font-size: 12px;
1895+ text-transform: uppercase;
1896+ letter-spacing: 0.04em;
1897+ color: var(--text-faint);
1898+ padding: 6px 10px 6px 0;
1899+ border-bottom: 1px solid var(--border);
1900+}
1901+
1902+.listing td {
1903+ padding: 8px 10px 8px 0;
1904+ border-bottom: 1px solid var(--border);
1905+ vertical-align: middle;
1906+}
1907+
1908+.listing tr:last-child td {
1909+ border-bottom: none;
1910+}
1911+
1912+.kv {
1913+ display: grid;
1914+ grid-template-columns: minmax(120px, max-content) 1fr;
1915+ gap: 6px 16px;
1916+ margin: 0;
1917+}
1918+
1919+.kv dt {
1920+ color: var(--text-faint);
1921+ font-size: 12px;
1922+ text-transform: uppercase;
1923+ letter-spacing: 0.04em;
1924+ align-self: center;
1925+}
1926+
1927+.kv dd {
1928+ margin: 0;
1929+}
1930+
1931+/* The danger zone reads as dangerous without relying on colour alone — the
1932+ border weight and the heading carry it for anyone who cannot see the hue. */
1933+.panel-danger {
1934+ border-color: color-mix(in srgb, var(--danger) 55%, var(--border));
1935+}
1936+
1937+.panel-danger h2,
1938+.panel-danger h3 {
1939+ color: var(--danger);
1940+}
1941+
1942+.panel-danger hr {
1943+ border: none;
1944+ border-top: 1px solid var(--border);
1945+ margin: 20px 0;
1946+}
1947+
1948+textarea,
1949+select {
1950+ width: 100%;
1951+ background: var(--bg);
1952+ color: var(--text);
1953+ border: 1px solid var(--border);
1954+ border-radius: var(--radius);
1955+ padding: 8px 10px;
1956+ font-family: inherit;
1957+ font-size: 14px;
1958+}
1959+
1960+textarea {
1961+ font-family: var(--font-mono);
1962+ resize: vertical;
1963+}
1964+
1965+textarea:focus,
1966+select:focus {
1967+ outline: 2px solid var(--brand);
1968+ outline-offset: -1px;
1969+}
1970+
1971+/* A select inside a table row should not stretch to the full column. */
1972+.listing select {
1973+ width: auto;
1974+ min-width: 120px;
1975+}
1976+
1977+.field label input[type="checkbox"] {
1978+ width: auto;
1979+ margin-right: 6px;
1980+}
1981+
1982+
1983+/* ─── diffs and review ─────────────────────────────────────────────────────── */
1984+
1985+.filediff {
1986+ border: 1px solid var(--border);
1987+ border-radius: var(--radius);
1988+ margin-bottom: 14px;
1989+ overflow-x: auto;
1990+}
1991+
1992+.filediff-head {
1993+ padding: 8px 10px;
1994+ background: var(--surface-raised);
1995+ border-bottom: 1px solid var(--border);
1996+}
1997+
1998+.difftable {
1999+ width: 100%;
2000+ border-collapse: collapse;
2001+}
2002+
2003+.difftable .lineno {
2004+ width: 1%;
2005+ text-align: right;
2006+ padding: 0 8px;
2007+ user-select: none;
2008+ white-space: nowrap;
2009+}
2010+
2011+.difftable .codeline {
2012+ padding: 0 10px;
2013+ white-space: pre-wrap;
2014+ word-break: break-all;
2015+}
2016+
2017+.difftable .hunkhead td {
2018+ padding: 2px 10px;
2019+ color: var(--text-faint);
2020+ background: var(--bg);
2021+}
2022+
2023+.line-add { background: var(--diff-add-bg); color: var(--diff-add-text); }
2024+.line-del { background: var(--diff-del-bg); color: var(--diff-del-text); }
2025+
2026+/* Word-level intra-line changes (spec §8). Rendered as a background wash
2027+ rather than a colour change, so the line's add/delete colour still reads and
2028+ the emphasis survives a high-contrast or monochrome display. */
2029+.word-changed {
2030+ background: color-mix(in srgb, currentColor 22%, transparent);
2031+ border-radius: var(--radius-sm);
2032+}
2033+
2034+.inline-thread {
2035+ padding: 10px 14px;
2036+ background: var(--surface);
2037+ border-top: 1px solid var(--border);
2038+ border-bottom: 1px solid var(--border);
2039+ white-space: normal;
2040+}
2041+
2042+.inline-form {
2043+ padding: 4px 14px 10px;
2044+ background: var(--surface);
2045+ white-space: normal;
2046+}
2047+
2048+.inline-form summary {
2049+ cursor: pointer;
2050+ font-size: 12px;
2051+}
2052+
2053+.comment {
2054+ border: 1px solid var(--border);
2055+ border-radius: var(--radius);
2056+ padding: 10px 12px;
2057+ background: var(--surface);
2058+}
2059+
2060+.comment-body {
2061+ margin-top: 6px;
2062+}
2063+
2064+.comment-body > :first-child { margin-top: 0; }
2065+.comment-body > :last-child { margin-bottom: 0; }
2066+
2067+/* The line a comment was written about, retained when the anchor goes stale
2068+ (spec §5 step 4). */
2069+.anchor-context {
2070+ margin: 6px 0;
2071+ padding: 6px 10px;
2072+ background: var(--bg);
2073+ border-left: 2px solid var(--identity);
2074+ overflow-x: auto;
2075+ font-size: 12px;
2076+}
2077+
2078+.timeline-event {
2079+ padding: 6px 0;
2080+ border-bottom: 1px dashed var(--border);
2081+ font-size: 13px;
2082+}
2083+
2084+.timeline-dot {
2085+ width: 6px;
2086+ height: 6px;
2087+ flex-shrink: 0;
2088+ border-radius: 50%;
2089+ background: var(--border-strong);
2090+}
2091+
2092+.timeline-dot-conflict { background: var(--conflict); }
2093+.timeline-dot-merged { background: var(--merged); }
2094+.timeline-dot-open { background: var(--open); }
2095+.timeline-dot-abandoned { background: var(--abandoned); }
2096+.timeline-dot-action { background: var(--action); }
2097+.timeline-dot-dim { background: var(--border-strong); }
2098+
2099+/* ─── conflicts ────────────────────────────────────────────────────────────── */
2100+
2101+.conflict-columns {
2102+ display: grid;
2103+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
2104+ gap: 1px;
2105+ background: var(--border);
2106+}
2107+
2108+.conflict-side {
2109+ background: var(--surface);
2110+ padding: 10px 12px;
2111+ min-width: 0;
2112+}
2113+
2114+/* A base is the common ancestor, not another candidate resolution. Dimming it
2115+ says so without needing a legend. */
2116+.conflict-base {
2117+ background: var(--bg);
2118+}
2119+
2120+.conflict-side pre {
2121+ margin: 6px 0 0;
2122+ overflow-x: auto;
2123+ font-size: 12px;
2124+ max-height: 420px;
2125+}
2126+
2127+/* ─── stack graph ──────────────────────────────────────────────────────────── */
2128+
2129+.stackgraph {
2130+ list-style: none;
2131+ margin: 16px 0 0;
2132+ padding: 0;
2133+}
2134+
2135+.stacknode {
2136+ border-left: 2px solid var(--border);
2137+ padding: 10px 0 10px 14px;
2138+ position: relative;
2139+}
2140+
2141+.stacknode::before {
2142+ content: "";
2143+ position: absolute;
2144+ left: -5px;
2145+ top: 16px;
2146+ width: 8px;
2147+ height: 8px;
2148+ border-radius: 50%;
2149+ background: var(--border-strong);
2150+}
2151+
2152+.stacknode.current {
2153+ border-left-color: var(--identity);
2154+}
2155+
2156+.stacknode.current::before {
2157+ background: var(--identity);
2158+}
2159+
2160+details summary {
2161+ cursor: pointer;
2162+}
2163+
2164+
2165+/* ─── issues ───────────────────────────────────────────────────────────────── */
2166+
2167+/* A label's colour comes from the repository, so it is validated as a hex
2168+ triple before it reaches the inline style. When it is not one, the chip falls
2169+ back to these theme colours rather than being dropped. */
2170+.label-chip {
2171+ border-width: 1px;
2172+ border-style: solid;
2173+}
2174+
2175+/* ─── masthead search ──────────────────────────────────────────────────────── */
2176+
2177+.masthead-search {
2178+ margin-left: auto;
2179+ margin-right: 16px;
2180+ min-width: 0;
2181+}
2182+
2183+.theme-toggle {
2184+ display: flex;
2185+ align-items: center;
2186+ justify-content: center;
2187+ width: 30px;
2188+ height: 30px;
2189+ padding: 0;
2190+ margin-right: 8px;
2191+ flex-shrink: 0;
2192+ background: transparent;
2193+ border-color: transparent;
2194+ color: var(--text-dim);
2195+}
2196+
2197+.theme-toggle:hover {
2198+ border-color: var(--border-strong);
2199+ color: var(--text);
2200+}
2201+
2202+.theme-toggle .icon-moon {
2203+ display: none;
2204+}
2205+
2206+:root[data-theme="light"] .theme-toggle .icon-sun {
2207+ display: none;
2208+}
2209+
2210+:root[data-theme="light"] .theme-toggle .icon-moon {
2211+ display: block;
2212+}
2213+
2214+.masthead-search input {
2215+ width: 180px;
2216+ max-width: 40vw;
2217+ padding: 5px 9px;
2218+ font-size: 13px;
2219+}
2220+
2221+/* On a narrow screen the search box is the first thing to give up its space. */
2222+@media (max-width: 620px) {
2223+ .masthead-search {
2224+ display: none;
2225+ }
2226+}
2227+
2228+
2229+/* ─── accessibility (spec §11) ─────────────────────────────────────────────── */
2230+
2231+/* The skip link is the first thing in the tab order and invisible until it has
2232+ focus. Positioned rather than hidden — `display:none` would take it out of
2233+ the tab order entirely, which defeats the point. */
2234+.skip-link {
2235+ position: absolute;
2236+ left: -9999px;
2237+ top: 0;
2238+ z-index: 100;
2239+ padding: 10px 16px;
2240+ background: var(--surface-raised);
2241+ color: var(--text);
2242+ border: 1px solid var(--border-strong);
2243+ border-radius: 0 0 var(--radius) 0;
2244+}
2245+
2246+.skip-link:focus {
2247+ left: 0;
2248+}
2249+
2250+/* A visible focus indicator on everything interactive. `:focus-visible` so a
2251+ mouse click does not leave a ring behind, but a keyboard user always sees
2252+ where they are. */
2253+a:focus-visible,
2254+button:focus-visible,
2255+input:focus-visible,
2256+select:focus-visible,
2257+textarea:focus-visible,
2258+summary:focus-visible,
2259+[tabindex]:focus-visible {
2260+ outline: 2px solid var(--brand);
2261+ outline-offset: 2px;
2262+ border-radius: var(--radius-sm);
2263+}
2264+
2265+/* `main` is focusable only as the skip link's target; it should never draw a
2266+ ring of its own. */
2267+main:focus {
2268+ outline: none;
2269+}
2270+
2271+/* Windows high-contrast mode replaces our colours wholesale; make sure the
2272+ focus ring survives it. */
2273+@media (forced-colors: active) {
2274+ a:focus-visible,
2275+ button:focus-visible,
2276+ input:focus-visible,
2277+ select:focus-visible,
2278+ textarea:focus-visible {
2279+ outline: 2px solid CanvasText;
2280+ }
2281+}
2282+
2283+/* Badges and chips must not rely on colour alone. They already carry their
2284+ meaning as text; this keeps the border in high-contrast mode so the shape
2285+ still reads as a distinct token. */
2286+@media (forced-colors: active) {
2287+ .badge,
2288+ .chip {
2289+ border: 1px solid CanvasText;
2290+ }
2291+}
2292+
2293+/* Visually hidden but announced. */
2294+.sr-only {
2295+ position: absolute;
2296+ width: 1px;
2297+ height: 1px;
2298+ padding: 0;
2299+ margin: -1px;
2300+ overflow: hidden;
2301+ clip: rect(0, 0, 0, 0);
2302+ white-space: nowrap;
2303+ border: 0;
2304+}
2305+
2306+
2307+/* ─── the in-browser editor ────────────────────────────────────────────────── */
2308+
2309+/* The textarea is the editor until CodeMirror replaces it, so it has to look
2310+ like one on its own — not like a form field that happens to hold code. */
2311+.editor-host textarea {
2312+ width: 100%;
2313+ min-height: 420px;
2314+ font-family: var(--font-mono);
2315+ font-size: 13px;
2316+ line-height: 1.5;
2317+ tab-size: 4;
2318+ white-space: pre;
2319+ overflow: auto;
2320+}
2321+
2322+/* CodeMirror sizes itself; the host must not fight it. */
2323+.editor-host .cm-editor {
2324+ height: auto;
2325+}
2326+
2327+/* ─── code view three-pane layout ──────────────────────────────────────────── */
2328+
2329+/* The last commit that modified this file, shown above the pane layout. */
2330+.file-commit-bar {
2331+ display: flex;
2332+ align-items: center;
2333+ gap: 10px;
2334+ padding: 8px 12px;
2335+ margin-bottom: 12px;
2336+ border: 1px solid var(--border);
2337+ border-radius: var(--radius);
2338+ background: var(--surface);
2339+ font-size: var(--text-sm);
2340+}
2341+
2342+/* Three-column grid: file tree | code | symbol outline */
2343+.code-view-layout {
2344+ display: grid;
2345+ grid-template-columns: 220px 1fr 200px;
2346+ gap: 0;
2347+ align-items: start;
2348+ border: 1px solid var(--border);
2349+ border-radius: var(--radius);
2350+ overflow: hidden;
2351+}
2352+
2353+/* When the symbol sidebar is absent, the layout collapses to two columns. */
2354+.code-view-layout:not(:has(.code-sidebar-right)) {
2355+ grid-template-columns: 220px 1fr;
2356+}
2357+
2358+/* ─── left sidebar (file tree) ──────────────────────────────────────────── */
2359+.code-sidebar-left {
2360+ background: var(--surface);
2361+ border-right: 1px solid var(--border);
2362+ max-height: 80vh;
2363+ overflow-y: auto;
2364+ padding: 0;
2365+}
2366+
2367+.sidebar-header {
2368+ display: flex;
2369+ align-items: center;
2370+ gap: 6px;
2371+ padding: 10px 12px;
2372+ border-bottom: 1px solid var(--border);
2373+ font-size: var(--text-sm);
2374+}
2375+
2376+.file-tree {
2377+ display: flex;
2378+ flex-direction: column;
2379+}
2380+
2381+.file-tree-item {
2382+ display: flex;
2383+ align-items: center;
2384+ gap: 6px;
2385+ padding: 5px 12px;
2386+ font-size: var(--text-sm);
2387+ font-family: var(--font-mono);
2388+ color: var(--text);
2389+ text-decoration: none;
2390+ border-bottom: 1px solid var(--border);
2391+ white-space: nowrap;
2392+ overflow: hidden;
2393+ text-overflow: ellipsis;
2394+}
2395+
2396+.file-tree-item:last-child {
2397+ border-bottom: none;
2398+}
2399+
2400+.file-tree-item:hover {
2401+ background: var(--surface-raised);
2402+}
2403+
2404+.file-tree-item.is-current {
2405+ background: var(--surface-raised);
2406+ font-weight: 500;
2407+ border-left: 2px solid var(--action);
2408+ padding-left: 10px;
2409+}
2410+
2411+.file-tree-item.is-dir {
2412+ color: var(--text-dim);
2413+}
2414+
2415+.file-tree-parent {
2416+ color: var(--text-dim);
2417+}
2418+
2419+/* ─── center code panel ─────────────────────────────────────────────────── */
2420+.code-panel {
2421+ min-width: 0;
2422+ overflow-x: auto;
2423+}
2424+
2425+.code-panel .panel {
2426+ border: none;
2427+ border-radius: 0;
2428+}
2429+
2430+/* ─── right sidebar (symbol outline) ────────────────────────────────────── */
2431+.code-sidebar-right {
2432+ background: var(--surface);
2433+ border-left: 1px solid var(--border);
2434+ max-height: 80vh;
2435+ overflow-y: auto;
2436+ padding: 0;
2437+}
2438+
2439+.symbol-list {
2440+ display: flex;
2441+ flex-direction: column;
2442+}
2443+
2444+.symbol-item {
2445+ display: flex;
2446+ align-items: center;
2447+ gap: 8px;
2448+ padding: 5px 12px;
2449+ font-size: var(--text-sm);
2450+ color: var(--text);
2451+ text-decoration: none;
2452+ border-bottom: 1px solid var(--border);
2453+}
2454+
2455+.symbol-item:last-child {
2456+ border-bottom: none;
2457+}
2458+
2459+.symbol-item:hover {
2460+ background: var(--surface-raised);
2461+}
2462+
2463+.symbol-kind {
2464+ font-family: var(--font-mono);
2465+ font-size: 10px;
2466+ font-weight: 600;
2467+ text-transform: uppercase;
2468+ padding: 1px 5px;
2469+ border-radius: 3px;
2470+ flex-shrink: 0;
2471+ background: var(--border);
2472+ color: var(--text-dim);
2473+}
2474+
2475+.sk-fn { color: var(--hl-function, #d9a441); background: rgb(217 164 65 / 0.12); }
2476+.sk-type { color: var(--hl-type, #6ba9b8); background: rgb(107 169 184 / 0.12); }
2477+.sk-enum { color: var(--hl-constant, #c77dba); background: rgb(199 125 186 / 0.12); }
2478+.sk-trait { color: var(--hl-keyword, #db5a86); background: rgb(219 90 134 / 0.12); }
2479+.sk-const { color: var(--hl-constant, #c77dba); background: rgb(199 125 186 / 0.12); }
2480+.sk-mod { color: var(--text-dim); background: var(--border); }
2481+
2482+.symbol-name {
2483+ font-family: var(--font-mono);
2484+ font-size: var(--text-sm);
2485+ white-space: nowrap;
2486+ overflow: hidden;
2487+ text-overflow: ellipsis;
2488+}
2489+
2490+/* ─── blame gutter ──────────────────────────────────────────────────────── */
2491+.blame-cell {
2492+ padding: 0 8px;
2493+ white-space: nowrap;
2494+ font-size: 11px;
2495+ color: var(--text-faint);
2496+ border-right: 1px solid var(--border);
2497+ max-width: 120px;
2498+ overflow: hidden;
2499+ text-overflow: ellipsis;
2500+ user-select: none;
2501+}
2502+
2503+.blame-author {
2504+ font-family: var(--font-mono);
2505+}
2506+
2507+/* Small blame toggle button */
2508+.btn-sm {
2509+ padding: 3px 10px;
2510+ font-size: var(--text-xs);
2511+}
2512+
2513+/* ─── responsive: collapse sidebars on narrow screens ───────────────────── */
2514+@media (max-width: 900px) {
2515+ .code-view-layout {
2516+ grid-template-columns: 1fr;
2517+ }
2518+
2519+ .code-view-layout:not(:has(.code-sidebar-right)) {
2520+ grid-template-columns: 1fr;
2521+ }
2522+
2523+ .code-sidebar-left {
2524+ display: none;
2525+ }
2526+
2527+ .code-sidebar-right {
2528+ display: none;
2529+ }
2530+}
2531+
2532+@media (min-width: 901px) and (max-width: 1100px) {
2533+ .code-view-layout {
2534+ grid-template-columns: 180px 1fr;
2535+ }
2536+
2537+ .code-sidebar-right {
2538+ display: none;
2539+ }
2540+}
Acrates/df-web/assets/editor/editor.js+165−0
@@ −0,0 +1,165 @@
1+// In-browser file editing (CodeMirror 6).
2+//
3+// **Enhancement only.** The page ships a working `<textarea>` inside an
4+// ordinary form. This script replaces it with an editor and copies the content
5+// back on submit. If the bundle fails to load, is blocked, or throws, the
6+// textarea is what remains and saving still works — which is the same
7+// progressive-enhancement rule the rest of the product follows (spec §7).
8+//
9+// The bundle is served from our own origin and executed under a per-request
10+// nonce, because the CSP has no `unsafe-inline` and no external hosts.
11+
12+import { EditorState, Compartment } from "@codemirror/state";
13+import { EditorView, keymap, highlightActiveLine, lineNumbers } from "@codemirror/view";
14+import { indentWithTab } from "@codemirror/commands";
15+import { basicSetup } from "codemirror";
16+import { StreamLanguage } from "@codemirror/language";
17+
18+import { rust } from "@codemirror/lang-rust";
19+import { python } from "@codemirror/lang-python";
20+import { javascript } from "@codemirror/lang-javascript";
21+import { json } from "@codemirror/lang-json";
22+import { markdown } from "@codemirror/lang-markdown";
23+import { html } from "@codemirror/lang-html";
24+import { css } from "@codemirror/lang-css";
25+import { java } from "@codemirror/lang-java";
26+import { cpp } from "@codemirror/lang-cpp";
27+import { php } from "@codemirror/lang-php";
28+import { sql } from "@codemirror/lang-sql";
29+import { xml } from "@codemirror/lang-xml";
30+import { yaml } from "@codemirror/lang-yaml";
31+import { shell } from "@codemirror/legacy-modes/mode/shell";
32+import { toml } from "@codemirror/legacy-modes/mode/toml";
33+import { go } from "@codemirror/legacy-modes/mode/go";
34+import { ruby } from "@codemirror/legacy-modes/mode/ruby";
35+
36+// Deliberately the same set the server-side highlighter covers, so a file that
37+// is coloured when you read it is coloured when you edit it. Keys match the
38+// grammar names df-render uses.
39+const LANGUAGES = {
40+ rust: () => rust(),
41+ python: () => python(),
42+ javascript: () => javascript(),
43+ typescript: () => javascript({ typescript: true }),
44+ tsx: () => javascript({ typescript: true, jsx: true }),
45+ json: () => json(),
46+ markdown: () => markdown(),
47+ html: () => html(),
48+ css: () => css(),
49+ java: () => java(),
50+ c: () => cpp(),
51+ cpp: () => cpp(),
52+ php: () => php(),
53+ sql: () => sql(),
54+ xml: () => xml(),
55+ yaml: () => yaml(),
56+ bash: () => StreamLanguage.define(shell),
57+ toml: () => StreamLanguage.define(toml),
58+ go: () => StreamLanguage.define(go),
59+ ruby: () => StreamLanguage.define(ruby),
60+};
61+
62+// The editor takes its colours from the page's CSS custom properties rather
63+// than shipping a theme of its own. That way it matches whichever theme the
64+// reader is in, including one added later, with no toggle to keep in sync.
65+const theme = EditorView.theme({
66+ "&": {
67+ color: "var(--text)",
68+ backgroundColor: "var(--bg)",
69+ border: "1px solid var(--border)",
70+ borderRadius: "var(--radius)",
71+ fontSize: "13px",
72+ },
73+ ".cm-content": {
74+ fontFamily: "var(--font-mono)",
75+ caretColor: "var(--identity)",
76+ },
77+ ".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--identity)" },
78+ "&.cm-focused": { outline: "2px solid var(--action)", outlineOffset: "-1px" },
79+ "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection": {
80+ backgroundColor: "color-mix(in srgb, var(--action) 30%, transparent)",
81+ },
82+ ".cm-gutters": {
83+ backgroundColor: "var(--surface)",
84+ color: "var(--text-faint)",
85+ borderRight: "1px solid var(--border)",
86+ },
87+ ".cm-activeLine": {
88+ backgroundColor: "color-mix(in srgb, var(--identity) 8%, transparent)",
89+ },
90+ ".cm-activeLineGutter": { backgroundColor: "transparent", color: "var(--text-dim)" },
91+ ".cm-scroller": { overflow: "auto", maxHeight: "70vh" },
92+});
93+
94+function enhance(form) {
95+ const textarea = form.querySelector("textarea[data-editor]");
96+ if (!textarea) return;
97+
98+ const language = textarea.dataset.language || "";
99+ const factory = LANGUAGES[language];
100+
101+ const extensions = [
102+ basicSetup,
103+ theme,
104+ // Tab indents rather than moving focus. Bound explicitly because taking
105+ // Tab away from keyboard navigation is only acceptable when it is doing
106+ // something — Escape then Tab still leaves the editor.
107+ keymap.of([indentWithTab]),
108+ EditorView.lineWrapping,
109+ ];
110+ if (factory) {
111+ try {
112+ extensions.push(factory());
113+ } catch (e) {
114+ // A grammar that fails to construct must not cost the user their editor.
115+ console.warn("dogfood: language mode failed to load", language, e);
116+ }
117+ }
118+
119+ const view = new EditorView({
120+ state: EditorState.create({ doc: textarea.value, extensions }),
121+ parent: textarea.parentElement,
122+ });
123+
124+ // The textarea remains the thing that is submitted; it is hidden rather than
125+ // removed so the form keeps working exactly as it did.
126+ textarea.style.display = "none";
127+ textarea.setAttribute("aria-hidden", "true");
128+ textarea.tabIndex = -1;
129+
130+ // CodeMirror's editable area is not a labelled control, so name it from the
131+ // same label the textarea used.
132+ const content = view.contentDOM;
133+ content.setAttribute("role", "textbox");
134+ content.setAttribute("aria-multiline", "true");
135+ const label = form.querySelector(`label[for="${textarea.id}"]`);
136+ if (label) content.setAttribute("aria-label", label.textContent.trim());
137+
138+ form.addEventListener("submit", () => {
139+ textarea.value = view.state.doc.toString();
140+ });
141+
142+ // Warn before navigating away from unsaved work. Registered only once the
143+ // editor is actually up, so the no-JavaScript path never gets a stray prompt.
144+ const initial = textarea.value;
145+ let saving = false;
146+ form.addEventListener("submit", () => {
147+ saving = true;
148+ });
149+ window.addEventListener("beforeunload", (e) => {
150+ if (saving) return;
151+ if (view.state.doc.toString() !== initial) {
152+ e.preventDefault();
153+ e.returnValue = "";
154+ }
155+ });
156+}
157+
158+for (const form of document.querySelectorAll("form[data-editor-form]")) {
159+ try {
160+ enhance(form);
161+ } catch (e) {
162+ // Leaves the plain textarea in place, which is a working editor.
163+ console.error("dogfood: could not start the editor", e);
164+ }
165+}
Acrates/df-web/assets/editor/package-lock.json952 lines+952−0
@@ −0,0 +1,952 @@
1+{
2+ "name": "dogfood-editor",
3+ "lockfileVersion": 3,
4+ "requires": true,
5+ "packages": {
6+ "": {
7+ "name": "dogfood-editor",
8+ "dependencies": {
9+ "@codemirror/lang-cpp": "^6.0.2",
10+ "@codemirror/lang-css": "^6.3.1",
11+ "@codemirror/lang-html": "^6.4.9",
12+ "@codemirror/lang-java": "^6.0.1",
13+ "@codemirror/lang-javascript": "^6.2.2",
14+ "@codemirror/lang-json": "^6.0.1",
15+ "@codemirror/lang-markdown": "^6.3.1",
16+ "@codemirror/lang-php": "^6.0.1",
17+ "@codemirror/lang-python": "^6.1.6",
18+ "@codemirror/lang-rust": "^6.0.1",
19+ "@codemirror/lang-sql": "^6.8.0",
20+ "@codemirror/lang-xml": "^6.1.0",
21+ "@codemirror/lang-yaml": "^6.1.2",
22+ "@codemirror/language": "^6.10.8",
23+ "@codemirror/legacy-modes": "^6.4.2",
24+ "@codemirror/state": "^6.5.0",
25+ "@codemirror/view": "^6.36.1",
26+ "codemirror": "^6.0.1"
27+ },
28+ "devDependencies": {
29+ "esbuild": "^0.24.2"
30+ }
31+ },
32+ "node_modules/@codemirror/autocomplete": {
33+ "version": "6.20.3",
34+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
35+ "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
36+ "license": "MIT",
37+ "dependencies": {
38+ "@codemirror/language": "^6.0.0",
39+ "@codemirror/state": "^6.0.0",
40+ "@codemirror/view": "^6.17.0",
41+ "@lezer/common": "^1.0.0"
42+ }
43+ },
44+ "node_modules/@codemirror/commands": {
45+ "version": "6.10.4",
46+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
47+ "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
48+ "license": "MIT",
49+ "dependencies": {
50+ "@codemirror/language": "^6.0.0",
51+ "@codemirror/state": "^6.7.0",
52+ "@codemirror/view": "^6.27.0",
53+ "@lezer/common": "^1.1.0"
54+ }
55+ },
56+ "node_modules/@codemirror/lang-cpp": {
57+ "version": "6.0.3",
58+ "resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz",
59+ "integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==",
60+ "license": "MIT",
61+ "dependencies": {
62+ "@codemirror/language": "^6.0.0",
63+ "@lezer/cpp": "^1.0.0"
64+ }
65+ },
66+ "node_modules/@codemirror/lang-css": {
67+ "version": "6.3.1",
68+ "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
69+ "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
70+ "license": "MIT",
71+ "dependencies": {
72+ "@codemirror/autocomplete": "^6.0.0",
73+ "@codemirror/language": "^6.0.0",
74+ "@codemirror/state": "^6.0.0",
75+ "@lezer/common": "^1.0.2",
76+ "@lezer/css": "^1.1.7"
77+ }
78+ },
79+ "node_modules/@codemirror/lang-html": {
80+ "version": "6.4.11",
81+ "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
82+ "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
83+ "license": "MIT",
84+ "dependencies": {
85+ "@codemirror/autocomplete": "^6.0.0",
86+ "@codemirror/lang-css": "^6.0.0",
87+ "@codemirror/lang-javascript": "^6.0.0",
88+ "@codemirror/language": "^6.4.0",
89+ "@codemirror/state": "^6.0.0",
90+ "@codemirror/view": "^6.17.0",
91+ "@lezer/common": "^1.0.0",
92+ "@lezer/css": "^1.1.0",
93+ "@lezer/html": "^1.3.12"
94+ }
95+ },
96+ "node_modules/@codemirror/lang-java": {
97+ "version": "6.0.2",
98+ "resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz",
99+ "integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==",
100+ "license": "MIT",
101+ "dependencies": {
102+ "@codemirror/language": "^6.0.0",
103+ "@lezer/java": "^1.0.0"
104+ }
105+ },
106+ "node_modules/@codemirror/lang-javascript": {
107+ "version": "6.2.5",
108+ "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
109+ "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
110+ "license": "MIT",
111+ "dependencies": {
112+ "@codemirror/autocomplete": "^6.0.0",
113+ "@codemirror/language": "^6.6.0",
114+ "@codemirror/lint": "^6.0.0",
115+ "@codemirror/state": "^6.0.0",
116+ "@codemirror/view": "^6.17.0",
117+ "@lezer/common": "^1.0.0",
118+ "@lezer/javascript": "^1.0.0"
119+ }
120+ },
121+ "node_modules/@codemirror/lang-json": {
122+ "version": "6.0.2",
123+ "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
124+ "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
125+ "license": "MIT",
126+ "dependencies": {
127+ "@codemirror/language": "^6.0.0",
128+ "@lezer/json": "^1.0.0"
129+ }
130+ },
131+ "node_modules/@codemirror/lang-markdown": {
132+ "version": "6.5.1",
133+ "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.1.tgz",
134+ "integrity": "sha512-6re5avCNfyRMIoi3XNjbEfQM1vTeVD3JS3g/Fyegyso/eoANFM71Cyvbb66LDyYtQLMEcRFlzioywCqDo9SlLA==",
135+ "license": "MIT",
136+ "dependencies": {
137+ "@codemirror/autocomplete": "^6.7.1",
138+ "@codemirror/lang-html": "^6.0.0",
139+ "@codemirror/language": "^6.3.0",
140+ "@codemirror/state": "^6.0.0",
141+ "@codemirror/view": "^6.0.0",
142+ "@lezer/common": "^1.2.1",
143+ "@lezer/markdown": "^1.0.0"
144+ }
145+ },
146+ "node_modules/@codemirror/lang-php": {
147+ "version": "6.0.2",
148+ "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz",
149+ "integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==",
150+ "license": "MIT",
151+ "dependencies": {
152+ "@codemirror/lang-html": "^6.0.0",
153+ "@codemirror/language": "^6.0.0",
154+ "@codemirror/state": "^6.0.0",
155+ "@lezer/common": "^1.0.0",
156+ "@lezer/php": "^1.0.0"
157+ }
158+ },
159+ "node_modules/@codemirror/lang-python": {
160+ "version": "6.2.1",
161+ "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
162+ "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
163+ "license": "MIT",
164+ "dependencies": {
165+ "@codemirror/autocomplete": "^6.3.2",
166+ "@codemirror/language": "^6.8.0",
167+ "@codemirror/state": "^6.0.0",
168+ "@lezer/common": "^1.2.1",
169+ "@lezer/python": "^1.1.4"
170+ }
171+ },
172+ "node_modules/@codemirror/lang-rust": {
173+ "version": "6.0.2",
174+ "resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz",
175+ "integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==",
176+ "license": "MIT",
177+ "dependencies": {
178+ "@codemirror/language": "^6.0.0",
179+ "@lezer/rust": "^1.0.0"
180+ }
181+ },
182+ "node_modules/@codemirror/lang-sql": {
183+ "version": "6.10.0",
184+ "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz",
185+ "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==",
186+ "license": "MIT",
187+ "dependencies": {
188+ "@codemirror/autocomplete": "^6.0.0",
189+ "@codemirror/language": "^6.0.0",
190+ "@codemirror/state": "^6.0.0",
191+ "@lezer/common": "^1.2.0",
192+ "@lezer/highlight": "^1.0.0",
193+ "@lezer/lr": "^1.0.0"
194+ }
195+ },
196+ "node_modules/@codemirror/lang-xml": {
197+ "version": "6.1.0",
198+ "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz",
199+ "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==",
200+ "license": "MIT",
201+ "dependencies": {
202+ "@codemirror/autocomplete": "^6.0.0",
203+ "@codemirror/language": "^6.4.0",
204+ "@codemirror/state": "^6.0.0",
205+ "@codemirror/view": "^6.0.0",
206+ "@lezer/common": "^1.0.0",
207+ "@lezer/xml": "^1.0.0"
208+ }
209+ },
210+ "node_modules/@codemirror/lang-yaml": {
211+ "version": "6.1.3",
212+ "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz",
213+ "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==",
214+ "license": "MIT",
215+ "dependencies": {
216+ "@codemirror/autocomplete": "^6.0.0",
217+ "@codemirror/language": "^6.0.0",
218+ "@codemirror/state": "^6.0.0",
219+ "@lezer/common": "^1.2.0",
220+ "@lezer/highlight": "^1.2.0",
221+ "@lezer/lr": "^1.0.0",
222+ "@lezer/yaml": "^1.0.0"
223+ }
224+ },
225+ "node_modules/@codemirror/language": {
226+ "version": "6.12.4",
227+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
228+ "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
229+ "license": "MIT",
230+ "dependencies": {
231+ "@codemirror/state": "^6.0.0",
232+ "@codemirror/view": "^6.23.0",
233+ "@lezer/common": "^1.5.0",
234+ "@lezer/highlight": "^1.0.0",
235+ "@lezer/lr": "^1.0.0",
236+ "style-mod": "^4.0.0"
237+ }
238+ },
239+ "node_modules/@codemirror/legacy-modes": {
240+ "version": "6.5.3",
241+ "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz",
242+ "integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==",
243+ "license": "MIT",
244+ "dependencies": {
245+ "@codemirror/language": "^6.0.0"
246+ }
247+ },
248+ "node_modules/@codemirror/lint": {
249+ "version": "6.9.7",
250+ "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
251+ "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
252+ "license": "MIT",
253+ "dependencies": {
254+ "@codemirror/state": "^6.0.0",
255+ "@codemirror/view": "^6.42.0",
256+ "crelt": "^1.0.5"
257+ }
258+ },
259+ "node_modules/@codemirror/search": {
260+ "version": "6.7.1",
261+ "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
262+ "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
263+ "license": "MIT",
264+ "dependencies": {
265+ "@codemirror/state": "^6.0.0",
266+ "@codemirror/view": "^6.37.0",
267+ "crelt": "^1.0.5"
268+ }
269+ },
270+ "node_modules/@codemirror/state": {
271+ "version": "6.7.1",
272+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
273+ "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
274+ "license": "MIT",
275+ "dependencies": {
276+ "@marijn/find-cluster-break": "^1.0.0"
277+ }
278+ },
279+ "node_modules/@codemirror/view": {
280+ "version": "6.43.7",
281+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.7.tgz",
282+ "integrity": "sha512-FZsExxkoxnAN+d9TgqXLg5g4A1oQwzX9WlkOT5i2PKkcW7xx3Bmu0vs90g6fo9Mpdsb/l96dnAraQ8932aO4/g==",
283+ "license": "MIT",
284+ "dependencies": {
285+ "@codemirror/state": "^6.7.0",
286+ "crelt": "^1.0.6",
287+ "style-mod": "^4.1.0",
288+ "w3c-keyname": "^2.2.4"
289+ }
290+ },
291+ "node_modules/@esbuild/aix-ppc64": {
292+ "version": "0.24.2",
293+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
294+ "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
295+ "cpu": [
296+ "ppc64"
297+ ],
298+ "dev": true,
299+ "license": "MIT",
300+ "optional": true,
301+ "os": [
302+ "aix"
303+ ],
304+ "engines": {
305+ "node": ">=18"
306+ }
307+ },
308+ "node_modules/@esbuild/android-arm": {
309+ "version": "0.24.2",
310+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
311+ "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
312+ "cpu": [
313+ "arm"
314+ ],
315+ "dev": true,
316+ "license": "MIT",
317+ "optional": true,
318+ "os": [
319+ "android"
320+ ],
321+ "engines": {
322+ "node": ">=18"
323+ }
324+ },
325+ "node_modules/@esbuild/android-arm64": {
326+ "version": "0.24.2",
327+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
328+ "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
329+ "cpu": [
330+ "arm64"
331+ ],
332+ "dev": true,
333+ "license": "MIT",
334+ "optional": true,
335+ "os": [
336+ "android"
337+ ],
338+ "engines": {
339+ "node": ">=18"
340+ }
341+ },
342+ "node_modules/@esbuild/android-x64": {
343+ "version": "0.24.2",
344+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
345+ "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
346+ "cpu": [
347+ "x64"
348+ ],
349+ "dev": true,
350+ "license": "MIT",
351+ "optional": true,
352+ "os": [
353+ "android"
354+ ],
355+ "engines": {
356+ "node": ">=18"
357+ }
358+ },
359+ "node_modules/@esbuild/darwin-arm64": {
360+ "version": "0.24.2",
361+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
362+ "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
363+ "cpu": [
364+ "arm64"
365+ ],
366+ "dev": true,
367+ "license": "MIT",
368+ "optional": true,
369+ "os": [
370+ "darwin"
371+ ],
372+ "engines": {
373+ "node": ">=18"
374+ }
375+ },
376+ "node_modules/@esbuild/darwin-x64": {
377+ "version": "0.24.2",
378+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
379+ "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
380+ "cpu": [
381+ "x64"
382+ ],
383+ "dev": true,
384+ "license": "MIT",
385+ "optional": true,
386+ "os": [
387+ "darwin"
388+ ],
389+ "engines": {
390+ "node": ">=18"
391+ }
392+ },
393+ "node_modules/@esbuild/freebsd-arm64": {
394+ "version": "0.24.2",
395+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
396+ "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
397+ "cpu": [
398+ "arm64"
399+ ],
400+ "dev": true,
401+ "license": "MIT",
402+ "optional": true,
403+ "os": [
404+ "freebsd"
405+ ],
406+ "engines": {
407+ "node": ">=18"
408+ }
409+ },
410+ "node_modules/@esbuild/freebsd-x64": {
411+ "version": "0.24.2",
412+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
413+ "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
414+ "cpu": [
415+ "x64"
416+ ],
417+ "dev": true,
418+ "license": "MIT",
419+ "optional": true,
420+ "os": [
421+ "freebsd"
422+ ],
423+ "engines": {
424+ "node": ">=18"
425+ }
426+ },
427+ "node_modules/@esbuild/linux-arm": {
428+ "version": "0.24.2",
429+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
430+ "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
431+ "cpu": [
432+ "arm"
433+ ],
434+ "dev": true,
435+ "license": "MIT",
436+ "optional": true,
437+ "os": [
438+ "linux"
439+ ],
440+ "engines": {
441+ "node": ">=18"
442+ }
443+ },
444+ "node_modules/@esbuild/linux-arm64": {
445+ "version": "0.24.2",
446+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
447+ "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
448+ "cpu": [
449+ "arm64"
450+ ],
451+ "dev": true,
452+ "license": "MIT",
453+ "optional": true,
454+ "os": [
455+ "linux"
456+ ],
457+ "engines": {
458+ "node": ">=18"
459+ }
460+ },
461+ "node_modules/@esbuild/linux-ia32": {
462+ "version": "0.24.2",
463+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
464+ "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
465+ "cpu": [
466+ "ia32"
467+ ],
468+ "dev": true,
469+ "license": "MIT",
470+ "optional": true,
471+ "os": [
472+ "linux"
473+ ],
474+ "engines": {
475+ "node": ">=18"
476+ }
477+ },
478+ "node_modules/@esbuild/linux-loong64": {
479+ "version": "0.24.2",
480+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
481+ "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
482+ "cpu": [
483+ "loong64"
484+ ],
485+ "dev": true,
486+ "license": "MIT",
487+ "optional": true,
488+ "os": [
489+ "linux"
490+ ],
491+ "engines": {
492+ "node": ">=18"
493+ }
494+ },
495+ "node_modules/@esbuild/linux-mips64el": {
496+ "version": "0.24.2",
497+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
498+ "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
499+ "cpu": [
500+ "mips64el"
501+ ],
502+ "dev": true,
503+ "license": "MIT",
504+ "optional": true,
505+ "os": [
506+ "linux"
507+ ],
508+ "engines": {
509+ "node": ">=18"
510+ }
511+ },
512+ "node_modules/@esbuild/linux-ppc64": {
513+ "version": "0.24.2",
514+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
515+ "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
516+ "cpu": [
517+ "ppc64"
518+ ],
519+ "dev": true,
520+ "license": "MIT",
521+ "optional": true,
522+ "os": [
523+ "linux"
524+ ],
525+ "engines": {
526+ "node": ">=18"
527+ }
528+ },
529+ "node_modules/@esbuild/linux-riscv64": {
530+ "version": "0.24.2",
531+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
532+ "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
533+ "cpu": [
534+ "riscv64"
535+ ],
536+ "dev": true,
537+ "license": "MIT",
538+ "optional": true,
539+ "os": [
540+ "linux"
541+ ],
542+ "engines": {
543+ "node": ">=18"
544+ }
545+ },
546+ "node_modules/@esbuild/linux-s390x": {
547+ "version": "0.24.2",
548+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
549+ "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
550+ "cpu": [
551+ "s390x"
552+ ],
553+ "dev": true,
554+ "license": "MIT",
555+ "optional": true,
556+ "os": [
557+ "linux"
558+ ],
559+ "engines": {
560+ "node": ">=18"
561+ }
562+ },
563+ "node_modules/@esbuild/linux-x64": {
564+ "version": "0.24.2",
565+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
566+ "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
567+ "cpu": [
568+ "x64"
569+ ],
570+ "dev": true,
571+ "license": "MIT",
572+ "optional": true,
573+ "os": [
574+ "linux"
575+ ],
576+ "engines": {
577+ "node": ">=18"
578+ }
579+ },
580+ "node_modules/@esbuild/netbsd-arm64": {
581+ "version": "0.24.2",
582+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
583+ "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
584+ "cpu": [
585+ "arm64"
586+ ],
587+ "dev": true,
588+ "license": "MIT",
589+ "optional": true,
590+ "os": [
591+ "netbsd"
592+ ],
593+ "engines": {
594+ "node": ">=18"
595+ }
596+ },
597+ "node_modules/@esbuild/netbsd-x64": {
598+ "version": "0.24.2",
599+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
600+ "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
601+ "cpu": [
602+ "x64"
603+ ],
604+ "dev": true,
605+ "license": "MIT",
606+ "optional": true,
607+ "os": [
608+ "netbsd"
609+ ],
610+ "engines": {
611+ "node": ">=18"
612+ }
613+ },
614+ "node_modules/@esbuild/openbsd-arm64": {
615+ "version": "0.24.2",
616+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
617+ "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
618+ "cpu": [
619+ "arm64"
620+ ],
621+ "dev": true,
622+ "license": "MIT",
623+ "optional": true,
624+ "os": [
625+ "openbsd"
626+ ],
627+ "engines": {
628+ "node": ">=18"
629+ }
630+ },
631+ "node_modules/@esbuild/openbsd-x64": {
632+ "version": "0.24.2",
633+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
634+ "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
635+ "cpu": [
636+ "x64"
637+ ],
638+ "dev": true,
639+ "license": "MIT",
640+ "optional": true,
641+ "os": [
642+ "openbsd"
643+ ],
644+ "engines": {
645+ "node": ">=18"
646+ }
647+ },
648+ "node_modules/@esbuild/sunos-x64": {
649+ "version": "0.24.2",
650+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
651+ "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
652+ "cpu": [
653+ "x64"
654+ ],
655+ "dev": true,
656+ "license": "MIT",
657+ "optional": true,
658+ "os": [
659+ "sunos"
660+ ],
661+ "engines": {
662+ "node": ">=18"
663+ }
664+ },
665+ "node_modules/@esbuild/win32-arm64": {
666+ "version": "0.24.2",
667+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
668+ "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
669+ "cpu": [
670+ "arm64"
671+ ],
672+ "dev": true,
673+ "license": "MIT",
674+ "optional": true,
675+ "os": [
676+ "win32"
677+ ],
678+ "engines": {
679+ "node": ">=18"
680+ }
681+ },
682+ "node_modules/@esbuild/win32-ia32": {
683+ "version": "0.24.2",
684+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
685+ "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
686+ "cpu": [
687+ "ia32"
688+ ],
689+ "dev": true,
690+ "license": "MIT",
691+ "optional": true,
692+ "os": [
693+ "win32"
694+ ],
695+ "engines": {
696+ "node": ">=18"
697+ }
698+ },
699+ "node_modules/@esbuild/win32-x64": {
700+ "version": "0.24.2",
701+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
702+ "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
703+ "cpu": [
704+ "x64"
705+ ],
706+ "dev": true,
707+ "license": "MIT",
708+ "optional": true,
709+ "os": [
710+ "win32"
711+ ],
712+ "engines": {
713+ "node": ">=18"
714+ }
715+ },
716+ "node_modules/@lezer/common": {
717+ "version": "1.5.2",
718+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
719+ "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
720+ "license": "MIT"
721+ },
722+ "node_modules/@lezer/cpp": {
723+ "version": "1.1.6",
724+ "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.6.tgz",
725+ "integrity": "sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==",
726+ "license": "MIT",
727+ "dependencies": {
728+ "@lezer/common": "^1.2.0",
729+ "@lezer/highlight": "^1.0.0",
730+ "@lezer/lr": "^1.0.0"
731+ }
732+ },
733+ "node_modules/@lezer/css": {
734+ "version": "1.3.4",
735+ "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz",
736+ "integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==",
737+ "license": "MIT",
738+ "dependencies": {
739+ "@lezer/common": "^1.2.0",
740+ "@lezer/highlight": "^1.0.0",
741+ "@lezer/lr": "^1.3.0"
742+ }
743+ },
744+ "node_modules/@lezer/highlight": {
745+ "version": "1.2.3",
746+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
747+ "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
748+ "license": "MIT",
749+ "dependencies": {
750+ "@lezer/common": "^1.3.0"
751+ }
752+ },
753+ "node_modules/@lezer/html": {
754+ "version": "1.3.13",
755+ "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
756+ "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
757+ "license": "MIT",
758+ "dependencies": {
759+ "@lezer/common": "^1.2.0",
760+ "@lezer/highlight": "^1.0.0",
761+ "@lezer/lr": "^1.0.0"
762+ }
763+ },
764+ "node_modules/@lezer/java": {
765+ "version": "1.1.3",
766+ "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz",
767+ "integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==",
768+ "license": "MIT",
769+ "dependencies": {
770+ "@lezer/common": "^1.2.0",
771+ "@lezer/highlight": "^1.0.0",
772+ "@lezer/lr": "^1.0.0"
773+ }
774+ },
775+ "node_modules/@lezer/javascript": {
776+ "version": "1.5.4",
777+ "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
778+ "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
779+ "license": "MIT",
780+ "dependencies": {
781+ "@lezer/common": "^1.2.0",
782+ "@lezer/highlight": "^1.1.3",
783+ "@lezer/lr": "^1.3.0"
784+ }
785+ },
786+ "node_modules/@lezer/json": {
787+ "version": "1.0.3",
788+ "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
789+ "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
790+ "license": "MIT",
791+ "dependencies": {
792+ "@lezer/common": "^1.2.0",
793+ "@lezer/highlight": "^1.0.0",
794+ "@lezer/lr": "^1.0.0"
795+ }
796+ },
797+ "node_modules/@lezer/lr": {
798+ "version": "1.4.10",
799+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
800+ "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
801+ "license": "MIT",
802+ "dependencies": {
803+ "@lezer/common": "^1.0.0"
804+ }
805+ },
806+ "node_modules/@lezer/markdown": {
807+ "version": "1.7.2",
808+ "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.2.tgz",
809+ "integrity": "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==",
810+ "license": "MIT",
811+ "dependencies": {
812+ "@lezer/common": "^1.5.0",
813+ "@lezer/highlight": "^1.0.0"
814+ }
815+ },
816+ "node_modules/@lezer/php": {
817+ "version": "1.0.5",
818+ "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz",
819+ "integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==",
820+ "license": "MIT",
821+ "dependencies": {
822+ "@lezer/common": "^1.2.0",
823+ "@lezer/highlight": "^1.0.0",
824+ "@lezer/lr": "^1.1.0"
825+ }
826+ },
827+ "node_modules/@lezer/python": {
828+ "version": "1.1.19",
829+ "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz",
830+ "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==",
831+ "license": "MIT",
832+ "dependencies": {
833+ "@lezer/common": "^1.2.0",
834+ "@lezer/highlight": "^1.0.0",
835+ "@lezer/lr": "^1.0.0"
836+ }
837+ },
838+ "node_modules/@lezer/rust": {
839+ "version": "1.0.2",
840+ "resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz",
841+ "integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==",
842+ "license": "MIT",
843+ "dependencies": {
844+ "@lezer/common": "^1.2.0",
845+ "@lezer/highlight": "^1.0.0",
846+ "@lezer/lr": "^1.0.0"
847+ }
848+ },
849+ "node_modules/@lezer/xml": {
850+ "version": "1.0.6",
851+ "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz",
852+ "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==",
853+ "license": "MIT",
854+ "dependencies": {
855+ "@lezer/common": "^1.2.0",
856+ "@lezer/highlight": "^1.0.0",
857+ "@lezer/lr": "^1.0.0"
858+ }
859+ },
860+ "node_modules/@lezer/yaml": {
861+ "version": "1.0.4",
862+ "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz",
863+ "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
864+ "license": "MIT",
865+ "dependencies": {
866+ "@lezer/common": "^1.2.0",
867+ "@lezer/highlight": "^1.0.0",
868+ "@lezer/lr": "^1.4.0"
869+ }
870+ },
871+ "node_modules/@marijn/find-cluster-break": {
872+ "version": "1.0.3",
873+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
874+ "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
875+ "license": "MIT"
876+ },
877+ "node_modules/codemirror": {
878+ "version": "6.0.2",
879+ "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
880+ "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
881+ "license": "MIT",
882+ "dependencies": {
883+ "@codemirror/autocomplete": "^6.0.0",
884+ "@codemirror/commands": "^6.0.0",
885+ "@codemirror/language": "^6.0.0",
886+ "@codemirror/lint": "^6.0.0",
887+ "@codemirror/search": "^6.0.0",
888+ "@codemirror/state": "^6.0.0",
889+ "@codemirror/view": "^6.0.0"
890+ }
891+ },
892+ "node_modules/crelt": {
893+ "version": "1.0.7",
894+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
895+ "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
896+ "license": "MIT"
897+ },
898+ "node_modules/esbuild": {
899+ "version": "0.24.2",
900+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
901+ "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
902+ "dev": true,
903+ "hasInstallScript": true,
904+ "license": "MIT",
905+ "bin": {
906+ "esbuild": "bin/esbuild"
907+ },
908+ "engines": {
909+ "node": ">=18"
910+ },
911+ "optionalDependencies": {
912+ "@esbuild/aix-ppc64": "0.24.2",
913+ "@esbuild/android-arm": "0.24.2",
914+ "@esbuild/android-arm64": "0.24.2",
915+ "@esbuild/android-x64": "0.24.2",
916+ "@esbuild/darwin-arm64": "0.24.2",
917+ "@esbuild/darwin-x64": "0.24.2",
918+ "@esbuild/freebsd-arm64": "0.24.2",
919+ "@esbuild/freebsd-x64": "0.24.2",
920+ "@esbuild/linux-arm": "0.24.2",
921+ "@esbuild/linux-arm64": "0.24.2",
922+ "@esbuild/linux-ia32": "0.24.2",
923+ "@esbuild/linux-loong64": "0.24.2",
924+ "@esbuild/linux-mips64el": "0.24.2",
925+ "@esbuild/linux-ppc64": "0.24.2",
926+ "@esbuild/linux-riscv64": "0.24.2",
927+ "@esbuild/linux-s390x": "0.24.2",
928+ "@esbuild/linux-x64": "0.24.2",
929+ "@esbuild/netbsd-arm64": "0.24.2",
930+ "@esbuild/netbsd-x64": "0.24.2",
931+ "@esbuild/openbsd-arm64": "0.24.2",
932+ "@esbuild/openbsd-x64": "0.24.2",
933+ "@esbuild/sunos-x64": "0.24.2",
934+ "@esbuild/win32-arm64": "0.24.2",
935+ "@esbuild/win32-ia32": "0.24.2",
936+ "@esbuild/win32-x64": "0.24.2"
937+ }
938+ },
939+ "node_modules/style-mod": {
940+ "version": "4.1.3",
941+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
942+ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
943+ "license": "MIT"
944+ },
945+ "node_modules/w3c-keyname": {
946+ "version": "2.2.8",
947+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
948+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
949+ "license": "MIT"
950+ }
951+ }
952+}
Acrates/df-web/assets/editor/package.json+28−0
@@ −0,0 +1,28 @@
1+{
2+ "name": "dogfood-editor",
3+ "private": true,
4+ "description": "The CodeMirror bundle for in-browser file editing. Built at image build time by esbuild; no Node reaches the runtime image.",
5+ "dependencies": {
6+ "@codemirror/lang-cpp": "^6.0.2",
7+ "@codemirror/lang-css": "^6.3.1",
8+ "@codemirror/lang-html": "^6.4.9",
9+ "@codemirror/lang-java": "^6.0.1",
10+ "@codemirror/lang-javascript": "^6.2.2",
11+ "@codemirror/lang-json": "^6.0.1",
12+ "@codemirror/lang-markdown": "^6.3.1",
13+ "@codemirror/lang-php": "^6.0.1",
14+ "@codemirror/lang-python": "^6.1.6",
15+ "@codemirror/lang-rust": "^6.0.1",
16+ "@codemirror/lang-sql": "^6.8.0",
17+ "@codemirror/lang-xml": "^6.1.0",
18+ "@codemirror/lang-yaml": "^6.1.2",
19+ "@codemirror/legacy-modes": "^6.4.2",
20+ "@codemirror/language": "^6.10.8",
21+ "@codemirror/state": "^6.5.0",
22+ "@codemirror/view": "^6.36.1",
23+ "codemirror": "^6.0.1"
24+ },
25+ "devDependencies": {
26+ "esbuild": "^0.24.2"
27+ }
28+}
Acrates/df-web/assets/fonts/jetbrains-mono-400.woff2binary

Binary file not shown.

Acrates/df-web/assets/fonts/jetbrains-mono-500.woff2binary

Binary file not shown.

Acrates/df-web/assets/fonts/plex-condensed-500.woff2binary

Binary file not shown.

Acrates/df-web/assets/fonts/plex-condensed-600.woff2binary

Binary file not shown.

Acrates/df-web/assets/fonts/plex-condensed-700.woff2binary

Binary file not shown.

Acrates/df-web/assets/fonts/plex-sans-400.woff2binary

Binary file not shown.

Acrates/df-web/assets/fonts/plex-sans-500.woff2binary

Binary file not shown.

Acrates/df-web/assets/fonts/plex-sans-600.woff2binary

Binary file not shown.

Acrates/df-web/assets/htmx.min.js+1−0
@@ −0,0 +1,1 @@
1+var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e<t.length;e++){n.push(t[e])}}return n}function se(t,n){if(t){for(let e=0;e<t.length;e++){n(t[e])}}}function X(e){const t=e.getBoundingClientRect();const n=t.top;const r=t.bottom;return n<window.innerHeight&&r>=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e<r.length;e++){const l=r[e];if(l===","&&t===0){o.push(r.substring(n,e));n=e+1;continue}if(l==="<"){t++}else if(l==="/"&&e<r.length-1&&r[e+1]===">"){t--}}if(n<r.length){o.push(r.substring(n))}}const i=[];const s=[];while(o.length>0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e<r.length;e++){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_PRECEDING){return o}}};var me=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=r.length-1;e>=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e<n.length;e++){if(t===n[e]){return true}}return false}function Oe(t,n){se(t.attributes,function(e){if(!n.hasAttribute(e.name)&&Ce(e.name)){t.removeAttribute(e.name)}});se(n.attributes,function(e){if(Ce(e.name)){t.setAttribute(e.name,e.value)}})}function Re(t,e){const n=Un(e);for(let e=0;e<n.length;e++){const r=n[e];try{if(r.isInlineSwap(t)){return true}}catch(e){O(e)}}return t==="outerHTML"}function He(e,o,i,t){t=t||ne();let n="#"+ee(o,"id");let s="outerHTML";if(e==="true"){}else if(e.indexOf(":")>0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","<div id='--htmx-preserve-pantry--'></div>");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n<e.length){t=(t<<5)-t+e.charCodeAt(n++)|0}return t}function Pe(t){let n=0;if(t.attributes){for(let e=0;e<t.attributes.length;e++){const r=t.attributes[e];if(r.value){n=Ie(r.name,n);n=Ie(r.value,n)}}}return n}function ke(t){const n=ie(t);if(n.onHandlers){for(let e=0;e<n.onHandlers.length;e++){const r=n.onHandlers[e];be(t,r.event,r.listener)}delete n.onHandlers}}function De(e){const t=ie(e);if(t.timeout){clearTimeout(t.timeout)}if(t.listenerInfos){se(t.listenerInfos,function(e){if(e.on){be(e.on,e.trigger,e.listener)}})}ke(e);se(Object.keys(t),function(e){if(e!=="firstInitCompleted")delete t[e]})}function b(e){he(e,"htmx:beforeCleanupElement");De(e);if(e.children){se(e.children,function(e){b(e)})}}function Me(t,e,n){if(t instanceof Element&&t.tagName==="BODY"){return Ve(t,e,n)}let r;const o=t.previousSibling;const i=c(t);if(!i){return}a(i,t,e,n);if(o==null){r=i.firstChild}else{r=o.nextSibling}n.elts=n.elts.filter(function(e){return e!==t});while(r&&r!==t){if(r instanceof Element){n.elts.push(r)}r=r.nextSibling}b(t);if(t instanceof Element){t.remove()}else{t.parentNode.removeChild(t)}}function Xe(e,t,n){return a(e,e.firstChild,t,n)}function Fe(e,t,n){return a(c(e),e,t,n)}function Be(e,t,n){return a(e,null,t,n)}function Ue(e,t,n){return a(c(e),e.nextSibling,t,n)}function je(e){b(e);const t=c(e);if(t){return t.removeChild(e)}}function Ve(e,t,n){const r=e.firstChild;a(e,r,t,n);if(r){while(r.nextSibling){b(r.nextSibling);e.removeChild(r.nextSibling)}b(r);e.removeChild(r)}}function _e(t,e,n,r,o){switch(t){case"none":return;case"outerHTML":Me(n,r,o);return;case"afterbegin":Xe(n,r,o);return;case"beforebegin":Fe(n,r,o);return;case"beforeend":Be(n,r,o);return;case"afterend":Ue(n,r,o);return;case"delete":je(n);return;default:var i=Un(e);for(let e=0;e<i.length;e++){const s=i[e];try{const l=s.handleSwap(t,n,r,o);if(l){if(Array.isArray(l)){for(let e=0;e<l.length;e++){const c=l[e];if(c.nodeType!==Node.TEXT_NODE&&c.nodeType!==Node.COMMENT_NODE){o.tasks.push(Ae(c))}}}return}}catch(e){O(e)}}if(t==="innerHTML"){Ve(n,r,o)}else{_e(Q.config.defaultSwapStyle,e,n,r,o)}}}function ze(e,n,r){var t=x(e,"[hx-swap-oob], [data-hx-swap-oob]");se(t,function(e){if(Q.config.allowNestedOobSwaps||e.parentElement===null){const t=te(e,"hx-swap-oob");if(t!=null){He(t,e,n,r)}}else{e.removeAttribute("hx-swap-oob");e.removeAttribute("data-hx-swap-oob")}});return t.length>0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t<u.length;t++){const a=u[t].split(":",2);let e=a[0].trim();if(e.indexOf("#")===0){e=e.substring(1)}const f=a[1]||"true";const h=n.querySelector("#"+e);if(h){He(f,h,l,i)}}}ze(n,l,i);se(x(n,"template"),function(e){if(e.content&&ze(e.content,l,i)){e.remove()}});if(o.select){const d=ne().createDocumentFragment();se(n.querySelectorAll(o.select),function(e){d.appendChild(e)});n=d}qe(n);_e(r.swapStyle,o.contextElement,e,n,l);Te()}if(s.elt&&!le(s.elt)&&ee(s.elt,"id")){const g=document.getElementById(ee(s.elt,"id"));const p={preventScroll:r.focusScroll!==undefined?!r.focusScroll:!Q.config.defaultFocusScroll};if(g){if(s.start&&g.setSelectionRange){try{g.setSelectionRange(s.start,s.end)}catch(e){}}g.focus(p)}}e.classList.remove(Q.config.swappingClass);se(l.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}he(e,"htmx:afterSwap",o.eventInfo)});if(o.afterSwapCallback){o.afterSwapCallback()}if(!r.ignoreTitle){kn(l.title)}const c=function(){se(l.tasks,function(e){e.call()});se(l.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}he(e,"htmx:afterSettle",o.eventInfo)});if(o.anchor){const e=ue(y("#"+o.anchor));if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}yn(l.elts,r);if(o.afterSettleCallback){o.afterSettleCallback()}};if(r.settleDelay>0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e<s.length;e++){he(n,s[e].trim(),[])}}}const Ke=/\s/;const v=/[\s,]/;const Ge=/[_$a-zA-Z]/;const We=/[_$a-zA-Z0-9]/;const Ze=['"',"'","/"];const w=/[^\s]/;const Ye=/[{(]/;const Qe=/[})]/;function et(e){const t=[];let n=0;while(n<e.length){if(Ge.exec(e.charAt(n))){var r=n;while(We.exec(e.charAt(n+1))){n++}t.push(e.substring(r,n+1))}else if(Ze.indexOf(e.charAt(n))!==-1){const o=e.charAt(n);var r=n;n++;while(n<e.length&&e.charAt(n)!==o){if(e.charAt(n)==="\\"){n++}n++}t.push(e.substring(r,n+1))}else{const i=e.charAt(n);t.push(i)}n++}return t}function tt(e,t,n){return Ge.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==n&&t!=="."}function nt(r,o,i){if(o[0]==="["){o.shift();let e=1;let t=" return (function("+i+"){ return (";let n=null;while(o.length>0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e<t.length;e++){const n=t[e];if(n.isIntersecting){he(r,"intersect");break}}},o);i.observe(ue(r));pt(ue(r),n,t,e)}else if(!t.firstInitCompleted&&e.trigger==="load"){if(!gt(e,r,Mt("load",{elt:r}))){vt(ue(r),n,t,e.delay)}}else if(e.pollInterval>0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e<n.length;e++){const r=n[e].name;if(l(r,"hx-on:")||l(r,"data-hx-on:")||l(r,"hx-on-")||l(r,"data-hx-on-")){return true}}return false}const Ct=(new XPathEvaluator).createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or'+' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]');function Ot(e,t){if(Et(e)){t.push(ue(e))}const n=Ct.evaluate(e);let r=null;while(r=n.iterateNext())t.push(ue(r))}function Rt(e){const t=[];if(e instanceof DocumentFragment){for(const n of e.childNodes){Ot(n,t)}}else{Ot(e,t)}return t}function Ht(e){if(e.querySelectorAll){const n=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]";const r=[];for(const i in Mn){const s=Mn[i];if(s.getSelectors){var t=s.getSelectors();if(t){r.push(t)}}}const o=e.querySelectorAll(H+n+", form, [type='submit'],"+" [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]"+r.flat().map(e=>", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;e<t.attributes.length;e++){const n=t.attributes[e].name;const r=t.attributes[e].value;if(l(n,"hx-on")||l(n,"data-hx-on")){const o=n.indexOf("-on")+3;const i=n.slice(o,o+1);if(i==="-"||i===":"){let e=n.slice(o+1);if(l(e,":")){e="htmx"+e}else if(l(e,"-")){e="htmx:"+e.slice(1)}else if(l(e,"htmx-")){e="htmx:"+e.slice(5)}Nt(t,e,r)}}}}function Pt(t){if(g(t,Q.config.disableSelector)){b(t);return}const n=ie(t);const e=Pe(t);if(n.initHash!==e){De(t);n.initHash=e;he(t,"htmx:beforeProcessNode");const r=st(t);const o=wt(t,n,r);if(!o){if(re(t,"hx-boost")==="true"){ft(t,n,r)}else if(s(t,"hx-trigger")){r.forEach(function(e){St(t,e,n,function(){})})}}if(t.tagName==="FORM"||ee(t,"type")==="submit"&&s(t,"form")){At(t)}n.firstInitCompleted=true;he(t,"htmx:afterProcessNode")}}function kt(e){e=y(e);if(g(e,Q.config.disableSelector)){b(e);return}Pt(e);se(Ht(e),function(e){Pt(e)});se(Rt(e),It)}function Dt(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function Mt(e,t){let n;if(window.CustomEvent&&typeof window.CustomEvent==="function"){n=new CustomEvent(e,{bubbles:true,cancelable:true,composed:true,detail:t})}else{n=ne().createEvent("CustomEvent");n.initCustomEvent(e,true,true,t)}return n}function fe(e,t,n){he(e,t,ce({error:t},n))}function Xt(e){return e==="htmx:afterProcessNode"}function Ft(e,t){se(Un(e),function(e){try{t(e)}catch(e){O(e)}})}function O(e){if(console.error){console.error(e)}else if(console.log){console.log("ERROR: ",e)}}function he(e,t,n){e=y(e);if(n==null){n={}}n.elt=e;const r=Mt(t,n);if(Q.logger&&!Xt(t)){Q.logger(e,t,n)}if(n.error){O(n.error);he(e,"htmx:error",{errorInfo:n})}let o=e.dispatchEvent(r);const i=Dt(t);if(o&&i!==t){const s=Mt(i,r.detail);o=o&&e.dispatchEvent(s)}Ft(ue(e),function(e){o=o&&(e.onEvent(t,r)!==false&&!r.defaultPrevented)});return o}let Bt=location.pathname+location.search;function Ut(){const e=ne().querySelector("[hx-history-elt],[data-hx-history-elt]");return e||ne().body}function jt(t,e){if(!B()){return}const n=_t(e);const r=ne().title;const o=window.scrollY;if(Q.config.historyCacheSize<=0){localStorage.removeItem("htmx-history-cache");return}t=U(t);const i=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<i.length;e++){if(i[e].url===t){i.splice(e,1);break}}const s={url:t,content:n,title:r,scroll:o};he(ne().body,"htmx:historyItemCreated",{item:s,cache:i});i.push(s);while(i.length>Q.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<n.length;e++){if(n[e].url===t){return n[e]}}return null}function _t(e){const t=Q.config.requestClass;const n=e.cloneNode(true);se(x(n,"."+t),function(e){G(e,t)});se(x(n,"[data-disabled-by-htmx]"),function(e){e.removeAttribute("disabled")});return n.innerHTML}function zt(){const e=Ut();const t=Bt||location.pathname+location.search;let n;try{n=ne().querySelector('[hx-history="false" i],[data-hx-history="false" i]')}catch(e){n=ne().querySelector('[hx-history="false"],[data-hx-history="false"]')}if(!n){he(ne().body,"htmx:beforeHistorySave",{path:t,historyElt:e});jt(t,e)}if(Q.config.historyEnabled)history.replaceState({htmx:true},ne().title,window.location.href)}function $t(e){if(Q.config.getCacheBusterParam){e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,"");if(Y(e,"&")||Y(e,"?")){e=e.slice(0,-1)}}if(Q.config.historyEnabled){history.pushState({htmx:true},"",e)}Bt=e}function Jt(e){if(Q.config.historyEnabled)history.replaceState({htmx:true},"",e);Bt=e}function Kt(e){se(e,function(e){e.call(undefined)})}function Gt(o){const e=new XMLHttpRequest;const i={path:o,xhr:e};he(ne().body,"htmx:historyCacheMiss",i);e.open("GET",o,true);e.setRequestHeader("HX-Request","true");e.setRequestHeader("HX-History-Restore-Request","true");e.setRequestHeader("HX-Current-URL",ne().location.href);e.onload=function(){if(this.status>=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;e<t.length;e++){const r=t[e];if(r.isSameNode(n)){return true}}return false}function tn(e){const t=e;if(t.name===""||t.name==null||t.disabled||g(t,"fieldset[disabled]")){return false}if(t.type==="button"||t.type==="submit"||t.tagName==="image"||t.tagName==="reset"||t.tagName==="file"){return false}if(t.type==="checkbox"||t.type==="radio"){return t.checked}return true}function nn(t,e,n){if(t!=null&&e!=null){if(Array.isArray(e)){e.forEach(function(e){n.append(t,e)})}else{n.append(t,e)}}}function rn(t,n,r){if(t!=null&&n!=null){let e=r.getAll(t);if(Array.isArray(n)){e=e.filter(e=>n.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e<s.length;e++){const l=s[e];if(l.indexOf("swap:")===0){r.swapDelay=d(l.slice(5))}else if(l.indexOf("settle:")===0){r.settleDelay=d(l.slice(7))}else if(l.indexOf("transition:")===0){r.transition=l.slice(11)==="true"}else if(l.indexOf("ignoreTitle:")===0){r.ignoreTitle=l.slice(12)==="true"}else if(l.indexOf("scroll:")===0){const c=l.slice(7);var o=c.split(":");const u=o.pop();var i=o.length>0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t<Q.config.responseHandling.length;t++){var n=Q.config.responseHandling[t];if(In(n,e.status)){return n}}return{swap:false}}function kn(e){if(e){const t=u("title");if(t){t.innerHTML=e}else{window.document.title=e}}}function Dn(o,i){const s=i.xhr;let l=i.target;const e=i.etc;const c=i.select;if(!he(o,"htmx:beforeOnLoad",i))return;if(R(s,/HX-Trigger:/i)){Je(s,"HX-Trigger",o)}if(R(s,/HX-Location:/i)){zt();let e=s.getResponseHeader("HX-Location");var t;if(e.indexOf("{")===0){t=S(e);e=t.path;delete t.path}Rn("get",e,t).then(function(){$t(e)});return}const n=R(s,/HX-Refresh:/i)&&s.getResponseHeader("HX-Refresh")==="true";if(R(s,/HX-Redirect:/i)){i.keepIndicators=true;location.href=s.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){i.keepIndicators=true;location.reload();return}if(R(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ue(ae(o,s.getResponseHeader("HX-Retarget")))}}const u=Nn(o,i);const r=Pn(s);const a=r.swap;let f=!!r.error;let h=Q.config.ignoreTitle||r.ignoreTitle;let d=r.select;if(r.target){i.target=ue(ae(o,r.target))}var g=e.swapOverride;if(g==null&&r.swapOverride){g=r.swapOverride}if(R(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ue(ae(o,s.getResponseHeader("HX-Retarget")))}}if(R(s,/HX-Reswap:/i)){g=s.getResponseHeader("HX-Reswap")}var p=s.response;var m=ce({shouldSwap:a,serverResponse:p,isError:f,ignoreTitle:h,selectOverride:d,swapOverride:g},i);if(r.event&&!he(l,r.event,m))return;if(!he(l,"htmx:beforeSwap",m))return;l=m.target;p=m.serverResponse;f=m.isError;h=m.ignoreTitle;d=m.selectOverride;g=m.swapOverride;i.target=l;i.failed=f;i.successful=!f;if(m.shouldSwap){if(s.status===286){lt(o)}Ft(o,function(e){p=e.transformResponse(p,s,o)});if(u.type){zt()}var x=gn(o,g);if(!x.hasOwnProperty("ignoreTitle")){x.ignoreTitle=h}l.classList.add(Q.config.swappingClass);let n=null;let r=null;if(c){d=c}if(R(s,/HX-Reselect:/i)){d=s.getResponseHeader("HX-Reselect")}const y=re(o,"hx-select-oob");const b=re(o,"hx-select");let e=function(){try{if(u.type){he(ne().body,"htmx:beforeHistoryUpdate",ce({history:u},i));if(u.type==="push"){$t(u.path);he(ne().body,"htmx:pushedIntoHistory",{path:u.path})}else{Jt(u.path);he(ne().body,"htmx:replacedInHistory",{path:u.path})}}$e(l,p,x,{select:d||b,selectOOB:y,eventInfo:i,anchor:i.pathInfo.anchor,contextElement:o,afterSwapCallback:function(){if(R(s,/HX-Trigger-After-Swap:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Swap",e)}},afterSettleCallback:function(){if(R(s,/HX-Trigger-After-Settle:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Settle",e)}oe(n)}})}catch(e){fe(o,"htmx:swapError",i);oe(r);throw e}};let t=Q.config.globalViewTransitions;if(x.hasOwnProperty("transition")){t=x.transition}if(t&&he(o,"htmx:beforeTransition",i)&&typeof Promise!=="undefined"&&document.startViewTransition){const v=new Promise(function(e,t){n=e;r=t});const w=e;e=function(){document.startViewTransition(function(){w();return v})}}if(x.swapDelay>0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend","<style"+e+"> ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} </style>")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}();
Acrates/df-web/assets/theme-init.js+12−0
@@ −0,0 +1,12 @@
1+// Applied before the body renders, so a stored "light" preference does not
2+// flash the default dark theme on first paint. Kept separate from theme.js
3+// (which is deferred) because this one has to block instead.
4+(function () {
5+ try {
6+ if (localStorage.getItem("dogfood-theme") === "light") {
7+ document.documentElement.setAttribute("data-theme", "light");
8+ }
9+ } catch (e) {
10+ // Storage blocked — falls back to the default theme, same as no JS.
11+ }
12+})();
Acrates/df-web/assets/theme.js+35−0
@@ −0,0 +1,35 @@
1+// Theme toggle. Enhancement only — the site is fully usable in its default
2+// dark theme with this script blocked or absent; it just cannot be switched.
3+(function () {
4+ var KEY = "dogfood-theme";
5+
6+ function apply(theme) {
7+ if (theme === "light") {
8+ document.documentElement.setAttribute("data-theme", "light");
9+ } else {
10+ document.documentElement.removeAttribute("data-theme");
11+ }
12+ }
13+
14+ document.querySelectorAll("[data-theme-toggle]").forEach(function (btn) {
15+ var current = document.documentElement.getAttribute("data-theme") === "light"
16+ ? "light"
17+ : "dark";
18+ btn.setAttribute("aria-pressed", String(current === "light"));
19+ btn.hidden = false;
20+
21+ btn.addEventListener("click", function () {
22+ var next = document.documentElement.getAttribute("data-theme") === "light"
23+ ? "dark"
24+ : "light";
25+ apply(next);
26+ try {
27+ localStorage.setItem(KEY, next);
28+ } catch (e) {
29+ // Storage blocked (private mode, quota). The toggle still works for
30+ // this page load; it just will not persist across visits.
31+ }
32+ btn.setAttribute("aria-pressed", String(next === "light"));
33+ });
34+ });
35+})();
Acrates/df-web/build.rs+32−0
@@ −0,0 +1,32 @@
1+//! Make the CodeMirror bundle optional at compile time.
2+//!
3+//! `assets/editor.min.js` is produced by esbuild in a Docker build stage, so it
4+//! does not exist in a source checkout. `include_str!` needs *something*
5+//! though, and a missing file would make `cargo build` fail outside Docker —
6+//! which would mean the test suite could not run without a Node toolchain.
7+//!
8+//! An empty placeholder is the right fallback rather than a workaround: the
9+//! edit page ships a working `<textarea>` and the bundle only enhances it, so a
10+//! build without the bundle produces a product where editing still works,
11+//! plainly. That is the documented behaviour when the script fails to load, and
12+//! this makes it the behaviour when it was never built either.
13+
14+use std::path::Path;
15+
16+fn main() {
17+ let bundle = Path::new("assets/editor.min.js");
18+ println!("cargo:rerun-if-changed=assets/editor.min.js");
19+
20+ if !bundle.exists() {
21+ std::fs::write(
22+ bundle,
23+ "/* No editor bundle in this build. The edit page falls back to a \
24+ plain textarea, which is a working editor. */\n",
25+ )
26+ .expect("writing the editor placeholder");
27+ println!(
28+ "cargo:warning=assets/editor.min.js was not built; \
29+ the web editor will fall back to a plain textarea"
30+ );
31+ }
32+}
Acrates/df-web/src/config.rs+307−0
@@ −0,0 +1,307 @@
1+//! Runtime configuration, read from the environment.
2+//!
3+//! Everything is validated at startup. A process that cannot serve correctly —
4+//! no database, no OIDC, a placeholder session secret — must fail loudly at
5+//! boot rather than at the first request that needs it.
6+
7+use anyhow::{bail, Context, Result};
8+
9+#[derive(Debug, Clone)]
10+pub struct Config {
11+ pub database_url: String,
12+ pub database_max_connections: u32,
13+
14+ pub base_url: String,
15+ pub bind: String,
16+ pub repo_root: String,
17+
18+ pub ssh_clone_host: String,
19+ pub ssh_clone_port: u16,
20+
21+ pub oidc_issuer: String,
22+ pub oidc_client_id: String,
23+ pub oidc_client_secret: String,
24+ pub oidc_redirect_url: String,
25+ pub oidc_scopes: String,
26+
27+ /// Emails admitted without an invitation. Blank entries are dropped so a
28+ /// stray comma cannot widen access.
29+ pub allowlist: Vec<String>,
30+
31+ pub session_secret: Vec<u8>,
32+ pub session_ttl_days: i64,
33+
34+ /// Path to the compiled `dogfood-hook` binary, as seen from *inside* the
35+ /// container that serves pushes. Installed into each repository's `hooks/`
36+ /// directory (spec §4: "Hooks are the compiled `dogfood-hook` binary …
37+ /// Do not use shell scripts").
38+ pub hook_binary: String,
39+
40+ pub max_pack_bytes: u64,
41+ pub max_blob_render_bytes: usize,
42+ pub max_diff_files: usize,
43+ pub max_diff_lines: usize,
44+}
45+
46+fn var(key: &str) -> Result<String> {
47+ std::env::var(key).with_context(|| format!("{key} must be set"))
48+}
49+
50+fn var_or(key: &str, default: &str) -> String {
51+ std::env::var(key).unwrap_or_else(|_| default.to_string())
52+}
53+
54+fn parse_num<T: std::str::FromStr>(key: &str, default: T) -> Result<T>
55+where
56+ T::Err: std::fmt::Display,
57+{
58+ match std::env::var(key) {
59+ Ok(v) => v
60+ .parse::<T>()
61+ .map_err(|e| anyhow::anyhow!("{key} is not a valid number: {e}")),
62+ Err(_) => Ok(default),
63+ }
64+}
65+
66+impl Config {
67+ pub fn from_env() -> Result<Self> {
68+ let base_url = var_or("BASE_URL", "http://localhost:8080")
69+ .trim_end_matches('/')
70+ .to_string();
71+
72+ let session_secret_hex = var("SESSION_SECRET")?;
73+ let session_secret = hex::decode(session_secret_hex.trim())
74+ .context("SESSION_SECRET must be hex")?;
75+ if session_secret.len() < 32 {
76+ bail!(
77+ "SESSION_SECRET must be at least 32 bytes ({} given); \
78+ generate with `openssl rand -hex 32`",
79+ session_secret.len()
80+ );
81+ }
82+
83+ let allowlist = var_or("DOGFOOD_ALLOWLIST", "")
84+ .split(',')
85+ .map(|s| s.trim().to_string())
86+ .filter(|s| !s.is_empty())
87+ .collect::<Vec<_>>();
88+
89+ if allowlist.is_empty() {
90+ // Not fatal — invitations still work — but silence here would look
91+ // like a broken login rather than a deliberate policy.
92+ tracing::warn!(
93+ "DOGFOOD_ALLOWLIST is empty; only invited users will be able to sign in"
94+ );
95+ }
96+
97+ let cfg = Config {
98+ database_url: var("DATABASE_URL")?,
99+ database_max_connections: parse_num("DATABASE_MAX_CONNECTIONS", 10)?,
100+
101+ bind: var_or("BIND", "0.0.0.0:8080"),
102+ repo_root: var_or("REPO_ROOT", "/srv/repos"),
103+
104+ ssh_clone_host: var_or("SSH_CLONE_HOST", "localhost"),
105+ ssh_clone_port: parse_num("SSH_CLONE_PORT", 2222u16)?,
106+
107+ oidc_issuer: var("OIDC_ISSUER")?,
108+ oidc_client_id: var("OIDC_CLIENT_ID")?,
109+ oidc_client_secret: var("OIDC_CLIENT_SECRET")?,
110+ oidc_redirect_url: var_or(
111+ "OIDC_REDIRECT_URL",
112+ &format!("{base_url}/auth/callback"),
113+ ),
114+ oidc_scopes: var_or("OIDC_SCOPES", "openid profile email"),
115+
116+ allowlist,
117+ session_secret,
118+ session_ttl_days: parse_num("SESSION_TTL_DAYS", 14i64)?,
119+
120+ hook_binary: var_or("DOGFOOD_HOOK_BINARY", "/usr/local/bin/dogfood-hook"),
121+ max_pack_bytes: parse_num("MAX_PACK_BYTES", 524_288_000u64)?,
122+ max_blob_render_bytes: parse_num("MAX_BLOB_RENDER_BYTES", 1_048_576)?,
123+ max_diff_files: parse_num("MAX_DIFF_FILES", 5_000)?,
124+ max_diff_lines: parse_num("MAX_DIFF_LINES", 100_000)?,
125+
126+ base_url,
127+ };
128+
129+ cfg.validate()?;
130+ Ok(cfg)
131+ }
132+
133+ fn validate(&self) -> Result<()> {
134+ if !self.base_url.starts_with("http://") && !self.base_url.starts_with("https://") {
135+ bail!("BASE_URL must be an absolute http(s) URL, got {}", self.base_url);
136+ }
137+ // The redirect URI must match what is registered with Hydra exactly, so
138+ // a mismatch is worth catching here rather than as an opaque OAuth error.
139+ if !self.oidc_redirect_url.starts_with(&self.base_url) {
140+ tracing::warn!(
141+ "OIDC_REDIRECT_URL ({}) is not under BASE_URL ({}); \
142+ this is only correct behind a rewriting proxy",
143+ self.oidc_redirect_url,
144+ self.base_url
145+ );
146+ }
147+ if self.session_ttl_days < 1 {
148+ bail!("SESSION_TTL_DAYS must be at least 1");
149+ }
150+ Ok(())
151+ }
152+
153+ /// Whether cookies may carry the `Secure` attribute.
154+ ///
155+ /// Always true in production. Over plain HTTP a `Secure` cookie is silently
156+ /// dropped by the browser, which presents as "login does nothing" — so it
157+ /// is tied to the scheme rather than hardcoded.
158+ pub fn secure_cookies(&self) -> bool {
159+ self.base_url.starts_with("https://")
160+ }
161+
162+ /// The SSH clone URL shown in the UI.
163+ pub fn ssh_clone_url(&self, owner: &str, repo: &str) -> String {
164+ if self.ssh_clone_port == 22 {
165+ format!("git@{}:{owner}/{repo}.git", self.ssh_clone_host)
166+ } else {
167+ // Port 22 is taken by the host sshd on this deployment, so the
168+ // explicit-port form is the normal case.
169+ format!(
170+ "ssh://git@{}:{}/{owner}/{repo}.git",
171+ self.ssh_clone_host, self.ssh_clone_port
172+ )
173+ }
174+ }
175+
176+ pub fn https_clone_url(&self, owner: &str, repo: &str) -> String {
177+ format!("{}/{owner}/{repo}.git", self.base_url)
178+ }
179+
180+ /// A configuration for tests, with every cap set small.
181+ ///
182+ /// Small caps on purpose: a test that trips a limit should trip it quickly
183+ /// rather than needing a megabyte of fixture to get there.
184+ #[cfg(test)]
185+ pub fn for_tests() -> Config {
186+ Config {
187+ database_url: "postgres://test".into(),
188+ database_max_connections: 4,
189+ base_url: "https://dogfood.test".into(),
190+ bind: "127.0.0.1:0".into(),
191+ repo_root: "/tmp/df-test-repos".into(),
192+ ssh_clone_host: "dogfood.test".into(),
193+ ssh_clone_port: 2222,
194+ oidc_issuer: "https://oidc.test/".into(),
195+ oidc_client_id: "test-client".into(),
196+ oidc_client_secret: "test-secret".into(),
197+ oidc_redirect_url: "https://dogfood.test/auth/callback".into(),
198+ oidc_scopes: "openid profile email".into(),
199+ allowlist: vec![],
200+ session_secret: vec![7; 32],
201+ session_ttl_days: 14,
202+ hook_binary: "/usr/local/bin/dogfood-hook".into(),
203+ max_pack_bytes: 1024 * 1024,
204+ max_blob_render_bytes: 64 * 1024,
205+ max_diff_files: 50,
206+ max_diff_lines: 5_000,
207+ }
208+ }
209+
210+ /// The instance's hostname, without scheme or port.
211+ ///
212+ /// Used to build the `noreply` addresses that end up in merge commits, so
213+ /// it must be a bare host: an author line containing `https://` or a colon
214+ /// produces a commit object Git will not parse.
215+ pub fn host(&self) -> &str {
216+ self.base_url
217+ .split("://")
218+ .nth(1)
219+ .unwrap_or(&self.base_url)
220+ .split('/')
221+ .next()
222+ .unwrap_or("localhost")
223+ .split(':')
224+ .next()
225+ .unwrap_or("localhost")
226+ }
227+}
228+
229+#[cfg(test)]
230+mod tests {
231+ use super::*;
232+
233+ fn cfg(base: &str, port: u16) -> Config {
234+ Config {
235+ database_url: "postgres://x".into(),
236+ database_max_connections: 5,
237+ base_url: base.into(),
238+ bind: "0.0.0.0:8080".into(),
239+ repo_root: "/srv/repos".into(),
240+ ssh_clone_host: "dogfood.sh".into(),
241+ ssh_clone_port: port,
242+ oidc_issuer: "https://oauth.dogfood.sh/".into(),
243+ oidc_client_id: "id".into(),
244+ oidc_client_secret: "secret".into(),
245+ oidc_redirect_url: format!("{base}/auth/callback"),
246+ oidc_scopes: "openid".into(),
247+ allowlist: vec![],
248+ session_secret: vec![0; 32],
249+ session_ttl_days: 14,
250+ hook_binary: "/usr/local/bin/dogfood-hook".into(),
251+ max_pack_bytes: 1024,
252+ max_blob_render_bytes: 1024,
253+ max_diff_files: 10,
254+ max_diff_lines: 10,
255+ }
256+ }
257+
258+ #[test]
259+ fn ssh_clone_url_includes_the_port_when_not_22() {
260+ let c = cfg("https://dogfood.sh", 2222);
261+ assert_eq!(
262+ c.ssh_clone_url("dogfood", "dogfood"),
263+ "ssh://git@dogfood.sh:2222/dogfood/dogfood.git"
264+ );
265+ }
266+
267+ #[test]
268+ fn ssh_clone_url_uses_scp_syntax_on_port_22() {
269+ let c = cfg("https://dogfood.sh", 22);
270+ assert_eq!(
271+ c.ssh_clone_url("dogfood", "dogfood"),
272+ "git@dogfood.sh:dogfood/dogfood.git"
273+ );
274+ }
275+
276+ #[test]
277+ fn https_clone_url_is_built_from_base_url() {
278+ let c = cfg("https://dogfood.sh", 2222);
279+ assert_eq!(
280+ c.https_clone_url("dogfood", "dogfood"),
281+ "https://dogfood.sh/dogfood/dogfood.git"
282+ );
283+ }
284+
285+ #[test]
286+ fn secure_cookies_follow_the_scheme() {
287+ assert!(cfg("https://dogfood.sh", 2222).secure_cookies());
288+ assert!(
289+ !cfg("http://localhost:8080", 2222).secure_cookies(),
290+ "a Secure cookie over plain http is dropped by the browser"
291+ );
292+ }
293+
294+ #[test]
295+ fn validate_rejects_a_relative_base_url() {
296+ let mut c = cfg("dogfood.sh", 2222);
297+ c.base_url = "dogfood.sh".into();
298+ assert!(c.validate().is_err());
299+ }
300+
301+ #[test]
302+ fn validate_rejects_a_zero_session_ttl() {
303+ let mut c = cfg("https://dogfood.sh", 2222);
304+ c.session_ttl_days = 0;
305+ assert!(c.validate().is_err());
306+ }
307+}
Acrates/df-web/src/error.rs+75−0
@@ −0,0 +1,75 @@
1+//! Request error handling.
2+//!
3+//! The important rule (spec §9): an unauthorised private repo and a nonexistent
4+//! repo must be indistinguishable. [`AppError::NotFound`] is therefore the only
5+//! way to say "you can't see this" — there is deliberately no `Forbidden`
6+//! variant for repository access.
7+
8+use axum::http::StatusCode;
9+use axum::response::{IntoResponse, Response};
10+
11+#[derive(Debug)]
12+pub enum AppError {
13+ /// Also the response for "exists but you may not see it".
14+ NotFound,
15+ BadRequest(String),
16+ /// Signed in, but lacking a permission that is not itself a secret —
17+ /// e.g. trying to change settings on a repo you can already read.
18+ Forbidden,
19+ /// Not signed in, on a page that requires it.
20+ Unauthorized,
21+ Internal(anyhow::Error),
22+}
23+
24+impl std::fmt::Display for AppError {
25+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26+ match self {
27+ AppError::NotFound => write!(f, "not found"),
28+ AppError::BadRequest(m) => write!(f, "bad request: {m}"),
29+ AppError::Forbidden => write!(f, "forbidden"),
30+ AppError::Unauthorized => write!(f, "unauthorized"),
31+ AppError::Internal(e) => write!(f, "internal error: {e}"),
32+ }
33+ }
34+}
35+
36+impl<E: Into<anyhow::Error>> From<E> for AppError {
37+ fn from(e: E) -> Self {
38+ AppError::Internal(e.into())
39+ }
40+}
41+
42+impl IntoResponse for AppError {
43+ fn into_response(self) -> Response {
44+ let (status, message) = match self {
45+ AppError::NotFound => (StatusCode::NOT_FOUND, "Not found".to_string()),
46+ AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
47+ AppError::Forbidden => (
48+ StatusCode::FORBIDDEN,
49+ "You do not have permission to do that.".to_string(),
50+ ),
51+ AppError::Unauthorized => {
52+ // Send them to sign in rather than rendering a dead end.
53+ return axum::response::Redirect::to("/login").into_response();
54+ }
55+ AppError::Internal(e) => {
56+ // Log the detail; never show it. Internal errors routinely carry
57+ // connection strings and object ids.
58+ tracing::error!("internal error: {e:#}");
59+ // In tests the detail goes to stderr as well. A failing security
60+ // test that can only report "500" is nearly useless, and this
61+ // path is compiled out of every real build.
62+ #[cfg(test)]
63+ eprintln!("internal error: {e:#}");
64+ (
65+ StatusCode::INTERNAL_SERVER_ERROR,
66+ "Something went wrong.".to_string(),
67+ )
68+ }
69+ };
70+
71+ (status, crate::views::layout::bare_error(status, &message)).into_response()
72+ }
73+}
74+
75+pub type AppResult<T> = Result<T, AppError>;
Acrates/df-web/src/git_http/auth.rs+144−0
@@ −0,0 +1,144 @@
1+//! HTTP Basic authentication for Git clients (spec §6).
2+//!
3+//! "Git over HTTPS uses Basic auth: username is the user handle, password is a
4+//! personal access token."
5+//!
6+//! The handle is accepted but not trusted for identity — the token alone
7+//! determines who the caller is. A token that belongs to a different user than
8+//! the supplied handle still authenticates as its real owner rather than
9+//! being rejected, because the handle is decoration in the credential helper
10+//! and users routinely have it stale.
11+
12+use axum::http::HeaderMap;
13+use base64::Engine;
14+use df_db::models::User;
15+use uuid::Uuid;
16+
17+use crate::error::AppResult;
18+use crate::state::AppState;
19+
20+/// Decode `Authorization: Basic …` into (username, password).
21+fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> {
22+ let raw = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?;
23+ let encoded = raw.strip_prefix("Basic ").or_else(|| raw.strip_prefix("basic "))?;
24+
25+ let decoded = base64::engine::general_purpose::STANDARD
26+ .decode(encoded.trim())
27+ .ok()?;
28+ let text = String::from_utf8(decoded).ok()?;
29+
30+ // The password may itself contain a colon, so split only on the first.
31+ let (user, pass) = text.split_once(':')?;
32+ Some((user.to_string(), pass.to_string()))
33+}
34+
35+/// Authenticate a Git request, if credentials were supplied.
36+///
37+/// Returns `Ok(None)` for an anonymous request — which is legitimate for
38+/// reading a public repository — and for credentials that do not verify. The
39+/// caller turns that into a challenge or a 404 as appropriate; it never
40+/// distinguishes "no credentials" from "bad credentials" to the client.
41+pub async fn authenticate(state: &AppState, headers: &HeaderMap) -> AppResult<Option<User>> {
42+ let Some((handle, token)) = parse_basic(headers) else {
43+ return Ok(None);
44+ };
45+
46+ let Some(user_id) = df_auth::tokens::verify(&state.db, &token).await? else {
47+ tracing::info!(%handle, "git authentication failed: token did not verify");
48+ return Ok(None);
49+ };
50+
51+ let user = sqlx::query_as::<_, User>(
52+ "SELECT id, subject, handle, display_name, email, avatar_url, is_admin, created_at
53+ FROM users WHERE id = $1",
54+ )
55+ .bind(user_id)
56+ .fetch_optional(&state.db)
57+ .await?;
58+
59+ if let Some(u) = &user {
60+ if !u.handle.eq_ignore_ascii_case(&handle) {
61+ // Not an error: credential helpers cache stale usernames. The token
62+ // is the authority.
63+ tracing::debug!(
64+ supplied = %handle,
65+ actual = %u.handle,
66+ "git basic-auth handle does not match the token owner; using the token owner"
67+ );
68+ }
69+ }
70+
71+ Ok(user)
72+}
73+
74+/// The authenticated user's id, for attributing a push.
75+pub async fn authenticated_user_id(state: &AppState, headers: &HeaderMap) -> Option<Uuid> {
76+ authenticate(state, headers).await.ok().flatten().map(|u| u.id)
77+}
78+
79+#[cfg(test)]
80+mod tests {
81+ use super::*;
82+ use axum::http::HeaderValue;
83+
84+ fn headers_with(value: &str) -> HeaderMap {
85+ let mut h = HeaderMap::new();
86+ h.insert(
87+ axum::http::header::AUTHORIZATION,
88+ HeaderValue::from_str(value).unwrap(),
89+ );
90+ h
91+ }
92+
93+ fn basic(user: &str, pass: &str) -> String {
94+ format!(
95+ "Basic {}",
96+ base64::engine::general_purpose::STANDARD.encode(format!("{user}:{pass}"))
97+ )
98+ }
99+
100+ #[test]
101+ fn parses_ordinary_credentials() {
102+ let h = headers_with(&basic("snow", "dgf_abc123"));
103+ assert_eq!(
104+ parse_basic(&h),
105+ Some(("snow".into(), "dgf_abc123".into()))
106+ );
107+ }
108+
109+ #[test]
110+ fn a_password_may_contain_colons() {
111+ // Splitting on the last colon, or on all of them, would corrupt tokens.
112+ let h = headers_with(&basic("snow", "a:b:c"));
113+ assert_eq!(parse_basic(&h), Some(("snow".into(), "a:b:c".into())));
114+ }
115+
116+ #[test]
117+ fn an_empty_username_is_still_parsed() {
118+ // Some credential helpers send only a token, with an empty username.
119+ let h = headers_with(&basic("", "dgf_token"));
120+ assert_eq!(parse_basic(&h), Some(("".into(), "dgf_token".into())));
121+ }
122+
123+ #[test]
124+ fn missing_or_malformed_headers_yield_none() {
125+ assert_eq!(parse_basic(&HeaderMap::new()), None);
126+ assert_eq!(parse_basic(&headers_with("Bearer sometoken")), None);
127+ assert_eq!(parse_basic(&headers_with("Basic !!!not-base64!!!")), None);
128+ // Valid base64, but no colon separator.
129+ let no_colon = base64::engine::general_purpose::STANDARD.encode("nocolon");
130+ assert_eq!(parse_basic(&headers_with(&format!("Basic {no_colon}"))), None);
131+ }
132+
133+ #[test]
134+ fn the_scheme_match_is_case_insensitive() {
135+ let encoded = base64::engine::general_purpose::STANDARD.encode("a:b");
136+ assert!(parse_basic(&headers_with(&format!("basic {encoded}"))).is_some());
137+ }
138+
139+ #[test]
140+ fn non_utf8_credentials_are_rejected_rather_than_panicking() {
141+ let encoded = base64::engine::general_purpose::STANDARD.encode([0xff, 0xfe, 0x3a, 0x61]);
142+ assert_eq!(parse_basic(&headers_with(&format!("Basic {encoded}"))), None);
143+ }
144+}
Acrates/df-web/src/git_http/mod.rs481 lines+481−0
@@ −0,0 +1,481 @@
1+//! Git smart HTTP (spec §7).
2+//!
3+//! `jj git push` and plain `git push` are both ordinary Git pushes over this
4+//! transport — the server never runs `jj`. We shell out to `git upload-pack`
5+//! and `git receive-pack`, which is what every forge does: reimplementing the
6+//! pack protocol buys nothing and gets the edge cases wrong.
7+//!
8+//! Authentication is HTTP Basic with a personal access token as the password
9+//! (spec §6), because Git clients cannot perform an OIDC browser flow.
10+
11+use std::process::Stdio;
12+
13+use axum::extract::{Path as UrlPath, Query, State};
14+use axum::http::{header, HeaderMap, StatusCode};
15+use axum::response::{IntoResponse, Response};
16+use df_db::ids::new_id;
17+use df_store::RepoId;
18+use serde::Deserialize;
19+use tokio::io::AsyncWriteExt;
20+use uuid::Uuid;
21+
22+use crate::error::{AppError, AppResult};
23+use crate::repo_ctx::RepoContext;
24+use crate::state::AppState;
25+
26+mod auth;
27+
28+/// Wall-clock cap on any Git subprocess.
29+///
30+/// Spec §9: "A `git-upload-pack` on a maliciously constructed repo can consume
31+/// unbounded CPU — run it with a timeout."
32+const GIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
33+
34+/// Cap on a fetch request body.
35+///
36+/// `git-upload-pack` receives want/have negotiation lines, not objects. Even a
37+/// pathological client with an enormous have-list stays far below this, so a
38+/// body larger than it is not a fetch.
39+const UPLOAD_PACK_REQUEST_LIMIT: u64 = 16 * 1024 * 1024;
40+
41+#[derive(Deserialize)]
42+pub struct ServiceQuery {
43+ pub service: Option<String>,
44+}
45+
46+/// `GET /{owner}/{repo}.git/info/refs?service=…`
47+///
48+/// Reference advertisement. This is the first request any clone or push makes,
49+/// so it is also where authentication is challenged.
50+pub async fn info_refs(
51+ State(state): State<AppState>,
52+ UrlPath((owner, repo)): UrlPath<(String, String)>,
53+ Query(q): Query<ServiceQuery>,
54+ headers: HeaderMap,
55+) -> AppResult<Response> {
56+ let service = q.service.unwrap_or_default();
57+
58+ // Only the two real services. Anything else is either a probe or an
59+ // attempt to reach a different git command.
60+ let write = match service.as_str() {
61+ "git-upload-pack" => false,
62+ "git-receive-pack" => true,
63+ _ => return Err(AppError::NotFound),
64+ };
65+
66+ let repo = strip_git_suffix(&repo);
67+ let ctx = match resolve(&state, &owner, &repo, &headers, write).await? {
68+ Resolved::Ok(ctx) => ctx,
69+ Resolved::Challenge => return Ok(unauthorized()),
70+ };
71+
72+ let dir = repo_dir(&state, &ctx);
73+ let out = run_git(
74+ &[
75+ service.trim_start_matches("git-"),
76+ "--stateless-rpc",
77+ "--advertise-refs",
78+ ".",
79+ ],
80+ &dir,
81+ GitInput::Empty,
82+ None,
83+ )
84+ .await?;
85+
86+ // The advertisement is prefixed with a pkt-line naming the service.
87+ let mut body = pkt_line(&format!("# service={service}\n"));
88+ body.extend_from_slice(b"0000");
89+ body.extend_from_slice(&out);
90+
91+ Ok((
92+ StatusCode::OK,
93+ [
94+ (
95+ header::CONTENT_TYPE,
96+ format!("application/x-{service}-advertisement"),
97+ ),
98+ (header::CACHE_CONTROL, "no-cache".into()),
99+ ],
100+ body,
101+ )
102+ .into_response())
103+}
104+
105+/// `POST /{owner}/{repo}.git/git-upload-pack` — a clone or fetch.
106+pub async fn upload_pack(
107+ State(state): State<AppState>,
108+ UrlPath((owner, repo)): UrlPath<(String, String)>,
109+ headers: HeaderMap,
110+ body: axum::body::Body,
111+) -> AppResult<Response> {
112+ let repo = strip_git_suffix(&repo);
113+ let ctx = match resolve(&state, &owner, &repo, &headers, false).await? {
114+ Resolved::Ok(ctx) => ctx,
115+ Resolved::Challenge => return Ok(unauthorized()),
116+ };
117+
118+ let dir = repo_dir(&state, &ctx);
119+ let out = run_git(
120+ &["upload-pack", "--stateless-rpc", "."],
121+ &dir,
122+ // A fetch request is want/have lines, not a pack. The cap is generous
123+ // for that and still bounded.
124+ GitInput::Stream { body, max_bytes: UPLOAD_PACK_REQUEST_LIMIT },
125+ // A fetch does not run the pre-receive hook.
126+ None,
127+ )
128+ .await?;
129+
130+ Ok(git_response("git-upload-pack-result", out))
131+}
132+
133+/// `POST /{owner}/{repo}.git/git-receive-pack` — a push.
134+///
135+/// After the pack lands, an `IndexPush` job is enqueued and we return
136+/// immediately; indexing happens in the worker (spec §4).
137+pub async fn receive_pack(
138+ State(state): State<AppState>,
139+ UrlPath((owner, repo)): UrlPath<(String, String)>,
140+ headers: HeaderMap,
141+ body: axum::body::Body,
142+) -> AppResult<Response> {
143+ let repo = strip_git_suffix(&repo);
144+
145+ // Push requires write, so an anonymous request is challenged rather than
146+ // 404'd — the client needs to know to send credentials.
147+ let ctx = match resolve(&state, &owner, &repo, &headers, true).await? {
148+ Resolved::Ok(ctx) => ctx,
149+ Resolved::Challenge => return Ok(unauthorized()),
150+ };
151+
152+ // An archived repository is read-only. The settings page promises that, so
153+ // it has to be true on the wire and not only in the UI.
154+ if ctx.repo.archived {
155+ return Err(AppError::BadRequest(
156+ "this repository is archived and does not accept pushes".into(),
157+ ));
158+ }
159+
160+ // The pack cap (spec §9) is enforced as the body streams, in `run_git` —
161+ // `Content-Length` is a claim by the client and checking it here would be
162+ // checking the claim rather than the bytes.
163+ let limit = state.config.max_pack_bytes;
164+
165+ let pusher = auth::authenticated_user_id(&state, &headers).await;
166+
167+ let dir = repo_dir(&state, &ctx);
168+ let out = run_git(
169+ &["receive-pack", "--stateless-rpc", "."],
170+ &dir,
171+ GitInput::Stream { body, max_bytes: limit },
172+ Some(HookEnv {
173+ database_url: state.config.database_url.clone(),
174+ pusher_id: pusher,
175+ }),
176+ )
177+ .await?;
178+
179+ // Enqueue indexing. A failure here must not fail the push — the objects are
180+ // already durably written, and `dogfood-admin reindex` can recover the
181+ // index. Failing the push would make the client retry a push that already
182+ // succeeded.
183+ if let Err(e) = enqueue_index(&state, ctx.repo.id, pusher).await {
184+ tracing::error!(repo = %ctx.repo.id, "enqueuing IndexPush failed: {e:#}");
185+ }
186+
187+ if let Err(e) = sqlx::query("UPDATE repos SET pushed_at = now() WHERE id = $1")
188+ .bind(ctx.repo.id)
189+ .execute(&state.db)
190+ .await
191+ {
192+ tracing::warn!("recording push time failed: {e}");
193+ }
194+
195+ Ok(git_response("git-receive-pack-result", out))
196+}
197+
198+/// Queue an indexing job for a repository.
199+pub async fn enqueue_index(
200+ state: &AppState,
201+ repo_id: Uuid,
202+ pushed_by: Option<Uuid>,
203+) -> anyhow::Result<()> {
204+ sqlx::query("INSERT INTO jobs (id, kind, payload) VALUES ($1, 'index_push', $2)")
205+ .bind(new_id())
206+ .bind(serde_json::json!({
207+ "repo_id": repo_id,
208+ "pushed_by": pushed_by,
209+ }))
210+ .execute(&state.db)
211+ .await?;
212+ Ok(())
213+}
214+
215+// ─── plumbing ────────────────────────────────────────────────────────────────
216+
217+enum Resolved {
218+ Ok(RepoContext),
219+ /// Credentials are needed, or the ones supplied were wrong.
220+ Challenge,
221+}
222+
223+/// Resolve the repository and authorize the operation.
224+///
225+/// Read access to a public repo is anonymous. Anything else needs a token.
226+/// Returning `Challenge` rather than 404 for an unauthenticated *write* is
227+/// deliberate: without it, `git push` to a private repo fails with a confusing
228+/// "not found" instead of prompting for credentials. It leaks only that a push
229+/// endpoint exists, which is true of every path on the host.
230+async fn resolve(
231+ state: &AppState,
232+ owner: &str,
233+ name: &str,
234+ headers: &HeaderMap,
235+ write: bool,
236+) -> AppResult<Resolved> {
237+ let user = auth::authenticate(state, headers).await?;
238+
239+ let ctx = match RepoContext::load(state, owner, name, user.as_ref()).await {
240+ Ok(ctx) => ctx,
241+ Err(AppError::NotFound) => {
242+ // Unauthenticated: could be a private repo. Challenge so a client
243+ // with credentials can retry.
244+ return Ok(if user.is_none() {
245+ Resolved::Challenge
246+ } else {
247+ return Err(AppError::NotFound);
248+ });
249+ }
250+ Err(e) => return Err(e),
251+ };
252+
253+ if write && !ctx.access.can_push() {
254+ return Ok(if user.is_none() {
255+ Resolved::Challenge
256+ } else {
257+ // Authenticated but not permitted: say so plainly, since they can
258+ // already see the repository.
259+ return Err(AppError::Forbidden);
260+ });
261+ }
262+
263+ Ok(Resolved::Ok(ctx))
264+}
265+
266+fn unauthorized() -> Response {
267+ (
268+ StatusCode::UNAUTHORIZED,
269+ [(
270+ header::WWW_AUTHENTICATE,
271+ "Basic realm=\"Dogfood\", charset=\"UTF-8\"",
272+ )],
273+ "Authentication required. Use your handle and a personal access token.\n",
274+ )
275+ .into_response()
276+}
277+
278+fn repo_dir(state: &AppState, ctx: &RepoContext) -> std::path::PathBuf {
279+ // Sharded exactly as GitStore lays it out. Derived from the UUID, never
280+ // from a name (spec §3).
281+ let hex = RepoId(ctx.repo.id).0.simple().to_string();
282+ std::path::Path::new(&state.config.repo_root)
283+ .join(&hex[..2])
284+ .join(format!("{hex}.git"))
285+}
286+
287+/// Strip a trailing `.git` from the URL segment.
288+fn strip_git_suffix(s: &str) -> String {
289+ s.strip_suffix(".git").unwrap_or(s).to_string()
290+}
291+
292+fn git_response(content_type: &str, body: Vec<u8>) -> Response {
293+ (
294+ StatusCode::OK,
295+ [
296+ (
297+ header::CONTENT_TYPE,
298+ format!("application/x-{content_type}"),
299+ ),
300+ (header::CACHE_CONTROL, "no-cache".into()),
301+ ],
302+ body,
303+ )
304+ .into_response()
305+}
306+
307+/// Encode a pkt-line.
308+fn pkt_line(s: &str) -> Vec<u8> {
309+ let mut out = format!("{:04x}", s.len() + 4).into_bytes();
310+ out.extend_from_slice(s.as_bytes());
311+ out
312+}
313+
314+/// What to feed a git subprocess on stdin.
315+enum GitInput {
316+ /// Nothing — the ref advertisement takes no input.
317+ Empty,
318+ /// The request body, streamed. Never collected into memory: a pack is
319+ /// hundreds of megabytes by design, and buffering one per concurrent push
320+ /// is how a forge runs a host out of RAM.
321+ Stream {
322+ body: axum::body::Body,
323+ /// Hard cap. Exceeding it kills the transfer rather than letting an
324+ /// unbounded pack through (spec §9).
325+ max_bytes: u64,
326+ },
327+}
328+
329+/// What the pre-receive hook needs from its environment.
330+///
331+/// `git` is spawned with a cleared environment, so anything the hook needs has
332+/// to be handed to it explicitly. Without `DATABASE_URL` the hook cannot check
333+/// protected bookmarks — and it used to skip that check silently, which meant
334+/// bookmark protection did not exist over HTTPS at all.
335+struct HookEnv {
336+ database_url: String,
337+ pusher_id: Option<Uuid>,
338+}
339+
340+/// Run a git subprocess with a timeout, feeding it `input`.
341+async fn run_git(
342+ args: &[&str],
343+ dir: &std::path::Path,
344+ input: GitInput,
345+ hook_env: Option<HookEnv>,
346+) -> AppResult<Vec<u8>> {
347+ if !dir.is_dir() {
348+ // The database knows about a repository the filesystem does not. Real
349+ // after a partial restore (spec §10).
350+ tracing::error!(?dir, "repository directory is missing");
351+ return Err(AppError::NotFound);
352+ }
353+
354+ let mut cmd = tokio::process::Command::new("git");
355+ cmd.args(args)
356+ .current_dir(dir)
357+ .stdin(Stdio::piped())
358+ .stdout(Stdio::piped())
359+ .stderr(Stdio::piped())
360+ // Never let a repository's own config influence what we run, and never
361+ // let git prompt for anything.
362+ .env_clear()
363+ .env("PATH", "/usr/bin:/bin")
364+ .env("GIT_TERMINAL_PROMPT", "0")
365+ .env("GIT_CONFIG_NOSYSTEM", "1")
366+ .env("HOME", "/nonexistent")
367+ // Advertise object-format and allow the protocol v2 the client asks for.
368+ .env("GIT_PROTOCOL", "version=2");
369+
370+ // Only the push path installs these, and only the push path runs the hook.
371+ if let Some(hook) = hook_env {
372+ cmd.env("DATABASE_URL", hook.database_url);
373+ // Marks the check as mandatory: the hook refuses the push if it is set
374+ // and the check cannot be performed, rather than failing open.
375+ cmd.env("DOGFOOD_ENFORCE", "1");
376+ if let Some(id) = hook.pusher_id {
377+ cmd.env("DOGFOOD_PUSHER_ID", id.to_string());
378+ }
379+ }
380+
381+ let mut child = cmd
382+ .spawn()
383+ .map_err(|e| AppError::Internal(anyhow::anyhow!("spawning git: {e}")))?;
384+
385+ if let Some(mut stdin) = child.stdin.take() {
386+ // Write on a task: a large pack exceeds the pipe buffer many times over,
387+ // and writing inline would deadlock against a child that is blocked
388+ // writing stdout.
389+ match input {
390+ GitInput::Empty => {
391+ let _ = stdin.shutdown().await;
392+ }
393+ GitInput::Stream { body, max_bytes } => {
394+ tokio::spawn(async move {
395+ use futures::StreamExt;
396+
397+ let mut stream = body.into_data_stream();
398+ let mut written: u64 = 0;
399+
400+ while let Some(chunk) = stream.next().await {
401+ let chunk = match chunk {
402+ Ok(c) => c,
403+ Err(e) => {
404+ tracing::debug!("reading the request body failed: {e}");
405+ break;
406+ }
407+ };
408+
409+ written += chunk.len() as u64;
410+ if written > max_bytes {
411+ // Dropping stdin makes git see a truncated pack and
412+ // fail, which is what we want: the objects are not
413+ // written and the client is told the push failed.
414+ tracing::warn!(
415+ max_bytes,
416+ "request body exceeded the pack limit; aborting the transfer"
417+ );
418+ break;
419+ }
420+
421+ if let Err(e) = stdin.write_all(&chunk).await {
422+ tracing::debug!("writing to git stdin failed: {e}");
423+ break;
424+ }
425+ }
426+
427+ let _ = stdin.shutdown().await;
428+ });
429+ }
430+ }
431+ }
432+
433+ let out = match tokio::time::timeout(GIT_TIMEOUT, child.wait_with_output()).await {
434+ Ok(Ok(o)) => o,
435+ Ok(Err(e)) => {
436+ return Err(AppError::Internal(anyhow::anyhow!("running git: {e}")));
437+ }
438+ Err(_) => {
439+ tracing::error!(?args, "git operation timed out");
440+ return Err(AppError::Internal(anyhow::anyhow!("git timed out")));
441+ }
442+ };
443+
444+ if !out.status.success() {
445+ let stderr = String::from_utf8_lossy(&out.stderr);
446+ tracing::error!(?args, status = ?out.status, "git failed: {stderr}");
447+ return Err(AppError::Internal(anyhow::anyhow!("git failed: {stderr}")));
448+ }
449+
450+ Ok(out.stdout)
451+}
452+
453+#[cfg(test)]
454+mod tests {
455+ use super::*;
456+
457+ #[test]
458+ fn pkt_lines_carry_a_four_byte_hex_length() {
459+ // The length is inclusive of the 4-byte header itself.
460+ // "# service=git-upload-pack\n" is 26 bytes, +4 = 30 = 0x1e.
461+ let line = "# service=git-upload-pack\n";
462+ assert_eq!(line.len(), 26);
463+ let p = pkt_line(line);
464+ assert_eq!(&p[..4], b"001e");
465+ assert_eq!(p.len(), 0x1e);
466+ }
467+
468+ #[test]
469+ fn pkt_line_of_empty_input_is_just_the_header() {
470+ assert_eq!(pkt_line(""), b"0004");
471+ }
472+
473+ #[test]
474+ fn strips_only_a_trailing_git_suffix() {
475+ assert_eq!(strip_git_suffix("repo.git"), "repo");
476+ assert_eq!(strip_git_suffix("repo"), "repo");
477+ // A repository legitimately named `x.github` must not be truncated.
478+ assert_eq!(strip_git_suffix("x.github"), "x.github");
479+ assert_eq!(strip_git_suffix("my.git.repo"), "my.git.repo");
480+ }
481+}
Acrates/df-web/src/highlight_cache.rs+142−0
@@ −0,0 +1,142 @@
1+//! Cached syntax highlighting (spec §8).
2+//!
3+//! > Highlight in the worker and cache the rendered HTML in Postgres keyed by
4+//! > blob OID — highlighting the same file on every request is the easiest
5+//! > performance mistake to make here.
6+//!
7+//! **Deviation, deliberate.** Highlighting happens here on a cache miss rather
8+//! than only in the worker. Precomputing in the worker means the *first* view of
9+//! any file renders unhighlighted, which is the view that matters most after a
10+//! push. The property the spec is actually protecting — never highlight the same
11+//! bytes twice — is provided by the cache, which is what this module is. The
12+//! parse itself runs on a blocking thread so a large file cannot stall the
13+//! async runtime.
14+//!
15+//! The cache key is the blob's content address, so it is shared across
16+//! repositories and across revisions: a file that did not change between two
17+//! revisions is highlighted once, ever. Because the key is a pure function of
18+//! the bytes, there is no invalidation problem — different content is a
19+//! different key.
20+
21+use df_render::highlight::{self, Skipped};
22+use df_store::Blob;
23+use sqlx::PgPool;
24+
25+/// Highlighted lines, or the reason there are none.
26+pub struct Rendered {
27+ /// One HTML fragment per line. Empty when `skipped` is set.
28+ pub lines: Vec<String>,
29+ pub language: Option<String>,
30+ /// Set when the file is to be rendered as plain text, with the reason.
31+ pub skipped: Option<Skipped>,
32+}
33+
34+impl Rendered {
35+ fn plain(reason: Skipped) -> Self {
36+ Rendered { lines: Vec::new(), language: None, skipped: Some(reason) }
37+ }
38+}
39+
40+/// Highlight a blob, consulting and populating the Postgres cache.
41+///
42+/// Never fails: a database error degrades to highlighting without the cache,
43+/// and a highlighting failure degrades to plain text. A syntax colour is not
44+/// worth a 500.
45+pub async fn render(db: &PgPool, blob: &Blob) -> Rendered {
46+ let Some(text) = blob.text() else {
47+ return Rendered::plain(Skipped::NoGrammar);
48+ };
49+
50+ // No grammar means nothing to cache — decided from the path alone, so this
51+ // costs one table lookup and avoids a pointless database round trip for
52+ // every LICENSE and .gitignore in the tree.
53+ let Some(lang) = highlight::language_for_path(&blob.path) else {
54+ return Rendered::plain(Skipped::NoGrammar);
55+ };
56+
57+ match load(db, &blob.content_id).await {
58+ Ok(Some(hit)) => return hit,
59+ Ok(None) => {}
60+ Err(e) => tracing::warn!("highlight cache read failed, rendering uncached: {e}"),
61+ }
62+
63+ // tree-sitter is CPU-bound and this is an async handler; parsing a megabyte
64+ // on the runtime thread would stall every other request on that worker.
65+ let path = blob.path.clone();
66+ let source = text.to_owned();
67+ let highlighted =
68+ match tokio::task::spawn_blocking(move || highlight::highlight(&path, &source)).await {
69+ Ok(r) => r,
70+ Err(e) => {
71+ tracing::error!("highlighting panicked: {e}");
72+ return Rendered::plain(Skipped::Failed);
73+ }
74+ };
75+
76+ let out = match highlighted {
77+ Ok(h) => Rendered {
78+ lines: h.lines,
79+ language: Some(h.language.to_string()),
80+ skipped: None,
81+ },
82+ Err(reason) => Rendered::plain(reason),
83+ };
84+
85+ // Only successful renders are cached. Caching a rejection would save
86+ // nothing: every rejection is decided from the path or the size, both of
87+ // which are already known before the lookup.
88+ if out.skipped.is_none() {
89+ if let Err(e) = store(db, &blob.content_id, lang.display, &out.lines).await {
90+ tracing::warn!("highlight cache write failed: {e}");
91+ }
92+ }
93+
94+ out
95+}
96+
97+/// The separator between cached line fragments.
98+///
99+/// A form feed rather than a newline: the HTML fragments themselves are free of
100+/// newlines, but a fragment could in principle contain one from a multi-line
101+/// token, and splitting on the wrong character would silently corrupt the line
102+/// numbering. U+000C cannot appear in the renderer's output.
103+const LINE_SEP: char = '\u{c}';
104+
105+async fn load(db: &PgPool, content_id: &str) -> sqlx::Result<Option<Rendered>> {
106+ let row: Option<(Option<String>, String)> =
107+ sqlx::query_as("SELECT language, html FROM highlight_cache WHERE blob_oid = $1")
108+ .bind(content_id)
109+ .fetch_optional(db)
110+ .await?;
111+
112+ Ok(row.map(|(language, html)| Rendered {
113+ lines: if html.is_empty() {
114+ Vec::new()
115+ } else {
116+ html.split(LINE_SEP).map(str::to_owned).collect()
117+ },
118+ language,
119+ skipped: None,
120+ }))
121+}
122+
123+async fn store(db: &PgPool, content_id: &str, language: &str, lines: &[String]) -> sqlx::Result<()> {
124+ let html = lines.join(&LINE_SEP.to_string());
125+
126+ // ON CONFLICT DO NOTHING: two requests racing on the same blob both compute
127+ // the same bytes, so whichever lands first is correct and the other is a
128+ // no-op rather than a lock wait.
129+ sqlx::query(
130+ "INSERT INTO highlight_cache (blob_oid, language, html, bytes)
131+ VALUES ($1, $2, $3, $4)
132+ ON CONFLICT (blob_oid) DO NOTHING",
133+ )
134+ .bind(content_id)
135+ .bind(language)
136+ .bind(&html)
137+ .bind(html.len() as i32)
138+ .execute(db)
139+ .await?;
140+
141+ Ok(())
142+}
Acrates/df-web/src/hooks.rs+77−0
@@ −0,0 +1,77 @@
1+//! Keeping the pre-receive hook installed (spec §4, §9).
2+//!
3+//! > Hooks are the compiled `dogfood-hook` binary, installed into each repo's
4+//! > `hooks/` directory at creation.
5+//!
6+//! Installing at creation is necessary but not sufficient. A repository created
7+//! before the hook existed, restored from a backup taken before it, or created
8+//! by a code path that forgot the call, has no hook — and a *missing* hook fails
9+//! open. Nothing errors; pushes simply stop being validated. Protected bookmarks
10+//! silently become unprotected and the ref-name allowlist silently stops
11+//! applying.
12+//!
13+//! That is exactly the shape of bug this sweep exists to make impossible: on
14+//! every boot, every repository gets the hook reinstalled. The write is
15+//! idempotent and costs one small file per repository, which is nothing next to
16+//! a validation control that is off without anybody noticing.
17+
18+use std::sync::Arc;
19+
20+use df_db::PgPool;
21+use df_store::{RepoId, RepoStore};
22+use uuid::Uuid;
23+
24+/// Install the hook into one repository.
25+///
26+/// Called at creation, and by the boot sweep. Errors are the caller's to
27+/// report: at creation a failure should be loud, during the sweep it should not
28+/// stop the process from starting.
29+pub async fn install(store: &dyn RepoStore, repo_id: Uuid, hook_binary: &str) -> anyhow::Result<()> {
30+ store
31+ .configure_receive_validation(RepoId(repo_id), hook_binary)
32+ .await
33+ .map_err(|e| anyhow::anyhow!("installing the pre-receive hook: {e}"))
34+}
35+
36+/// Reinstall the hook on every repository, at startup.
37+///
38+/// Runs in the background so a slow filesystem cannot delay serving, and never
39+/// fails the process — an instance that cannot install hooks should still come
40+/// up, loudly complaining, rather than refuse to start.
41+pub fn sweep_in_background(db: PgPool, store: Arc<dyn RepoStore>, hook_binary: String) {
42+ tokio::spawn(async move {
43+ let repos: Vec<(Uuid,)> = match sqlx::query_as("SELECT id FROM repos").fetch_all(&db).await {
44+ Ok(r) => r,
45+ Err(e) => {
46+ tracing::error!("could not list repositories to install hooks: {e}");
47+ return;
48+ }
49+ };
50+
51+ let total = repos.len();
52+ let mut failed = 0usize;
53+
54+ for (id,) in repos {
55+ // A repository row whose storage is missing is a real state after a
56+ // partial restore (spec §10). It is reported by the reindex path,
57+ // not here, so this only counts hook failures.
58+ if !store.exists(RepoId(id)).await {
59+ continue;
60+ }
61+ if let Err(e) = install(store.as_ref(), id, &hook_binary).await {
62+ failed += 1;
63+ tracing::error!(repo = %id, "{e:#}");
64+ }
65+ }
66+
67+ if failed > 0 {
68+ tracing::error!(
69+ total,
70+ failed,
71+ "some repositories are accepting pushes WITHOUT validation"
72+ );
73+ } else {
74+ tracing::info!(total, "pre-receive hooks verified on every repository");
75+ }
76+ });
77+}
Acrates/df-web/src/main.rs464 lines+464−0
@@ −0,0 +1,464 @@
1+//! `dogfood-web` — the HTTP server.
2+
3+use std::sync::Arc;
4+
5+use anyhow::{Context, Result};
6+use axum::routing::{delete, get, post};
7+use axum::Router;
8+use tower_http::catch_panic::CatchPanicLayer;
9+use tower_http::trace::TraceLayer;
10+
11+mod config;
12+mod error;
13+mod git_http;
14+mod highlight_cache;
15+mod hooks;
16+mod middleware;
17+mod ratelimit;
18+mod repo_ctx;
19+mod revset;
20+mod routes;
21+#[cfg(test)]
22+mod security_tests;
23+mod state;
24+mod views;
25+
26+use config::Config;
27+use state::{AppState, Inner};
28+
29+#[tokio::main]
30+async fn main() -> Result<()> {
31+ // `.env` is a convenience for local runs; in Docker the values arrive as
32+ // real environment variables and this is a no-op.
33+ let _ = dotenvy::dotenv();
34+
35+ init_tracing();
36+
37+ let config = Config::from_env().context("loading configuration")?;
38+ tracing::info!(base_url = %config.base_url, "starting dogfood-web");
39+
40+ let db = df_db::connect(&config.database_url, config.database_max_connections)
41+ .await
42+ .context("connecting to the database")?;
43+
44+ // Migrations run on web startup under an advisory lock (spec §10).
45+ df_db::migrate(&db).await.context("running migrations")?;
46+
47+ // Mint the admin bootstrap token if nobody is admin yet. Logged once; only
48+ // its hash is stored, so it cannot be recovered afterwards.
49+ match df_auth::provisioning::ensure_setup_token(&db).await {
50+ Ok(Some(token)) => {
51+ tracing::warn!(
52+ "\n\n\
53+ ┌───────────────────────────────────────────────────────────────┐\n\
54+ │ SETUP TOKEN — claim site admin at {}/setup\n\
55+ │ {}\n\
56+ │ Shown once. Only its hash is stored.\n\
57+ └───────────────────────────────────────────────────────────────┘\n",
58+ config.base_url,
59+ token
60+ );
61+ }
62+ Ok(None) => tracing::debug!("no setup token needed"),
63+ Err(e) => tracing::error!("could not prepare setup token: {e:#}"),
64+ }
65+
66+ let oidc = df_auth::Oidc::discover(
67+ &config.oidc_issuer,
68+ &config.oidc_client_id,
69+ &config.oidc_client_secret,
70+ &config.oidc_redirect_url,
71+ &config.oidc_scopes,
72+ )
73+ .await
74+ .context("OIDC discovery")?;
75+ tracing::info!(issuer = %config.oidc_issuer, "OIDC relying party ready");
76+
77+ let store: Arc<dyn df_store::RepoStore> = Arc::new(
78+ df_store::GitStore::new(&config.repo_root)
79+ .with_max_blob_bytes(config.max_blob_render_bytes as u64 * 8),
80+ );
81+ tracing::info!(root = %config.repo_root, "repository storage ready");
82+
83+ let bind = config.bind.clone();
84+ let state = AppState(Arc::new(Inner {
85+ db,
86+ oidc,
87+ config,
88+ store,
89+ limiter: ratelimit::Limiter::new(),
90+ }));
91+
92+ // Reinstall the pre-receive hook everywhere. A missing hook fails *open* —
93+ // pushes stop being validated with no error anywhere — so this runs on
94+ // every boot rather than only at repository creation (spec §4, §9).
95+ hooks::sweep_in_background(
96+ state.db.clone(),
97+ state.store.clone(),
98+ state.config.hook_binary.clone(),
99+ );
100+
101+ let app = build_router(state);
102+
103+ let listener = tokio::net::TcpListener::bind(&bind)
104+ .await
105+ .with_context(|| format!("binding {bind}"))?;
106+ tracing::info!("listening on {bind}");
107+
108+ axum::serve(
109+ listener,
110+ app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
111+ )
112+ .with_graceful_shutdown(shutdown_signal())
113+ .await
114+ .context("server error")?;
115+
116+ Ok(())
117+}
118+
119+fn build_router(state: AppState) -> Router {
120+ Router::new()
121+ // ─── pages ───────────────────────────────────────────────────────────
122+ .route("/", get(routes::home::index))
123+ .route("/dashboard", get(routes::home::dashboard))
124+ .route("/design", get(routes::design::sheet))
125+ .route("/design/rationale", get(routes::design::rationale))
126+ .route("/login", get(routes::auth::login))
127+ .route("/auth/callback", get(routes::auth::callback))
128+ .route("/auth/handle", post(routes::auth::choose_handle))
129+ .route("/logout", post(routes::auth::logout))
130+ .route("/setup", get(routes::setup::show).post(routes::setup::claim))
131+ // ─── repositories ────────────────────────────────────────────────────
132+ .route("/new", get(routes::repo::new_form))
133+ .route("/repos", post(routes::repo::create))
134+ // ─── the signed-in user's own settings ───────────────────────────────
135+ // The POST aliases exist because a browser form cannot emit DELETE and
136+ // spec §7 makes working without JavaScript a hard requirement; the
137+ // DELETE routes are the same handlers, for API clients.
138+ .route("/search", get(routes::search::search))
139+ .route("/orgs/new", get(routes::org::new_form))
140+ .route("/orgs", post(routes::org::create))
141+ .route("/settings", get(routes::settings::show))
142+ .route("/settings/keys", post(routes::settings::add_key))
143+ .route("/settings/keys/{id}", delete(routes::settings::delete_key))
144+ .route("/settings/keys/{id}/delete", post(routes::settings::delete_key))
145+ .route("/settings/tokens", post(routes::settings::create_token))
146+ .route("/settings/tokens/{id}", delete(routes::settings::delete_token))
147+ .route("/settings/tokens/{id}/delete", post(routes::settings::delete_token))
148+ // ─── assets ──────────────────────────────────────────────────────────
149+ .route("/assets/app.css", get(serve_css))
150+ .route("/assets/fonts/{name}", get(serve_font))
151+ .route("/assets/htmx.min.js", get(serve_htmx))
152+ .route("/assets/theme-init.js", get(serve_theme_init_js))
153+ .route("/assets/theme.js", get(serve_theme_js))
154+ .route("/assets/editor.js", get(serve_editor_js))
155+ // ─── operational ─────────────────────────────────────────────────────
156+ // Repository routes are declared last so their `{owner}` wildcard
157+ // cannot shadow a fixed path like /new or /settings.
158+ .route("/{owner}", get(routes::profile::show))
159+ // Org membership lives under `/-/` so it cannot collide with a
160+ // repository named `members`.
161+ .route(
162+ "/{owner}/-/members",
163+ get(routes::org::members).post(routes::org::add_member),
164+ )
165+ .route("/{owner}/-/members/remove", post(routes::org::remove_member))
166+ .route("/{owner}/{repo}", get(routes::repo::index))
167+ .route("/{owner}/{repo}/settings", get(routes::repo_settings::show))
168+ .route("/{owner}/{repo}/settings/general", post(routes::repo_settings::update_general))
169+ .route(
170+ "/{owner}/{repo}/settings/collaborators",
171+ post(routes::repo_settings::upsert_collaborator),
172+ )
173+ .route(
174+ "/{owner}/{repo}/settings/collaborators/remove",
175+ post(routes::repo_settings::remove_collaborator),
176+ )
177+ .route("/{owner}/{repo}/settings/bookmarks", post(routes::repo_settings::protect_bookmark))
178+ .route("/{owner}/{repo}/settings/archive", post(routes::repo_settings::archive))
179+ .route("/{owner}/{repo}/settings/delete", post(routes::repo_settings::delete))
180+ .route("/{owner}/{repo}/tree/{rev}/{*path}", get(routes::repo::tree))
181+ .route("/{owner}/{repo}/blob/{rev}/{*path}", get(routes::repo::blob))
182+ .route("/{owner}/{repo}/raw/{rev}/{*path}", get(routes::repo::raw))
183+ // In-browser editing. A write-level action on a bookmark, which is why
184+ // it sits beside the browse routes rather than under /settings.
185+ .route(
186+ "/{owner}/{repo}/edit/{rev}/{*path}",
187+ get(routes::edit::show).post(routes::edit::save),
188+ )
189+ .route("/{owner}/{repo}/log", get(routes::repo::log))
190+ .route(
191+ "/{owner}/{repo}/changes",
192+ get(routes::change::list).post(routes::change::create),
193+ )
194+ .route("/{owner}/{repo}/changes/new", get(routes::change::new_form))
195+ // ─── one change ──────────────────────────────────────────────────────
196+ .route("/{owner}/{repo}/changes/{reference}", get(routes::review::overview))
197+ .route("/{owner}/{repo}/changes/{reference}/files", get(routes::review::files))
198+ .route("/{owner}/{repo}/changes/{reference}/revisions", get(routes::review::revisions))
199+ .route("/{owner}/{repo}/changes/{reference}/conflicts", get(routes::review::conflicts))
200+ .route("/{owner}/{repo}/changes/{reference}/comments", post(routes::review::create_comment))
201+ .route(
202+ "/{owner}/{repo}/changes/{reference}/comments/{id}/resolve",
203+ post(routes::review::resolve_comment),
204+ )
205+ .route("/{owner}/{repo}/changes/{reference}/reviews", post(routes::review::create_review))
206+ .route("/{owner}/{repo}/changes/{reference}/edit", post(routes::review::edit))
207+ .route("/{owner}/{repo}/changes/{reference}/state", post(routes::review::set_state))
208+ .route("/{owner}/{repo}/changes/{reference}/merge", post(routes::review::merge))
209+ // ─── stacks ──────────────────────────────────────────────────────────
210+ .route("/{owner}/{repo}/stacks/{change_id}", get(routes::review::stack))
211+ .route("/{owner}/{repo}/stacks/{change_id}/merge", post(routes::review::merge_stack))
212+ .route("/{owner}/{repo}/bookmarks", get(routes::repo::bookmarks))
213+ // ─── issues ──────────────────────────────────────────────────────────
214+ .route(
215+ "/{owner}/{repo}/issues",
216+ get(routes::issue::list).post(routes::issue::create),
217+ )
218+ .route("/{owner}/{repo}/issues/new", get(routes::issue::new_form))
219+ .route("/{owner}/{repo}/issues/{number}", get(routes::issue::detail))
220+ .route("/{owner}/{repo}/issues/{number}/comments", post(routes::issue::comment))
221+ .route("/{owner}/{repo}/issues/{number}/state", post(routes::issue::set_state))
222+ .route("/{owner}/{repo}/issues/{number}/labels", post(routes::issue::set_labels))
223+ .route("/{owner}/{repo}/issues/{number}/assignees", post(routes::issue::set_assignees))
224+ // ─── git smart HTTP ──────────────────────────────────────────────────
225+ .route("/{owner}/{repo}/info/refs", get(git_http::info_refs))
226+ // `DefaultBodyLimit` is 2 MB, which is far below any real pack — with
227+ // it in place every push over a couple of megabytes fails with a 413
228+ // and `MAX_PACK_BYTES` never gets a chance to apply. It is disabled
229+ // here and the real cap is enforced on the stream inside `git_http`,
230+ // where it counts bytes rather than trusting Content-Length.
231+ .route(
232+ "/{owner}/{repo}/git-upload-pack",
233+ post(git_http::upload_pack).layer(axum::extract::DefaultBodyLimit::disable()),
234+ )
235+ .route(
236+ "/{owner}/{repo}/git-receive-pack",
237+ post(git_http::receive_pack).layer(axum::extract::DefaultBodyLimit::disable()),
238+ )
239+ // ─── operational ─────────────────────────────────────────────────────
240+ .route("/healthz", get(routes::health::healthz))
241+ .route("/readyz", get(routes::health::readyz))
242+ // Loopback-only, enforced per request from the real peer address
243+ // (spec §7, §10).
244+ .route("/metrics", get(routes::metrics::metrics))
245+ // Order matters. Layers run outermost-last in this builder, so the
246+ // effective order per request is: security → session → rate limit →
247+ // handler. The limiter runs *after* the session so an authenticated
248+ // request gets its own bucket rather than sharing its neighbours'
249+ // address, and *inside* the security layer so a 429 still carries the
250+ // security headers.
251+ .layer(axum::middleware::from_fn_with_state(
252+ state.clone(),
253+ ratelimit::layer,
254+ ))
255+ .layer(axum::middleware::from_fn_with_state(
256+ state.clone(),
257+ middleware::session_layer,
258+ ))
259+ .layer(axum::middleware::from_fn_with_state(
260+ state.clone(),
261+ middleware::security_layer,
262+ ))
263+ .layer(CatchPanicLayer::new())
264+ .layer(TraceLayer::new_for_http())
265+ .fallback(not_found)
266+ .with_state(state)
267+}
268+
269+/// Assets are embedded in the binary so the runtime image needs no asset
270+/// volume and cannot serve a file the build did not produce.
271+async fn serve_css() -> impl axum::response::IntoResponse {
272+ (
273+ [
274+ (axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8"),
275+ (
276+ axum::http::header::CACHE_CONTROL,
277+ "public, max-age=3600",
278+ ),
279+ ],
280+ include_str!("../assets/app.css"),
281+ )
282+}
283+
284+/// The CodeMirror bundle, served only to the edit page.
285+///
286+/// Large (a few hundred KB gzipped) and therefore deliberately not referenced
287+/// from any other page. Cached hard: the content changes only when the image
288+/// does.
289+async fn serve_editor_js() -> impl axum::response::IntoResponse {
290+ (
291+ [
292+ (
293+ axum::http::header::CONTENT_TYPE,
294+ "application/javascript; charset=utf-8",
295+ ),
296+ (
297+ axum::http::header::CACHE_CONTROL,
298+ "public, max-age=604800, immutable",
299+ ),
300+ ],
301+ include_str!("../assets/editor.min.js"),
302+ )
303+}
304+
305+/// Web fonts, embedded like every other asset.
306+///
307+/// One route with a match rather than eight routes: the table *is* the
308+/// allowlist, so a request for anything the build did not embed is a 404 and
309+/// never touches the filesystem.
310+///
311+/// All eight are Latin subsets. The full families run 90KB+ per weight, which
312+/// is not a reasonable thing to put in front of every page of a code forge.
313+async fn serve_font(
314+ axum::extract::Path(name): axum::extract::Path<String>,
315+) -> axum::response::Response {
316+ use axum::response::IntoResponse;
317+
318+ const FONTS: &[(&str, &[u8])] = &[
319+ (
320+ "plex-condensed-500.woff2",
321+ include_bytes!("../assets/fonts/plex-condensed-500.woff2"),
322+ ),
323+ (
324+ "plex-condensed-600.woff2",
325+ include_bytes!("../assets/fonts/plex-condensed-600.woff2"),
326+ ),
327+ (
328+ "plex-condensed-700.woff2",
329+ include_bytes!("../assets/fonts/plex-condensed-700.woff2"),
330+ ),
331+ (
332+ "plex-sans-400.woff2",
333+ include_bytes!("../assets/fonts/plex-sans-400.woff2"),
334+ ),
335+ (
336+ "plex-sans-500.woff2",
337+ include_bytes!("../assets/fonts/plex-sans-500.woff2"),
338+ ),
339+ (
340+ "plex-sans-600.woff2",
341+ include_bytes!("../assets/fonts/plex-sans-600.woff2"),
342+ ),
343+ (
344+ "jetbrains-mono-400.woff2",
345+ include_bytes!("../assets/fonts/jetbrains-mono-400.woff2"),
346+ ),
347+ (
348+ "jetbrains-mono-500.woff2",
349+ include_bytes!("../assets/fonts/jetbrains-mono-500.woff2"),
350+ ),
351+ ];
352+
353+ let Some((_, bytes)) = FONTS.iter().find(|(n, _)| *n == name) else {
354+ return error::AppError::NotFound.into_response();
355+ };
356+
357+ (
358+ [
359+ (axum::http::header::CONTENT_TYPE, "font/woff2"),
360+ // A font file never changes under a given deploy — the name is
361+ // fixed and the bytes come from the image — so it can be cached as
362+ // hard as the editor bundle.
363+ (
364+ axum::http::header::CACHE_CONTROL,
365+ "public, max-age=31536000, immutable",
366+ ),
367+ ],
368+ *bytes,
369+ )
370+ .into_response()
371+}
372+
373+async fn serve_htmx() -> impl axum::response::IntoResponse {
374+ (
375+ [
376+ (
377+ axum::http::header::CONTENT_TYPE,
378+ "application/javascript; charset=utf-8",
379+ ),
380+ (
381+ axum::http::header::CACHE_CONTROL,
382+ "public, max-age=86400",
383+ ),
384+ ],
385+ include_str!("../assets/htmx.min.js"),
386+ )
387+}
388+
389+/// The theme flash-guard. Loaded blocking, in `<head>`, so a stored "light"
390+/// preference is applied before the default dark theme paints.
391+async fn serve_theme_init_js() -> impl axum::response::IntoResponse {
392+ (
393+ [
394+ (
395+ axum::http::header::CONTENT_TYPE,
396+ "application/javascript; charset=utf-8",
397+ ),
398+ (axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
399+ ],
400+ include_str!("../assets/theme-init.js"),
401+ )
402+}
403+
404+/// The theme toggle button's behaviour. Deferred, like htmx — the button is
405+/// enhancement only.
406+async fn serve_theme_js() -> impl axum::response::IntoResponse {
407+ (
408+ [
409+ (
410+ axum::http::header::CONTENT_TYPE,
411+ "application/javascript; charset=utf-8",
412+ ),
413+ (axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
414+ ],
415+ include_str!("../assets/theme.js"),
416+ )
417+}
418+
419+async fn not_found() -> error::AppError {
420+ error::AppError::NotFound
421+}
422+
423+fn init_tracing() {
424+ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
425+
426+ let filter = EnvFilter::try_from_default_env()
427+ .unwrap_or_else(|_| EnvFilter::new("info,df_web=debug"));
428+
429+ // Structured JSON to stdout (spec §10). Falls back to a human-readable
430+ // format when a TTY is attached, which is what you want when running it by
431+ // hand.
432+ let json = !std::io::IsTerminal::is_terminal(&std::io::stdout());
433+
434+ let registry = tracing_subscriber::registry().with(filter);
435+ if json {
436+ registry.with(tracing_subscriber::fmt::layer().json()).init();
437+ } else {
438+ registry.with(tracing_subscriber::fmt::layer()).init();
439+ }
440+}
441+
442+async fn shutdown_signal() {
443+ let ctrl_c = async {
444+ tokio::signal::ctrl_c()
445+ .await
446+ .expect("installing Ctrl+C handler");
447+ };
448+
449+ #[cfg(unix)]
450+ let terminate = async {
451+ tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
452+ .expect("installing SIGTERM handler")
453+ .recv()
454+ .await;
455+ };
456+
457+ #[cfg(not(unix))]
458+ let terminate = std::future::pending::<()>();
459+
460+ tokio::select! {
461+ _ = ctrl_c => tracing::info!("received SIGINT, shutting down"),
462+ _ = terminate => tracing::info!("received SIGTERM, shutting down"),
463+ }
464+}
Acrates/df-web/src/middleware.rs+259−0
@@ −0,0 +1,259 @@
1+//! Request middleware: session resolution, CSRF, and security headers.
2+
3+use std::sync::Arc;
4+
5+use axum::extract::{Request, State};
6+use axum::http::{header, HeaderValue, Method, StatusCode};
7+use axum::middleware::Next;
8+use axum::response::{IntoResponse, Response};
9+use axum_extra::extract::cookie::{Cookie, SameSite};
10+use df_auth::{csrf, session};
11+use rand::RngCore;
12+use uuid::Uuid;
13+
14+use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
15+
16+/// Resolve the session cookie into a user, once per request.
17+///
18+/// Handlers never read the cookie themselves — spec §6: "Resolve permissions
19+/// once per request in middleware … and pass it down."
20+pub async fn session_layer(
21+ State(state): State<AppState>,
22+ mut req: Request,
23+ next: Next,
24+) -> Response {
25+ let jar = axum_extra::extract::cookie::CookieJar::from_headers(req.headers());
26+
27+ let mut current = CurrentUser(None);
28+
29+ if let Some(raw) = jar.get(session::COOKIE_NAME).map(|c| c.value().to_owned()) {
30+ if let Ok(id) = raw.parse::<Uuid>() {
31+ match session::load(&state.db, id).await {
32+ Ok(Some((sess, user))) => {
33+ // Slide the expiry only past the halfway point, to avoid a
34+ // database write on every page view.
35+ if session::should_slide(sess.expires_at, state.config.session_ttl_days) {
36+ if let Err(e) =
37+ session::slide(&state.db, sess.id, state.config.session_ttl_days).await
38+ {
39+ tracing::warn!("sliding session failed: {e}");
40+ }
41+ }
42+ current = CurrentUser(Some(Arc::new(user)));
43+ }
44+ Ok(None) => { /* expired or unknown: treated as signed out */ }
45+ Err(e) => tracing::error!("loading session failed: {e}"),
46+ }
47+ }
48+ }
49+
50+ req.extensions_mut().insert(current);
51+ next.run(req).await
52+}
53+
54+/// Issue a CSP nonce and a CSRF token, enforce CSRF on unsafe methods, and set
55+/// the security headers from spec §9.
56+pub async fn security_layer(
57+ State(state): State<AppState>,
58+ mut req: Request,
59+ next: Next,
60+) -> Response {
61+ let secret = state.config.session_secret.clone();
62+
63+ let jar = axum_extra::extract::cookie::CookieJar::from_headers(req.headers());
64+ let existing = jar.get(csrf::COOKIE_NAME).map(|c| c.value().to_owned());
65+
66+ // Reuse a valid token so a page open in two tabs does not invalidate itself.
67+ let token = match &existing {
68+ Some(t) if csrf::is_valid(&secret, t) => t.clone(),
69+ _ => csrf::issue(&secret),
70+ };
71+ let issued_new = existing.as_deref() != Some(token.as_str());
72+
73+ // Enforce on every state-changing method, except the Git RPC endpoints.
74+ //
75+ // Git clients cannot carry a CSRF token, and these endpoints are exempt
76+ // *safely* because they never authenticate from the session cookie —
77+ // `git_http::auth` reads only the `Authorization` header. CSRF protects
78+ // cookie-authenticated state changes; a cross-origin form cannot set an
79+ // Authorization header, so there is nothing here for it to ride on.
80+ //
81+ // This exemption is only sound while that remains true. If a Git endpoint
82+ // ever starts honouring the session cookie, it must be re-included here.
83+ let git_rpc = is_git_rpc(req.uri().path());
84+
85+ if !git_rpc && !matches!(req.method(), &Method::GET | &Method::HEAD | &Method::OPTIONS) {
86+ let submitted = req
87+ .headers()
88+ .get(csrf::HEADER_NAME)
89+ .and_then(|v| v.to_str().ok())
90+ .map(str::to_owned);
91+
92+ // Forms submit the token in a hidden field. Reading it requires
93+ // buffering the body, so we do that only when the header is absent —
94+ // which is the no-JavaScript path.
95+ let (submitted, req_rebuilt) = match submitted {
96+ Some(s) => (Some(s), req),
97+ None => extract_csrf_from_form(req).await,
98+ };
99+ req = req_rebuilt;
100+
101+ if !csrf::verify(&secret, existing.as_deref(), submitted.as_deref()) {
102+ tracing::warn!(
103+ method = %req.method(),
104+ path = %req.uri().path(),
105+ "CSRF validation failed"
106+ );
107+ return (StatusCode::FORBIDDEN, "CSRF validation failed").into_response();
108+ }
109+ }
110+
111+ let nonce = {
112+ let mut b = [0u8; 16];
113+ rand::thread_rng().fill_bytes(&mut b);
114+ hex::encode(b)
115+ };
116+
117+ req.extensions_mut().insert(CsrfToken(token.clone()));
118+ req.extensions_mut().insert(Nonce(nonce.clone()));
119+
120+ let mut res = next.run(req).await;
121+ let headers = res.headers_mut();
122+
123+ // Strict CSP with no `unsafe-inline` (spec §9). htmx works fine with nonces.
124+ headers.insert(
125+ header::CONTENT_SECURITY_POLICY,
126+ HeaderValue::from_str(&format!(
127+ "default-src 'self'; \
128+ script-src 'self' 'nonce-{nonce}'; \
129+ style-src 'self'; \
130+ img-src 'self' data:; \
131+ font-src 'self'; \
132+ connect-src 'self'; \
133+ form-action 'self'; \
134+ frame-ancestors 'none'; \
135+ base-uri 'none'; \
136+ object-src 'none'"
137+ ))
138+ .unwrap_or_else(|_| HeaderValue::from_static("default-src 'self'")),
139+ );
140+ headers.insert(
141+ header::X_CONTENT_TYPE_OPTIONS,
142+ HeaderValue::from_static("nosniff"),
143+ );
144+ headers.insert(
145+ header::REFERRER_POLICY,
146+ HeaderValue::from_static("same-origin"),
147+ );
148+ // Redundant with `frame-ancestors 'none'` above, but kept for clients that
149+ // predate CSP framing directives.
150+ headers.insert(
151+ header::X_FRAME_OPTIONS,
152+ HeaderValue::from_static("DENY"),
153+ );
154+
155+ if issued_new {
156+ // Not HttpOnly: the double-submit pattern requires the page to read it
157+ // in order to echo it back. Its secrecy is not what makes it work — the
158+ // HMAC and the same-origin requirement are.
159+ let cookie = Cookie::build((csrf::COOKIE_NAME, token))
160+ .path("/")
161+ .secure(state.config.secure_cookies())
162+ .http_only(false)
163+ .same_site(SameSite::Lax)
164+ .build();
165+ if let Ok(v) = HeaderValue::from_str(&cookie.to_string()) {
166+ res.headers_mut().append(header::SET_COOKIE, v);
167+ }
168+ }
169+
170+ res
171+}
172+
173+/// Pull `_csrf` out of a urlencoded body, returning the request with its body
174+/// intact so the handler can still read it.
175+async fn extract_csrf_from_form(req: Request) -> (Option<String>, Request) {
176+ let is_form = req
177+ .headers()
178+ .get(header::CONTENT_TYPE)
179+ .and_then(|v| v.to_str().ok())
180+ .is_some_and(|v| v.starts_with("application/x-www-form-urlencoded"));
181+
182+ if !is_form {
183+ return (None, req);
184+ }
185+
186+ let (parts, body) = req.into_parts();
187+ // Bounded: a form body large enough to matter is not a form.
188+ let bytes = match axum::body::to_bytes(body, 64 * 1024).await {
189+ Ok(b) => b,
190+ Err(_) => {
191+ return (None, Request::from_parts(parts, axum::body::Body::empty()));
192+ }
193+ };
194+
195+ let token = form_urlencoded::parse(&bytes)
196+ .find(|(k, _)| k == csrf::FORM_FIELD)
197+ .map(|(_, v)| v.into_owned());
198+
199+ (
200+ token,
201+ Request::from_parts(parts, axum::body::Body::from(bytes)),
202+ )
203+}
204+
205+/// Whether a path is one of the Git smart-HTTP RPC endpoints.
206+///
207+/// Matched on the final segment only, so a repository named
208+/// `git-receive-pack` cannot smuggle an exemption for its own pages.
209+fn is_git_rpc(path: &str) -> bool {
210+ let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
211+ // Exactly `/{owner}/{repo}/{rpc}` — the shape the routes declare. Matching
212+ // on the last segment alone would also exempt `/git-receive-pack`, which is
213+ // an owner page, not an RPC endpoint.
214+ segments.len() == 3
215+ && matches!(segments[2], "git-upload-pack" | "git-receive-pack")
216+}
217+
218+#[cfg(test)]
219+mod tests {
220+ use super::is_git_rpc;
221+
222+ #[test]
223+ fn git_rpc_endpoints_are_recognised() {
224+ assert!(is_git_rpc("/owner/repo.git/git-receive-pack"));
225+ assert!(is_git_rpc("/owner/repo.git/git-upload-pack"));
226+ assert!(is_git_rpc("/o/r/git-receive-pack"));
227+ }
228+
229+ #[test]
230+ fn ordinary_paths_are_not_exempt() {
231+ for p in [
232+ "/",
233+ "/repos",
234+ "/owner/repo",
235+ "/owner/repo/settings",
236+ "/logout",
237+ "/auth/handle",
238+ ] {
239+ assert!(!is_git_rpc(p), "{p} must not be CSRF-exempt");
240+ }
241+ }
242+
243+ #[test]
244+ fn a_repository_named_like_the_rpc_cannot_exempt_its_own_pages() {
245+ // /owner/git-receive-pack is a repository page and must stay protected;
246+ // only the trailing RPC segment counts.
247+ assert!(!is_git_rpc("/owner/git-receive-pack/settings"));
248+ assert!(!is_git_rpc("/git-receive-pack"), "top-level owner page");
249+ }
250+
251+ #[test]
252+ fn csp_forbids_unsafe_inline() {
253+ // Guard against someone loosening the policy to make an inline script
254+ // work; the nonce is the supported mechanism.
255+ let policy = "default-src 'self'; script-src 'self' 'nonce-abc'; style-src 'self'";
256+ assert!(!policy.contains("unsafe-inline"));
257+ assert!(!policy.contains("unsafe-eval"));
258+ }
259+}
Acrates/df-web/src/ratelimit.rs+397−0
@@ −0,0 +1,397 @@
1+//! Rate limiting and per-user concurrency caps (spec §9, M6).
2+//!
3+//! > Cap: pack size on receive […] and **request concurrency per user**.
4+//!
5+//! Two different protections, because they stop two different things:
6+//!
7+//! * A **token bucket** bounds the *rate* of requests. It is what stops a script
8+//! walking every change in a repository, or grinding at the login endpoint.
9+//! * A **concurrency cap** bounds how many requests one identity may have in
10+//! flight. A rate limit alone does not stop ten simultaneous diffs of the
11+//! Linux kernel, and those are the requests that actually consume the box.
12+//!
13+//! Both are keyed by user id when there is one and by IP otherwise. Keying
14+//! authenticated traffic by user rather than IP matters in both directions: a
15+//! team behind one NAT is not one attacker, and one attacker on a hundred
16+//! addresses is still one account.
17+//!
18+//! In-memory, deliberately. A shared limiter would mean Redis, and this is a
19+//! single-instance product (spec §1). The state is small, bounded, and reset by
20+//! a restart — which is the correct behaviour for a limiter whose only job is to
21+//! keep one process healthy.
22+
23+use std::collections::HashMap;
24+use std::net::IpAddr;
25+use std::sync::{Arc, Mutex};
26+use std::time::{Duration, Instant};
27+
28+use axum::extract::{ConnectInfo, Request, State};
29+use axum::http::StatusCode;
30+use axum::middleware::Next;
31+use axum::response::{IntoResponse, Response};
32+
33+use crate::state::{AppState, CurrentUser};
34+
35+/// Sustained requests per second, per identity.
36+const REFILL_PER_SEC: f64 = 8.0;
37+
38+/// Burst above the sustained rate. A page load is one document plus its assets,
39+/// and a reviewer clicking through a stack fires several in a second.
40+const BURST: f64 = 40.0;
41+
42+/// The much tighter bucket for endpoints that are worth grinding at: the login
43+/// redirect, the OIDC callback, and the setup-token claim.
44+const AUTH_REFILL_PER_SEC: f64 = 0.5;
45+const AUTH_BURST: f64 = 10.0;
46+
47+/// Simultaneous in-flight requests per identity.
48+///
49+/// A diff or a highlight can occupy a thread for a while; this is what stops one
50+/// identity holding all of them.
51+const MAX_CONCURRENT: u32 = 12;
52+
53+/// Entries idle longer than this are dropped, so the map does not grow with
54+/// every address that has ever connected.
55+const IDLE_EVICT: Duration = Duration::from_secs(600);
56+
57+/// The bucket every request without a resolvable peer address shares.
58+///
59+/// `UNSPECIFIED` is not a routable address, so it cannot collide with a real
60+/// client, and sharing one bucket is the conservative choice: unattributable
61+/// traffic is limited together rather than not at all.
62+const UNKNOWN_PEER: IpAddr = IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED);
63+
64+/// How many identities to track before evicting aggressively. Reached only
65+/// under a distributed flood, where the correct behaviour is to keep working
66+/// rather than to allocate.
67+const MAX_TRACKED: usize = 50_000;
68+
69+#[derive(Clone)]
70+pub struct Limiter(Arc<Mutex<Inner>>);
71+
72+struct Inner {
73+ buckets: HashMap<Key, Bucket>,
74+ last_sweep: Instant,
75+}
76+
77+#[derive(Clone, PartialEq, Eq, Hash, Debug)]
78+enum Key {
79+ User(uuid::Uuid),
80+ Addr(IpAddr),
81+}
82+
83+struct Bucket {
84+ tokens: f64,
85+ auth_tokens: f64,
86+ in_flight: u32,
87+ last: Instant,
88+}
89+
90+impl Default for Limiter {
91+ fn default() -> Self {
92+ Self::new()
93+ }
94+}
95+
96+impl Limiter {
97+ pub fn new() -> Self {
98+ Limiter(Arc::new(Mutex::new(Inner {
99+ buckets: HashMap::new(),
100+ last_sweep: Instant::now(),
101+ })))
102+ }
103+
104+ /// Take one token, and a concurrency slot.
105+ ///
106+ /// Returns `None` when the identity is over a limit. The returned guard
107+ /// releases the concurrency slot when dropped — including when the handler
108+ /// panics, which is why it is a guard and not a pair of calls.
109+ fn acquire(&self, key: Key, auth: bool, now: Instant) -> Option<Guard> {
110+ let mut inner = self.0.lock().expect("rate limiter poisoned");
111+ inner.sweep(now);
112+
113+ // A flood of distinct keys must not be able to grow the map without
114+ // bound. Past the cap, unknown keys are refused rather than admitted —
115+ // the alternative is admitting everything precisely when under attack.
116+ if inner.buckets.len() >= MAX_TRACKED && !inner.buckets.contains_key(&key) {
117+ return None;
118+ }
119+
120+ let bucket = inner.buckets.entry(key.clone()).or_insert(Bucket {
121+ tokens: BURST,
122+ auth_tokens: AUTH_BURST,
123+ in_flight: 0,
124+ last: now,
125+ });
126+
127+ // Refill for the time that passed. `saturating_duration_since` because a
128+ // clock that went backwards must not mint tokens.
129+ let elapsed = now.saturating_duration_since(bucket.last).as_secs_f64();
130+ bucket.tokens = (bucket.tokens + elapsed * REFILL_PER_SEC).min(BURST);
131+ bucket.auth_tokens = (bucket.auth_tokens + elapsed * AUTH_REFILL_PER_SEC).min(AUTH_BURST);
132+ bucket.last = now;
133+
134+ if bucket.in_flight >= MAX_CONCURRENT {
135+ return None;
136+ }
137+ if bucket.tokens < 1.0 {
138+ return None;
139+ }
140+ if auth && bucket.auth_tokens < 1.0 {
141+ return None;
142+ }
143+
144+ bucket.tokens -= 1.0;
145+ if auth {
146+ bucket.auth_tokens -= 1.0;
147+ }
148+ bucket.in_flight += 1;
149+
150+ Some(Guard { limiter: self.clone(), key })
151+ }
152+
153+ fn release(&self, key: &Key) {
154+ if let Ok(mut inner) = self.0.lock() {
155+ if let Some(b) = inner.buckets.get_mut(key) {
156+ b.in_flight = b.in_flight.saturating_sub(1);
157+ }
158+ }
159+ }
160+
161+ #[cfg(test)]
162+ fn tracked(&self) -> usize {
163+ self.0.lock().unwrap().buckets.len()
164+ }
165+}
166+
167+impl Inner {
168+ fn sweep(&mut self, now: Instant) {
169+ if now.saturating_duration_since(self.last_sweep) < Duration::from_secs(60) {
170+ return;
171+ }
172+ self.last_sweep = now;
173+ // An entry with a request in flight is never evicted, however idle its
174+ // bucket looks — dropping it would lose the concurrency count and let
175+ // the cap be bypassed by a slow request.
176+ self.buckets.retain(|_, b| {
177+ b.in_flight > 0 || now.saturating_duration_since(b.last) < IDLE_EVICT
178+ });
179+ }
180+}
181+
182+/// Holds a concurrency slot for the life of a request.
183+pub struct Guard {
184+ limiter: Limiter,
185+ key: Key,
186+}
187+
188+impl Drop for Guard {
189+ fn drop(&mut self) {
190+ self.limiter.release(&self.key);
191+ }
192+}
193+
194+/// Whether a path gets the strict auth bucket.
195+fn is_auth_path(path: &str) -> bool {
196+ matches!(path, "/login" | "/auth/callback" | "/auth/handle" | "/setup")
197+}
198+
199+/// Whether a path is exempt.
200+///
201+/// Health checks come from the orchestrator on a fixed interval and must never
202+/// be throttled — a rate-limited `/healthz` restarts the container. Static
203+/// assets are served from memory and are not worth a bucket.
204+fn is_exempt(path: &str) -> bool {
205+ matches!(path, "/healthz" | "/readyz") || path.starts_with("/assets/")
206+}
207+
208+/// The rate-limiting middleware.
209+///
210+/// `ConnectInfo` is optional so a missing peer address cannot turn every
211+/// request into a 500. It is always present in production — `main` serves with
212+/// `into_make_service_with_connect_info` — and its absence falls back to a
213+/// single shared bucket, which is stricter than per-address, not looser.
214+pub async fn layer(State(state): State<AppState>, req: Request, next: Next) -> Response {
215+ let addr = req
216+ .extensions()
217+ .get::<ConnectInfo<std::net::SocketAddr>>()
218+ .map(|c| c.0);
219+ let path = req.uri().path().to_owned();
220+ if is_exempt(&path) {
221+ return next.run(req).await;
222+ }
223+
224+ // The session layer runs before this one, so an authenticated request is
225+ // already resolved and gets its own bucket rather than sharing its
226+ // neighbours' address.
227+ let key = match req.extensions().get::<CurrentUser>().and_then(|u| u.0.as_ref()) {
228+ Some(user) => Key::User(user.id),
229+ None => Key::Addr(addr.map(|a| a.ip()).unwrap_or(UNKNOWN_PEER)),
230+ };
231+
232+ let Some(_guard) = state.limiter.acquire(key, is_auth_path(&path), Instant::now()) else {
233+ tracing::warn!(%path, peer = ?addr, "rate limited");
234+ return (
235+ StatusCode::TOO_MANY_REQUESTS,
236+ [(axum::http::header::RETRY_AFTER, "5")],
237+ "Too many requests. Try again in a moment.",
238+ )
239+ .into_response();
240+ };
241+
242+ next.run(req).await
243+}
244+
245+#[cfg(test)]
246+mod tests {
247+ use super::*;
248+
249+ fn key() -> Key {
250+ Key::Addr("10.0.0.1".parse().unwrap())
251+ }
252+
253+ #[test]
254+ fn a_burst_is_allowed_then_refused() {
255+ let l = Limiter::new();
256+ let now = Instant::now();
257+
258+ // Guards are held, so this also exercises the concurrency cap — which
259+ // bites first, and should.
260+ let mut held = Vec::new();
261+ for _ in 0..MAX_CONCURRENT {
262+ held.push(l.acquire(key(), false, now).expect("under the cap"));
263+ }
264+ assert!(
265+ l.acquire(key(), false, now).is_none(),
266+ "the concurrency cap must refuse the next request"
267+ );
268+ }
269+
270+ #[test]
271+ fn a_released_slot_is_reusable() {
272+ let l = Limiter::new();
273+ let now = Instant::now();
274+ {
275+ let _g = l.acquire(key(), false, now).unwrap();
276+ }
277+ assert!(l.acquire(key(), false, now).is_some(), "dropping a guard frees the slot");
278+ }
279+
280+ #[test]
281+ fn the_rate_limit_bites_once_the_burst_is_spent() {
282+ let l = Limiter::new();
283+ let now = Instant::now();
284+
285+ // Drop each guard immediately so only the token bucket is in play.
286+ for i in 0..BURST as usize {
287+ assert!(l.acquire(key(), false, now).is_some(), "request {i} of the burst");
288+ }
289+ assert!(l.acquire(key(), false, now).is_none(), "the burst is spent");
290+ }
291+
292+ #[test]
293+ fn tokens_refill_over_time() {
294+ let l = Limiter::new();
295+ let start = Instant::now();
296+ for _ in 0..BURST as usize {
297+ let _ = l.acquire(key(), false, start);
298+ }
299+ assert!(l.acquire(key(), false, start).is_none());
300+
301+ let later = start + Duration::from_secs(2);
302+ assert!(
303+ l.acquire(key(), false, later).is_some(),
304+ "two seconds should refill {REFILL_PER_SEC} tokens per second"
305+ );
306+ }
307+
308+ /// The login endpoint is worth grinding at, so it gets its own much smaller
309+ /// bucket — and spending it must not spend the ordinary one.
310+ #[test]
311+ fn the_auth_bucket_is_separate_and_tighter() {
312+ let l = Limiter::new();
313+ let now = Instant::now();
314+
315+ for _ in 0..AUTH_BURST as usize {
316+ assert!(l.acquire(key(), true, now).is_some());
317+ }
318+ assert!(l.acquire(key(), true, now).is_none(), "the auth bucket is spent");
319+ assert!(
320+ l.acquire(key(), false, now).is_some(),
321+ "ordinary requests must still be served"
322+ );
323+ }
324+
325+ #[test]
326+ fn identities_do_not_share_a_bucket() {
327+ let l = Limiter::new();
328+ let now = Instant::now();
329+ let other = Key::Addr("10.0.0.2".parse().unwrap());
330+
331+ for _ in 0..BURST as usize {
332+ let _ = l.acquire(key(), false, now);
333+ }
334+ assert!(l.acquire(key(), false, now).is_none());
335+ assert!(
336+ l.acquire(other, false, now).is_some(),
337+ "one address must not exhaust another's budget"
338+ );
339+ }
340+
341+ #[test]
342+ fn a_user_key_is_distinct_from_an_address_key() {
343+ let l = Limiter::new();
344+ let now = Instant::now();
345+ let user = Key::User(uuid::Uuid::from_u128(1));
346+
347+ for _ in 0..BURST as usize {
348+ let _ = l.acquire(key(), false, now);
349+ }
350+ assert!(l.acquire(user, false, now).is_some());
351+ }
352+
353+ #[test]
354+ fn idle_entries_are_evicted() {
355+ let l = Limiter::new();
356+ let start = Instant::now();
357+ let _ = l.acquire(key(), false, start);
358+ assert_eq!(l.tracked(), 1);
359+
360+ // Past the idle window, and past the sweep interval.
361+ let later = start + IDLE_EVICT + Duration::from_secs(1);
362+ let _ = l.acquire(Key::Addr("10.0.0.9".parse().unwrap()), false, later);
363+ assert_eq!(l.tracked(), 1, "the idle entry should have been swept");
364+ }
365+
366+ #[test]
367+ fn a_backwards_clock_does_not_mint_tokens() {
368+ let l = Limiter::new();
369+ let now = Instant::now();
370+ for _ in 0..BURST as usize {
371+ let _ = l.acquire(key(), false, now);
372+ }
373+ // An earlier instant must not refill the bucket.
374+ let earlier = now.checked_sub(Duration::from_secs(60)).unwrap_or(now);
375+ assert!(l.acquire(key(), false, earlier).is_none());
376+ }
377+
378+ #[test]
379+ fn health_checks_and_assets_are_exempt() {
380+ assert!(is_exempt("/healthz"));
381+ assert!(is_exempt("/readyz"));
382+ assert!(is_exempt("/assets/app.css"));
383+ assert!(!is_exempt("/"), "ordinary pages are limited");
384+ assert!(!is_exempt("/alice/repo/git-upload-pack"));
385+ }
386+
387+ #[test]
388+ fn auth_paths_are_recognised() {
389+ for p in ["/login", "/auth/callback", "/auth/handle", "/setup"] {
390+ assert!(is_auth_path(p), "{p}");
391+ }
392+ assert!(!is_auth_path("/"), "the dashboard is not an auth endpoint");
393+ // Not prefix-matched: a repository called `login` is a page, not an
394+ // auth endpoint, and must not inherit the tighter bucket.
395+ assert!(!is_auth_path("/login/something"));
396+ }
397+}
Acrates/df-web/src/repo_ctx.rs+166−0
@@ −0,0 +1,166 @@
1+//! Repository resolution and authorization.
2+//!
3+//! Spec §6: "Resolve permissions once per request in middleware into a
4+//! `RepoContext` and pass it down. Do not scatter permission checks through
5+//! handlers — one function, one call site per request, and a default-deny
6+//! fallthrough."
7+//!
8+//! [`RepoContext::load`] is that one call site. Every repository handler starts
9+//! by calling it and gets back either a context that has *already* been
10+//! authorized for read, or a 404.
11+//!
12+//! The 404 is deliberate and load-bearing. Spec §9: "Return an identical 404
13+//! for a private repo and a nonexistent repo." A 403 would confirm the
14+//! repository exists, which is the leak.
15+
16+use df_auth::permissions::{self, AccessInputs, RepoAccess, Viewer};
17+use df_db::models::{OrgRole, OwnerKind, Repo, RepoRole, User, Visibility};
18+use df_store::RepoId;
19+use uuid::Uuid;
20+
21+use crate::error::{AppError, AppResult};
22+use crate::state::AppState;
23+
24+pub struct RepoContext {
25+ pub repo: Repo,
26+ /// Display handle of the owning user or org.
27+ pub owner: String,
28+ pub access: RepoAccess,
29+}
30+
31+impl RepoContext {
32+ /// Resolve `{owner}/{repo}` and authorize the viewer for read.
33+ ///
34+ /// Returns [`AppError::NotFound`] when the repository does not exist *or*
35+ /// the viewer may not see it — the two cases are indistinguishable to the
36+ /// client by design.
37+ pub async fn load(
38+ state: &AppState,
39+ owner: &str,
40+ name: &str,
41+ viewer: Option<&User>,
42+ ) -> AppResult<RepoContext> {
43+ // One query loads the repo, its owner handle, and the viewer's
44+ // collaborator and org roles. Doing it in one statement keeps the
45+ // authorization decision atomic and avoids a check-then-use gap.
46+ let viewer_id = viewer.map(|u| u.id);
47+
48+ let row: Option<RepoRow> = sqlx::query_as::<_, RepoRow>(
49+ r#"
50+ SELECT r.id, r.owner_kind, r.owner_user_id, r.owner_org_id, r.name::text AS name,
51+ r.description, r.visibility, r.default_bookmark, r.fork_of_repo_id,
52+ r.size_bytes, r.pushed_at, r.archived, r.created_at,
53+ COALESCE(ou.handle, og.handle)::text AS owner_handle,
54+ c.role AS collaborator_role,
55+ m.role AS org_role
56+ FROM repos r
57+ LEFT JOIN users ou ON ou.id = r.owner_user_id
58+ LEFT JOIN orgs og ON og.id = r.owner_org_id
59+ LEFT JOIN repo_collaborators c
60+ ON c.repo_id = r.id AND c.user_id = $3
61+ LEFT JOIN org_members m
62+ ON m.org_id = r.owner_org_id AND m.user_id = $3
63+ WHERE COALESCE(ou.handle, og.handle) = $1 AND r.name = $2
64+ "#,
65+ )
66+ .bind(owner)
67+ .bind(name)
68+ .bind(viewer_id)
69+ .fetch_optional(&state.db)
70+ .await?;
71+
72+ // Nonexistent repository: 404.
73+ let Some(row) = row else {
74+ return Err(AppError::NotFound);
75+ };
76+
77+ let access = permissions::resolve(AccessInputs {
78+ visibility: row.visibility,
79+ viewer: viewer.map(|u| Viewer {
80+ user_id: u.id,
81+ is_site_admin: u.is_admin,
82+ }),
83+ owner_user_id: row.owner_user_id,
84+ collaborator_role: row.collaborator_role,
85+ org_role: row.org_role,
86+ });
87+
88+ // Exists but not visible: the *same* 404, so the response does not
89+ // distinguish the two cases.
90+ if !access.can_read() {
91+ tracing::debug!(
92+ repo = %row.id,
93+ viewer = ?viewer_id,
94+ "denying repository read; responding 404"
95+ );
96+ return Err(AppError::NotFound);
97+ }
98+
99+ Ok(RepoContext {
100+ owner: row.owner_handle,
101+ access,
102+ repo: Repo {
103+ id: row.id,
104+ owner_kind: row.owner_kind,
105+ owner_user_id: row.owner_user_id,
106+ owner_org_id: row.owner_org_id,
107+ name: row.name,
108+ description: row.description,
109+ visibility: row.visibility,
110+ default_bookmark: row.default_bookmark,
111+ fork_of_repo_id: row.fork_of_repo_id,
112+ size_bytes: row.size_bytes,
113+ pushed_at: row.pushed_at,
114+ archived: row.archived,
115+ created_at: row.created_at,
116+ },
117+ })
118+ }
119+
120+ pub fn store_id(&self) -> RepoId {
121+ RepoId(self.repo.id)
122+ }
123+
124+ /// URL prefix for this repository.
125+ pub fn base(&self) -> String {
126+ format!("/{}/{}", self.owner, self.repo.name)
127+ }
128+
129+ /// Require a permission, or fail.
130+ ///
131+ /// Used *after* `load` has already authorized read, so a 403 here does not
132+ /// disclose anything the viewer cannot already see.
133+ pub fn require_push(&self) -> AppResult<()> {
134+ self.access
135+ .can_push()
136+ .then_some(())
137+ .ok_or(AppError::Forbidden)
138+ }
139+
140+ pub fn require_settings(&self) -> AppResult<()> {
141+ self.access
142+ .can_change_settings()
143+ .then_some(())
144+ .ok_or(AppError::Forbidden)
145+ }
146+}
147+
148+#[derive(sqlx::FromRow)]
149+struct RepoRow {
150+ id: Uuid,
151+ owner_kind: OwnerKind,
152+ owner_user_id: Option<Uuid>,
153+ owner_org_id: Option<Uuid>,
154+ name: String,
155+ description: Option<String>,
156+ visibility: Visibility,
157+ default_bookmark: String,
158+ fork_of_repo_id: Option<Uuid>,
159+ size_bytes: i64,
160+ pushed_at: Option<chrono::DateTime<chrono::Utc>>,
161+ archived: bool,
162+ created_at: chrono::DateTime<chrono::Utc>,
163+ owner_handle: String,
164+ collaborator_role: Option<RepoRole>,
165+ org_role: Option<OrgRole>,
166+}
Acrates/df-web/src/revset.rs543 lines+543−0
@@ −0,0 +1,543 @@
1+//! A deliberately small revset subset (spec §8).
2+//!
3+//! > Do not attempt to implement jj's full revset language against a Git-backed
4+//! > index; it will not be faithful, and a subtly wrong revset is worse than an
5+//! > honestly limited one. Show a clear "unsupported expression" message rather
6+//! > than silently returning partial results.
7+//!
8+//! Supported: `author()`, `description()`, `conflicted()`, `bookmarks()`,
9+//! `open()`, and the operators `&`, `|`, `~`.
10+//!
11+//! `docs/revset-semantics.md` records the trap this parser is built around: in
12+//! jj, `description("x")` is an **exact** match, not a substring match. The
13+//! obvious `ILIKE '%x%'` translation returns *more* rows than the CLI would, so
14+//! the bare form is rejected and `description(substring:"x")` is required to
15+//! ask for substring matching.
16+
17+#[derive(Debug, Clone, PartialEq, Eq)]
18+pub enum Expr {
19+ Author(String),
20+ /// Exact match on the full description.
21+ DescriptionExact(String),
22+ /// Substring match, only via the explicit `substring:` prefix.
23+ DescriptionSubstring(String),
24+ Conflicted,
25+ Open,
26+ Bookmark(String),
27+ And(Box<Expr>, Box<Expr>),
28+ Or(Box<Expr>, Box<Expr>),
29+ Not(Box<Expr>),
30+}
31+
32+#[derive(Debug, Clone, PartialEq, Eq)]
33+pub struct RevsetError(pub String);
34+
35+impl std::fmt::Display for RevsetError {
36+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37+ f.write_str(&self.0)
38+ }
39+}
40+
41+/// Functions jj has that this subset deliberately does not implement.
42+///
43+/// Named explicitly so the message says *which* function is unsupported rather
44+/// than producing a generic syntax error.
45+const KNOWN_UNSUPPORTED: &[&str] = &[
46+ "heads", "roots", "ancestors", "descendants", "parents", "children",
47+ "latest", "merges", "file", "diff_contains", "empty", "tags", "git_refs",
48+ "visible_heads", "reachable", "connected", "mine", "trunk", "present",
49+];
50+
51+pub fn parse(input: &str) -> Result<Option<Expr>, RevsetError> {
52+ let trimmed = input.trim();
53+ if trimmed.is_empty() {
54+ return Ok(None);
55+ }
56+ if trimmed.len() > 500 {
57+ return Err(RevsetError("Expression is too long.".into()));
58+ }
59+
60+ let mut p = Parser {
61+ s: trimmed.as_bytes(),
62+ i: 0,
63+ };
64+ let e = p.parse_or()?;
65+ p.skip_ws();
66+ if p.i < p.s.len() {
67+ return Err(RevsetError(format!(
68+ "Unexpected input at position {}.",
69+ p.i
70+ )));
71+ }
72+ Ok(Some(e))
73+}
74+
75+struct Parser<'a> {
76+ s: &'a [u8],
77+ i: usize,
78+}
79+
80+impl<'a> Parser<'a> {
81+ fn skip_ws(&mut self) {
82+ while self.i < self.s.len() && self.s[self.i].is_ascii_whitespace() {
83+ self.i += 1;
84+ }
85+ }
86+
87+ fn peek(&mut self) -> Option<u8> {
88+ self.skip_ws();
89+ self.s.get(self.i).copied()
90+ }
91+
92+ fn parse_or(&mut self) -> Result<Expr, RevsetError> {
93+ let mut left = self.parse_and()?;
94+ while self.peek() == Some(b'|') {
95+ self.i += 1;
96+ let right = self.parse_and()?;
97+ left = Expr::Or(Box::new(left), Box::new(right));
98+ }
99+ Ok(left)
100+ }
101+
102+ fn parse_and(&mut self) -> Result<Expr, RevsetError> {
103+ let mut left = self.parse_unary()?;
104+ while self.peek() == Some(b'&') {
105+ self.i += 1;
106+ let right = self.parse_unary()?;
107+ left = Expr::And(Box::new(left), Box::new(right));
108+ }
109+ Ok(left)
110+ }
111+
112+ fn parse_unary(&mut self) -> Result<Expr, RevsetError> {
113+ if self.peek() == Some(b'~') {
114+ self.i += 1;
115+ return Ok(Expr::Not(Box::new(self.parse_unary()?)));
116+ }
117+ self.parse_atom()
118+ }
119+
120+ fn parse_atom(&mut self) -> Result<Expr, RevsetError> {
121+ match self.peek() {
122+ Some(b'(') => {
123+ self.i += 1;
124+ let e = self.parse_or()?;
125+ if self.peek() != Some(b')') {
126+ return Err(RevsetError("Missing closing parenthesis.".into()));
127+ }
128+ self.i += 1;
129+ Ok(e)
130+ }
131+ Some(c) if c.is_ascii_alphabetic() || c == b'_' => self.parse_call(),
132+ Some(c) => Err(RevsetError(format!(
133+ "Unexpected character '{}'.",
134+ c as char
135+ ))),
136+ None => Err(RevsetError("Unexpected end of expression.".into())),
137+ }
138+ }
139+
140+ fn parse_call(&mut self) -> Result<Expr, RevsetError> {
141+ self.skip_ws();
142+ let start = self.i;
143+ while self.i < self.s.len()
144+ && (self.s[self.i].is_ascii_alphanumeric() || self.s[self.i] == b'_')
145+ {
146+ self.i += 1;
147+ }
148+ let name = std::str::from_utf8(&self.s[start..self.i])
149+ .map_err(|_| RevsetError("Invalid characters in function name.".into()))?
150+ .to_string();
151+
152+ if self.peek() != Some(b'(') {
153+ return Err(RevsetError(format!(
154+ "`{name}` is not a value. This subset supports only function calls \
155+ such as author(…), description(…), conflicted(), open(), bookmarks(…)."
156+ )));
157+ }
158+ self.i += 1;
159+
160+ let arg = self.parse_arg()?;
161+
162+ if self.peek() != Some(b')') {
163+ return Err(RevsetError(format!("Missing closing parenthesis after `{name}(`.")));
164+ }
165+ self.i += 1;
166+
167+ build(&name, arg)
168+ }
169+
170+ /// Read a function argument: a quoted string, a `prefix:"value"` form, or a
171+ /// bare word.
172+ fn parse_arg(&mut self) -> Result<String, RevsetError> {
173+ self.skip_ws();
174+ if self.peek() == Some(b')') {
175+ return Ok(String::new());
176+ }
177+
178+ let mut out = String::new();
179+ // Optional `substring:` / `exact:` prefix, kept verbatim for `build`.
180+ let start = self.i;
181+ while self.i < self.s.len()
182+ && (self.s[self.i].is_ascii_alphanumeric() || self.s[self.i] == b'_')
183+ {
184+ self.i += 1;
185+ }
186+ if self.i < self.s.len() && self.s[self.i] == b':' {
187+ out.push_str(
188+ std::str::from_utf8(&self.s[start..self.i])
189+ .map_err(|_| RevsetError("Invalid argument.".into()))?,
190+ );
191+ out.push(':');
192+ self.i += 1;
193+ } else {
194+ self.i = start;
195+ }
196+
197+ self.skip_ws();
198+ match self.s.get(self.i) {
199+ Some(b'"') | Some(b'\'') => {
200+ let quote = self.s[self.i];
201+ self.i += 1;
202+ let vstart = self.i;
203+ while self.i < self.s.len() && self.s[self.i] != quote {
204+ self.i += 1;
205+ }
206+ if self.i >= self.s.len() {
207+ return Err(RevsetError("Unterminated string.".into()));
208+ }
209+ out.push_str(
210+ std::str::from_utf8(&self.s[vstart..self.i])
211+ .map_err(|_| RevsetError("Invalid string.".into()))?,
212+ );
213+ self.i += 1;
214+ }
215+ _ => {
216+ let vstart = self.i;
217+ while self.i < self.s.len()
218+ && self.s[self.i] != b')'
219+ && self.s[self.i] != b'&'
220+ && self.s[self.i] != b'|'
221+ {
222+ self.i += 1;
223+ }
224+ out.push_str(
225+ std::str::from_utf8(&self.s[vstart..self.i])
226+ .map_err(|_| RevsetError("Invalid argument.".into()))?
227+ .trim(),
228+ );
229+ }
230+ }
231+ Ok(out)
232+ }
233+}
234+
235+fn build(name: &str, arg: String) -> Result<Expr, RevsetError> {
236+ match name {
237+ "author" => {
238+ if arg.is_empty() {
239+ return Err(RevsetError("author() needs an argument.".into()));
240+ }
241+ Ok(Expr::Author(strip_prefix(&arg)))
242+ }
243+ "description" | "subject" => {
244+ if let Some(v) = arg.strip_prefix("substring:") {
245+ Ok(Expr::DescriptionSubstring(v.to_string()))
246+ } else if let Some(v) = arg.strip_prefix("exact:") {
247+ Ok(Expr::DescriptionExact(v.to_string()))
248+ } else {
249+ // The trap. jj matches exactly here; a substring translation
250+ // would return more than the CLI does.
251+ Err(RevsetError(format!(
252+ "`{name}(\"…\")` matches the description EXACTLY in jj, which rarely \
253+ does what you want here. Write {name}(substring:\"…\") for a \
254+ substring match, or {name}(exact:\"…\") to be explicit."
255+ )))
256+ }
257+ }
258+ "conflicted" | "conflicts" => Ok(Expr::Conflicted),
259+ "open" => Ok(Expr::Open),
260+ "bookmarks" | "bookmark" | "branches" => Ok(Expr::Bookmark(strip_prefix(&arg))),
261+ other if KNOWN_UNSUPPORTED.contains(&other) => Err(RevsetError(format!(
262+ "`{other}()` needs graph traversal or content search, which this index \
263+ cannot answer faithfully. Supported: author, description, conflicted, \
264+ open, bookmarks."
265+ ))),
266+ other => Err(RevsetError(format!(
267+ "Unknown function `{other}()`. Supported: author, description, \
268+ conflicted, open, bookmarks."
269+ ))),
270+ }
271+}
272+
273+fn strip_prefix(arg: &str) -> String {
274+ arg.strip_prefix("substring:")
275+ .or_else(|| arg.strip_prefix("exact:"))
276+ .unwrap_or(arg)
277+ .to_string()
278+}
279+
280+/// Compile to a SQL fragment plus its bind values.
281+///
282+/// Placeholders start at `next_param`, which the caller advances so the
283+/// fragment can be spliced into a larger query.
284+/// Escape the wildcards in a value destined for `ILIKE`.
285+///
286+/// The value is bound as a parameter, so this is not about SQL injection — it
287+/// is about meaning. `author(%)` would otherwise match every author, and
288+/// `description-substring(a_b)` would match `axb`, neither of which is what the
289+/// user wrote. `\` is the escape character Postgres uses by default.
290+fn escape_like(v: &str) -> String {
291+ let mut out = String::with_capacity(v.len());
292+ for c in v.chars() {
293+ if matches!(c, '%' | '_' | '\\') {
294+ out.push('\\');
295+ }
296+ out.push(c);
297+ }
298+ out
299+}
300+
301+pub fn to_sql(e: &Expr, next_param: &mut usize) -> (String, Vec<String>) {
302+ match e {
303+ Expr::Author(v) => {
304+ // jj matches author name *and* email as a substring.
305+ let a = *next_param;
306+ *next_param += 1;
307+ (
308+ format!(
309+ "EXISTS (SELECT 1 FROM revisions rv WHERE rv.change_id_fk = c.id \
310+ AND (rv.author_name ILIKE '%' || ${a} || '%' \
311+ OR rv.author_email ILIKE '%' || ${a} || '%'))"
312+ ),
313+ vec![escape_like(v)],
314+ )
315+ }
316+ Expr::DescriptionExact(v) => {
317+ let a = *next_param;
318+ *next_param += 1;
319+ (format!("(c.title = ${a} OR c.description = ${a})"), vec![v.clone()])
320+ }
321+ Expr::DescriptionSubstring(v) => {
322+ let a = *next_param;
323+ *next_param += 1;
324+ (
325+ format!("(c.title ILIKE '%' || ${a} || '%' OR c.description ILIKE '%' || ${a} || '%')"),
326+ vec![escape_like(v)],
327+ )
328+ }
329+ Expr::Conflicted => ("c.conflicted".to_string(), vec![]),
330+ Expr::Open => ("c.state = 'open'".to_string(), vec![]),
331+ Expr::Bookmark(v) => {
332+ let a = *next_param;
333+ *next_param += 1;
334+ (format!("c.target_bookmark = ${a}"), vec![v.clone()])
335+ }
336+ Expr::And(l, r) => {
337+ let (ls, mut lv) = to_sql(l, next_param);
338+ let (rs, rv) = to_sql(r, next_param);
339+ lv.extend(rv);
340+ (format!("({ls} AND {rs})"), lv)
341+ }
342+ Expr::Or(l, r) => {
343+ let (ls, mut lv) = to_sql(l, next_param);
344+ let (rs, rv) = to_sql(r, next_param);
345+ lv.extend(rv);
346+ (format!("({ls} OR {rs})"), lv)
347+ }
348+ Expr::Not(i) => {
349+ let (s, v) = to_sql(i, next_param);
350+ (format!("(NOT {s})"), v)
351+ }
352+ }
353+}
354+
355+#[cfg(test)]
356+mod tests {
357+ use super::*;
358+
359+ #[test]
360+ fn empty_input_is_no_filter() {
361+ assert_eq!(parse(""), Ok(None));
362+ assert_eq!(parse(" "), Ok(None));
363+ }
364+
365+ #[test]
366+ fn parses_the_supported_functions() {
367+ assert_eq!(parse("conflicted()").unwrap(), Some(Expr::Conflicted));
368+ assert_eq!(parse("open()").unwrap(), Some(Expr::Open));
369+ assert_eq!(
370+ parse("author(mira)").unwrap(),
371+ Some(Expr::Author("mira".into()))
372+ );
373+ assert_eq!(
374+ parse("author(\"mira\")").unwrap(),
375+ Some(Expr::Author("mira".into()))
376+ );
377+ assert_eq!(
378+ parse("bookmarks(main)").unwrap(),
379+ Some(Expr::Bookmark("main".into()))
380+ );
381+ }
382+
383+ // ─── the description trap (docs/revset-semantics.md) ─────────────────────
384+
385+ #[test]
386+ fn bare_description_is_rejected_rather_than_silently_made_substring() {
387+ // jj's description("x") is an EXACT match. Translating it to ILIKE
388+ // would return more rows than the CLI, which §8 calls worse than an
389+ // honest limitation.
390+ let e = parse("description(\"fix\")").unwrap_err();
391+ assert!(e.0.contains("EXACTLY"), "message must explain: {}", e.0);
392+ assert!(e.0.contains("substring:"), "must suggest the fix: {}", e.0);
393+ }
394+
395+ #[test]
396+ fn explicit_substring_and_exact_forms_are_accepted() {
397+ assert_eq!(
398+ parse("description(substring:\"fix\")").unwrap(),
399+ Some(Expr::DescriptionSubstring("fix".into()))
400+ );
401+ assert_eq!(
402+ parse("description(exact:\"fix\")").unwrap(),
403+ Some(Expr::DescriptionExact("fix".into()))
404+ );
405+ }
406+
407+ // ─── operators ───────────────────────────────────────────────────────────
408+
409+ #[test]
410+ fn parses_and_or_not() {
411+ let e = parse("author(a) & conflicted()").unwrap().unwrap();
412+ assert!(matches!(e, Expr::And(_, _)));
413+
414+ let e = parse("open() | conflicted()").unwrap().unwrap();
415+ assert!(matches!(e, Expr::Or(_, _)));
416+
417+ let e = parse("~conflicted()").unwrap().unwrap();
418+ assert!(matches!(e, Expr::Not(_)));
419+ }
420+
421+ #[test]
422+ fn and_binds_tighter_than_or() {
423+ // a | b & c == a | (b & c)
424+ let e = parse("open() | conflicted() & author(x)").unwrap().unwrap();
425+ match e {
426+ Expr::Or(l, r) => {
427+ assert_eq!(*l, Expr::Open);
428+ assert!(matches!(*r, Expr::And(_, _)));
429+ }
430+ other => panic!("expected Or at the top, got {other:?}"),
431+ }
432+ }
433+
434+ #[test]
435+ fn parentheses_override_precedence() {
436+ let e = parse("(open() | conflicted()) & author(x)").unwrap().unwrap();
437+ assert!(matches!(e, Expr::And(_, _)));
438+ }
439+
440+ // ─── honest rejection (spec §8) ──────────────────────────────────────────
441+
442+ #[test]
443+ fn unsupported_functions_are_named_in_the_error() {
444+ for f in ["heads()", "ancestors(x)", "file(a.rs)", "merges()"] {
445+ let e = parse(f).unwrap_err();
446+ let name = f.split('(').next().unwrap();
447+ assert!(
448+ e.0.contains(name),
449+ "error for {f} must name the function: {}",
450+ e.0
451+ );
452+ }
453+ }
454+
455+ #[test]
456+ fn unknown_functions_are_rejected_not_ignored() {
457+ let e = parse("authr(mira)").unwrap_err();
458+ assert!(e.0.contains("authr"), "got {}", e.0);
459+ }
460+
461+ #[test]
462+ fn malformed_input_produces_an_error_not_a_partial_result() {
463+ for bad in [
464+ "authr(mira) & (",
465+ "open(",
466+ ")",
467+ "&",
468+ "author(",
469+ "open() &",
470+ "\"unterminated",
471+ ] {
472+ assert!(parse(bad).is_err(), "{bad:?} should not parse");
473+ }
474+ }
475+
476+ #[test]
477+ fn a_bare_word_is_rejected_with_a_helpful_message() {
478+ let e = parse("main").unwrap_err();
479+ assert!(e.0.contains("not a value"), "got {}", e.0);
480+ }
481+
482+ #[test]
483+ fn absurdly_long_input_is_refused() {
484+ assert!(parse(&"open() | ".repeat(200)).is_err());
485+ }
486+
487+ // ─── SQL generation ──────────────────────────────────────────────────────
488+
489+ #[test]
490+ fn generates_numbered_placeholders_without_interpolating_values() {
491+ let e = parse("author(mira) & conflicted()").unwrap().unwrap();
492+ let mut n = 3;
493+ let (sql, vals) = to_sql(&e, &mut n);
494+
495+ assert!(sql.contains("$3"), "got {sql}");
496+ assert_eq!(vals, vec!["mira".to_string()]);
497+ assert_eq!(n, 4, "the parameter counter must advance");
498+ // The value must never appear inline — that would be an injection.
499+ assert!(!sql.contains("mira"), "value was interpolated into SQL: {sql}");
500+ }
501+
502+ #[test]
503+ fn a_hostile_value_stays_a_bind_parameter() {
504+ let e = parse("author(\"'; DROP TABLE changes; --\")").unwrap().unwrap();
505+ let mut n = 1;
506+ let (sql, vals) = to_sql(&e, &mut n);
507+ assert!(!sql.contains("DROP"), "SQL injection: {sql}");
508+ assert_eq!(vals[0], "'; DROP TABLE changes; --");
509+ }
510+
511+ #[test]
512+ fn placeholders_stay_unique_across_a_compound_expression() {
513+ let e = parse("author(a) & (bookmarks(b) | description(substring:\"c\"))")
514+ .unwrap()
515+ .unwrap();
516+ let mut n = 1;
517+ let (sql, vals) = to_sql(&e, &mut n);
518+ assert_eq!(vals.len(), 3);
519+ for i in 1..=3 {
520+ assert!(sql.contains(&format!("${i}")), "missing ${i} in {sql}");
521+ }
522+ assert_eq!(n, 4);
523+ }
524+
525+ #[test]
526+ fn like_wildcards_in_a_value_are_escaped() {
527+ // Bound as a parameter either way, so this is about meaning rather than
528+ // injection: `author(%)` must not match every author.
529+ assert_eq!(escape_like("%"), "\\%");
530+ assert_eq!(escape_like("a_b"), "a\\_b");
531+ assert_eq!(escape_like("100%_sure"), "100\\%\\_sure");
532+ assert_eq!(escape_like("plain"), "plain");
533+ assert_eq!(escape_like("back\\slash"), "back\\\\slash");
534+ }
535+
536+ #[test]
537+ fn escaped_values_reach_the_bind_list() {
538+ let e = parse("author(%)").unwrap().unwrap();
539+ let mut n = 3;
540+ let (_, vals) = to_sql(&e, &mut n);
541+ assert_eq!(vals, vec!["\\%".to_string()]);
542+ }
543+}
Acrates/df-web/src/routes/auth.rs401 lines+401−0
@@ −0,0 +1,401 @@
1+//! Login, callback, handle selection, and logout.
2+
3+use axum::extract::{Query, State};
4+use axum::response::{IntoResponse, Redirect, Response};
5+use axum::Form;
6+use axum_extra::extract::cookie::{Cookie, SameSite};
7+use axum_extra::extract::CookieJar;
8+use df_auth::provisioning;
9+use df_auth::{oidc, session};
10+use serde::Deserialize;
11+
12+use crate::error::{AppError, AppResult};
13+use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
14+use crate::views::{self, Chrome};
15+
16+#[derive(Deserialize)]
17+pub struct LoginQuery {
18+ /// Where to land after signing in. Validated as a local path.
19+ pub next: Option<String>,
20+ /// `sso` starts the OIDC round trip. Absent renders the sign-in page.
21+ pub r#continue: Option<String>,
22+}
23+
24+/// The sign-in page, and the start of the OIDC flow.
25+///
26+/// `/login` used to redirect to the provider immediately. It now renders a page
27+/// first and starts the flow at `/login?continue=sso`, so the product has a
28+/// sign-in screen of its own rather than handing the first impression to the
29+/// identity provider. Every existing link to `/login` still lands somewhere
30+/// sensible — one click further from the provider than before.
31+pub async fn login(
32+ State(state): State<AppState>,
33+ Query(q): Query<LoginQuery>,
34+ CurrentUser(user): CurrentUser,
35+ CsrfToken(csrf): CsrfToken,
36+ Nonce(nonce): Nonce,
37+) -> AppResult<Response> {
38+ if user.is_some() {
39+ return Ok(Redirect::to("/").into_response());
40+ }
41+
42+ // An unvalidated `next` here would be an open redirect handed to everyone
43+ // who follows a login link. Validated once, here, and then carried through
44+ // both the page and the flow.
45+ let next = q.next.as_deref().and_then(oidc::safe_redirect);
46+
47+ if q.r#continue.as_deref() == Some("sso") {
48+ let url = state.oidc.begin(&state.db, next.as_deref()).await?;
49+ return Ok(Redirect::to(&url).into_response());
50+ }
51+
52+ let sso_href = match &next {
53+ // `next` has already been validated as a local path, and Maud escapes
54+ // the attribute, so this cannot break out of the URL.
55+ Some(n) => {
56+ let encoded: String = form_urlencoded::byte_serialize(n.as_bytes()).collect();
57+ format!("/login?continue=sso&next={encoded}")
58+ }
59+ None => "/login?continue=sso".to_string(),
60+ };
61+
62+ Ok(views::page(
63+ Chrome { title: "Sign in", user: None, csrf: &csrf, nonce: &nonce },
64+ views::pages::signin(&sso_href),
65+ )
66+ .into_response())
67+}
68+
69+#[derive(Deserialize)]
70+pub struct CallbackQuery {
71+ pub code: Option<String>,
72+ pub state: Option<String>,
73+ pub error: Option<String>,
74+ pub error_description: Option<String>,
75+}
76+
77+/// OIDC redirect target.
78+pub async fn callback(
79+ State(state): State<AppState>,
80+ jar: CookieJar,
81+ Query(q): Query<CallbackQuery>,
82+ CsrfToken(csrf): CsrfToken,
83+ Nonce(nonce): Nonce,
84+ headers: axum::http::HeaderMap,
85+) -> AppResult<Response> {
86+ if let Some(err) = q.error {
87+ let detail = q.error_description.unwrap_or_default();
88+ tracing::warn!("OIDC provider returned error: {err} {detail}");
89+ return Ok(views::error_page(
90+ Chrome { title: "Sign-in failed", user: None, csrf: &csrf, nonce: &nonce },
91+ "Sign-in failed",
92+ "The identity provider rejected the sign-in. Please try again.",
93+ )
94+ .into_response());
95+ }
96+
97+ let (Some(code), Some(flow_state)) = (q.code, q.state) else {
98+ return Err(AppError::BadRequest("missing code or state".into()));
99+ };
100+
101+ let (identity, redirect_after) = state
102+ .oidc
103+ .complete(&state.db, &flow_state, &code)
104+ .await
105+ .map_err(|e| {
106+ tracing::warn!("completing OIDC login failed: {e:#}");
107+ AppError::BadRequest("Sign-in could not be completed. Please try again.".into())
108+ })?;
109+
110+ // Returning user: sign straight in.
111+ if let Some(user) = provisioning::find_by_subject(&state.db, &identity.subject).await? {
112+ let jar =
113+ establish_session(&state, jar, user.id, &headers, identity.id_token.as_deref())
114+ .await?;
115+ let target = redirect_after.as_deref().unwrap_or("/");
116+ return Ok((jar, Redirect::to(target)).into_response());
117+ }
118+
119+ // New identity: apply the invite/allowlist policy before creating anything.
120+ let invited = match identity.email.as_deref() {
121+ Some(email) => provisioning::has_invitation(&state.db, email).await?,
122+ None => false,
123+ };
124+
125+ if let Err(denied) = provisioning::admission(&identity, &state.config.allowlist, invited) {
126+ tracing::info!(
127+ subject = %identity.subject,
128+ "login denied: {denied:?}"
129+ );
130+ return Ok(views::page(
131+ Chrome { title: "Not invited", user: None, csrf: &csrf, nonce: &nonce },
132+ views::pages::not_invited(),
133+ )
134+ .into_response());
135+ }
136+
137+ // Admitted. Derive a handle, or ask for one.
138+ let suggested = match identity.suggested_handle() {
139+ Some(base) => provisioning::unique_handle(&state.db, &base).await?,
140+ None => None,
141+ };
142+
143+ let Some(handle) = suggested else {
144+ // Stash the pending identity and let the user choose.
145+ let jar = stash_pending(jar, &state, &identity)?;
146+ return Ok((
147+ jar,
148+ views::page(
149+ Chrome { title: "Choose a handle", user: None, csrf: &csrf, nonce: &nonce },
150+ views::pages::choose_handle(None, &csrf, None),
151+ ),
152+ )
153+ .into_response());
154+ };
155+
156+ let user = provisioning::create_user(&state.db, &identity, &handle).await?;
157+ tracing::info!(user = %user.id, handle = %handle, "provisioned new user");
158+
159+ let jar = establish_session(&state, jar, user.id, &headers, identity.id_token.as_deref())
160+ .await?;
161+ let target = redirect_after.as_deref().unwrap_or("/");
162+ Ok((jar, Redirect::to(target)).into_response())
163+}
164+
165+/// Cookie holding a signed, pending identity between the callback and handle
166+/// selection. Short-lived and self-contained, so no extra table is needed.
167+const PENDING_COOKIE: &str = "dogfood_pending";
168+
169+fn stash_pending(
170+ jar: CookieJar,
171+ state: &AppState,
172+ identity: &df_auth::Identity,
173+) -> AppResult<CookieJar> {
174+ let payload = serde_json::json!({
175+ "sub": identity.subject,
176+ "email": identity.email,
177+ "name": identity.name,
178+ "id_token": identity.id_token,
179+ })
180+ .to_string();
181+
182+ let mac = sign_pending(&state.config.session_secret, &payload);
183+ let value = format!("{}.{}", hex::encode(&payload), mac);
184+
185+ Ok(jar.add(
186+ Cookie::build((PENDING_COOKIE, value))
187+ .path("/")
188+ .secure(state.config.secure_cookies())
189+ .http_only(true)
190+ .same_site(SameSite::Lax)
191+ .max_age(time::Duration::minutes(15))
192+ .build(),
193+ ))
194+}
195+
196+fn sign_pending(secret: &[u8], payload: &str) -> String {
197+ use hmac::{Hmac, Mac};
198+ use sha2::Sha256;
199+ let mut mac = <Hmac<Sha256>>::new_from_slice(secret).expect("hmac accepts any key");
200+ mac.update(payload.as_bytes());
201+ hex::encode(mac.finalize().into_bytes())
202+}
203+
204+fn read_pending(secret: &[u8], jar: &CookieJar) -> Option<df_auth::Identity> {
205+ use subtle::ConstantTimeEq;
206+
207+ let raw = jar.get(PENDING_COOKIE)?.value();
208+ let (payload_hex, mac) = raw.split_once('.')?;
209+ let payload = String::from_utf8(hex::decode(payload_hex).ok()?).ok()?;
210+
211+ let expected = sign_pending(secret, &payload);
212+ let ok: bool = expected.as_bytes().ct_eq(mac.as_bytes()).into();
213+ if !ok {
214+ return None;
215+ }
216+
217+ let v: serde_json::Value = serde_json::from_str(&payload).ok()?;
218+ Some(df_auth::Identity {
219+ subject: v.get("sub")?.as_str()?.to_string(),
220+ email: v.get("email").and_then(|e| e.as_str()).map(str::to_string),
221+ name: v.get("name").and_then(|e| e.as_str()).map(str::to_string),
222+ preferred_username: None,
223+ id_token: v.get("id_token").and_then(|e| e.as_str()).map(str::to_string),
224+ })
225+}
226+
227+#[derive(Deserialize)]
228+pub struct HandleForm {
229+ pub handle: String,
230+}
231+
232+/// Complete provisioning with a user-chosen handle.
233+pub async fn choose_handle(
234+ State(state): State<AppState>,
235+ jar: CookieJar,
236+ CsrfToken(csrf): CsrfToken,
237+ Nonce(nonce): Nonce,
238+ headers: axum::http::HeaderMap,
239+ Form(form): Form<HandleForm>,
240+) -> AppResult<Response> {
241+ let Some(identity) = read_pending(&state.config.session_secret, &jar) else {
242+ return Ok(Redirect::to("/login").into_response());
243+ };
244+
245+ // Re-check admission: the pending cookie proves who they are, not that
246+ // policy still admits them.
247+ let invited = match identity.email.as_deref() {
248+ Some(e) => provisioning::has_invitation(&state.db, e).await?,
249+ None => false,
250+ };
251+ if provisioning::admission(&identity, &state.config.allowlist, invited).is_err() {
252+ return Ok(views::page(
253+ Chrome { title: "Not invited", user: None, csrf: &csrf, nonce: &nonce },
254+ views::pages::not_invited(),
255+ )
256+ .into_response());
257+ }
258+
259+ let handle = form.handle.trim().to_lowercase();
260+
261+ let reject = |msg: &str| -> Response {
262+ views::page(
263+ Chrome { title: "Choose a handle", user: None, csrf: &csrf, nonce: &nonce },
264+ views::pages::choose_handle(Some(&handle), &csrf, Some(msg)),
265+ )
266+ .into_response()
267+ };
268+
269+ if !valid_handle(&handle) {
270+ return Ok(reject(
271+ "Handles must start with a letter or digit and contain only lowercase \
272+ letters, digits and hyphens.",
273+ ));
274+ }
275+ if !provisioning::handle_available(&state.db, &handle).await? {
276+ return Ok(reject("That handle is already taken."));
277+ }
278+
279+ let user = provisioning::create_user(&state.db, &identity, &handle).await?;
280+ tracing::info!(user = %user.id, handle = %handle, "provisioned new user (chosen handle)");
281+
282+ let jar = jar.remove(Cookie::from(PENDING_COOKIE));
283+ let jar = establish_session(&state, jar, user.id, &headers, identity.id_token.as_deref())
284+ .await?;
285+ Ok((jar, Redirect::to("/")).into_response())
286+}
287+
288+/// Mirror of the database CHECK constraint.
289+fn valid_handle(h: &str) -> bool {
290+ !h.is_empty()
291+ && h.len() <= 39
292+ && h.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
293+ && h.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
294+}
295+
296+async fn establish_session(
297+ state: &AppState,
298+ jar: CookieJar,
299+ user_id: uuid::Uuid,
300+ headers: &axum::http::HeaderMap,
301+ id_token: Option<&str>,
302+) -> AppResult<CookieJar> {
303+ let ua = headers
304+ .get(axum::http::header::USER_AGENT)
305+ .and_then(|v| v.to_str().ok());
306+
307+ let (id, expires) = session::create(
308+ &state.db,
309+ user_id,
310+ state.config.session_ttl_days,
311+ ua,
312+ None,
313+ id_token,
314+ )
315+ .await?;
316+
317+ let cookie = Cookie::build((session::COOKIE_NAME, id.to_string()))
318+ .path("/")
319+ .secure(state.config.secure_cookies())
320+ .http_only(true)
321+ .same_site(SameSite::Lax)
322+ .expires(
323+ time::OffsetDateTime::from_unix_timestamp(expires.timestamp())
324+ .unwrap_or(time::OffsetDateTime::UNIX_EPOCH),
325+ )
326+ .build();
327+
328+ Ok(jar.add(cookie))
329+}
330+
331+/// Sign out: destroy the local session, then hand off to the provider's
332+/// end-session endpoint when one is advertised.
333+pub async fn logout(
334+ State(state): State<AppState>,
335+ jar: CookieJar,
336+) -> AppResult<Response> {
337+ let mut id_token_hint = None;
338+ if let Some(raw) = jar.get(session::COOKIE_NAME) {
339+ if let Ok(id) = raw.value().parse::<uuid::Uuid>() {
340+ id_token_hint = session::id_token(&state.db, id).await.unwrap_or_default();
341+ if let Err(e) = session::destroy(&state.db, id).await {
342+ tracing::warn!("destroying session failed: {e}");
343+ }
344+ }
345+ }
346+
347+ let jar = jar.remove(Cookie::from(session::COOKIE_NAME));
348+
349+ // Hydra rejects `post_logout_redirect_uri` without `id_token_hint`
350+ // (`invalid_request`), which would otherwise strand the user on an error
351+ // page even though the local session above is already gone. Without a
352+ // hint to offer, skip the provider round-trip and just go home.
353+ let target = match (state.oidc.end_session_endpoint(), id_token_hint) {
354+ (Some(endpoint), Some(hint)) => format!(
355+ "{endpoint}?id_token_hint={}&post_logout_redirect_uri={}",
356+ urlencoding_encode(&hint),
357+ urlencoding_encode(&state.config.base_url)
358+ ),
359+ _ => "/".to_string(),
360+ };
361+
362+ Ok((jar, Redirect::to(&target)).into_response())
363+}
364+
365+fn urlencoding_encode(s: &str) -> String {
366+ percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string()
367+}
368+
369+#[cfg(test)]
370+mod tests {
371+ use super::*;
372+
373+ #[test]
374+ fn handle_validation_mirrors_the_db_constraint() {
375+ assert!(valid_handle("alice"));
376+ assert!(valid_handle("a"));
377+ assert!(valid_handle("9lives"));
378+ assert!(valid_handle("with-hyphen"));
379+
380+ assert!(!valid_handle(""));
381+ assert!(!valid_handle("-leading"));
382+ assert!(!valid_handle("Upper"));
383+ assert!(!valid_handle("under_score"));
384+ assert!(!valid_handle("with space"));
385+ assert!(!valid_handle("üñí"));
386+ assert!(!valid_handle(&"a".repeat(40)));
387+ assert!(valid_handle(&"a".repeat(39)));
388+ }
389+
390+ #[test]
391+ fn pending_identity_round_trips_and_rejects_tampering() {
392+ let secret = b"secret-key-for-pending-identity-cookie";
393+ let payload = r#"{"sub":"abc","email":"a@b.c","name":null}"#;
394+ let mac = sign_pending(secret, payload);
395+
396+ // A wrong secret must not verify.
397+ assert_ne!(mac, sign_pending(b"other-secret", payload));
398+ // A changed payload must not verify against the old tag.
399+ assert_ne!(mac, sign_pending(secret, r#"{"sub":"attacker"}"#));
400+ }
401+}
Acrates/df-web/src/routes/change.rs413 lines+413−0
@@ −0,0 +1,413 @@
1+//! Change list and detail (M3).
2+
3+use axum::extract::{Path as UrlPath, Query, State};
4+use axum::response::{IntoResponse, Redirect, Response};
5+use axum::Form;
6+use serde::Deserialize;
7+use uuid::Uuid;
8+
9+use crate::error::{AppError, AppResult};
10+use crate::repo_ctx::RepoContext;
11+use crate::revset;
12+use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
13+use crate::views::change as v;
14+use crate::views::repo as rv;
15+use crate::views::{self, Chrome};
16+
17+#[derive(Deserialize, Default)]
18+pub struct ListQuery {
19+ pub state: Option<String>,
20+ pub revset: Option<String>,
21+}
22+
23+/// `GET /{owner}/{repo}/changes`
24+pub async fn list(
25+ State(state): State<AppState>,
26+ UrlPath((owner, name)): UrlPath<(String, String)>,
27+ Query(q): Query<ListQuery>,
28+ CurrentUser(user): CurrentUser,
29+ CsrfToken(csrf): CsrfToken,
30+ Nonce(nonce): Nonce,
31+) -> AppResult<Response> {
32+ let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
33+
34+ let state_filter = q.state.as_deref().unwrap_or("open");
35+ let revset_input = q.revset.unwrap_or_default();
36+
37+ // Parse the revset before touching the database. An unsupported expression
38+ // must produce a clear message, never a partial result (spec §8).
39+ let (revset_sql, revset_vals, revset_error) = match revset::parse(&revset_input) {
40+ Ok(Some(expr)) => {
41+ let mut n = 3; // $1 = repo_id, $2 = state filter
42+ let (sql, vals) = revset::to_sql(&expr, &mut n);
43+ (Some(sql), vals, None)
44+ }
45+ Ok(None) => (None, vec![], None),
46+ Err(e) => (None, vec![], Some(e.to_string())),
47+ };
48+
49+ // On a revset error, show the message and no rows rather than silently
50+ // listing everything — which would look like the filter had matched.
51+ let rows = if revset_error.is_some() {
52+ Vec::new()
53+ } else {
54+ load_changes(&state, ctx.repo.id, state_filter, revset_sql, revset_vals).await?
55+ };
56+
57+ let body = maud::html! {
58+ (rv::header(&ctx, "changes"))
59+ (v::list(&ctx, &rows, v::ListFilters {
60+ state: state_filter,
61+ revset: &revset_input,
62+ revset_error: revset_error.as_deref(),
63+ }))
64+ };
65+
66+ Ok(views::page(
67+ Chrome {
68+ title: &format!("Changes · {}/{}", ctx.owner, ctx.repo.name),
69+ user: user.as_deref(),
70+ csrf: &csrf,
71+ nonce: &nonce,
72+ },
73+ body,
74+ )
75+ .into_response())
76+}
77+
78+async fn load_changes(
79+ state: &AppState,
80+ repo_id: Uuid,
81+ state_filter: &str,
82+ revset_sql: Option<String>,
83+ revset_vals: Vec<String>,
84+) -> AppResult<Vec<v::ChangeRow>> {
85+ let mut sql = String::from(
86+ "SELECT c.number, c.change_id, c.synthetic, c.title, c.state::text,
87+ c.conflicted, c.updated_at,
88+ u.handle::text AS author,
89+ (SELECT count(*) FROM revisions rr WHERE rr.change_id_fk = c.id) AS revcount,
90+ COALESCE((
91+ SELECT array_agg(pc.change_id)
92+ FROM change_edges e
93+ JOIN changes pc ON pc.id = e.child_change
94+ WHERE e.parent_change = c.id
95+ ), '{}') AS children
96+ FROM changes c
97+ LEFT JOIN users u ON u.id = c.author_user_id
98+ WHERE c.repo_id = $1
99+ AND ($2 = 'all' OR c.state::text = $2)",
100+ );
101+
102+ if let Some(frag) = &revset_sql {
103+ sql.push_str(" AND ");
104+ sql.push_str(frag);
105+ }
106+ sql.push_str(" ORDER BY c.updated_at DESC LIMIT 100");
107+
108+ let mut query = sqlx::query_as::<
109+ _,
110+ (
111+ i64,
112+ String,
113+ bool,
114+ String,
115+ String,
116+ bool,
117+ chrono::DateTime<chrono::Utc>,
118+ Option<String>,
119+ i64,
120+ Vec<String>,
121+ ),
122+ >(&sql)
123+ .bind(repo_id)
124+ .bind(state_filter);
125+
126+ for v in revset_vals {
127+ query = query.bind(v);
128+ }
129+
130+ let rows = query.fetch_all(&state.db).await?;
131+
132+ Ok(rows
133+ .into_iter()
134+ .map(
135+ |(number, change_id, synthetic, title, st, conflicted, updated_at, author, revcount, children)| {
136+ v::ChangeRow {
137+ number,
138+ change_id,
139+ synthetic,
140+ title,
141+ state: st,
142+ conflicted,
143+ updated_at,
144+ author,
145+ revision_count: revcount,
146+ children,
147+ }
148+ },
149+ )
150+ .collect())
151+}
152+
153+pub struct ChangeRecord {
154+ pub id: Uuid,
155+ pub number: i64,
156+ pub change_id: String,
157+ pub synthetic: bool,
158+ pub title: String,
159+ pub description: String,
160+ pub state: String,
161+ pub conflicted: bool,
162+ pub target_bookmark: String,
163+}
164+
165+pub enum Resolution {
166+ One(Box<ChangeRecord>),
167+ /// (number, change_id, title)
168+ Ambiguous(Vec<(i64, String, String)>),
169+ None,
170+}
171+
172+type ChangeTuple = (Uuid, i64, String, bool, String, String, String, bool, String);
173+
174+fn to_record(t: ChangeTuple) -> ChangeRecord {
175+ ChangeRecord {
176+ id: t.0,
177+ number: t.1,
178+ change_id: t.2,
179+ synthetic: t.3,
180+ title: t.4,
181+ description: t.5,
182+ state: t.6,
183+ conflicted: t.7,
184+ target_bookmark: t.8,
185+ }
186+}
187+
188+const SELECT_CHANGE: &str = "SELECT id, number, change_id, synthetic, title, description,
189+ state::text, conflicted, target_bookmark
190+ FROM changes";
191+
192+pub async fn resolve_change(state: &AppState, repo_id: Uuid, reference: &str) -> AppResult<Resolution> {
193+ // A pure number is a display number.
194+ if let Ok(number) = reference.parse::<i64>() {
195+ let row: Option<ChangeTuple> =
196+ sqlx::query_as(&format!("{SELECT_CHANGE} WHERE repo_id = $1 AND number = $2"))
197+ .bind(repo_id)
198+ .bind(number)
199+ .fetch_optional(&state.db)
200+ .await?;
201+ return Ok(match row {
202+ Some(r) => Resolution::One(Box::new(to_record(r))),
203+ None => Resolution::None,
204+ });
205+ }
206+
207+ // Otherwise a change-id prefix. Reject anything outside the alphabet before
208+ // it reaches a LIKE pattern.
209+ if reference.is_empty()
210+ || reference.len() > 32
211+ || !reference.bytes().all(|b| (b'k'..=b'z').contains(&b))
212+ {
213+ return Ok(Resolution::None);
214+ }
215+
216+ // Uses changes_prefix_idx (repo_id, change_id text_pattern_ops).
217+ let rows: Vec<ChangeTuple> = sqlx::query_as(&format!(
218+ "{SELECT_CHANGE} WHERE repo_id = $1 AND change_id LIKE $2 || '%' ORDER BY number LIMIT 25"
219+ ))
220+ .bind(repo_id)
221+ .bind(reference)
222+ .fetch_all(&state.db)
223+ .await?;
224+
225+ Ok(match rows.len() {
226+ 0 => Resolution::None,
227+ 1 => Resolution::One(Box::new(to_record(rows.into_iter().next().unwrap()))),
228+ // Never guess (spec §7).
229+ _ => Resolution::Ambiguous(
230+ rows.into_iter()
231+ .map(|r| (r.1, r.2, r.4))
232+ .collect(),
233+ ),
234+ })
235+}
236+
237+// ─── opening a change for review (M3) ────────────────────────────────────────
238+
239+/// `GET /{owner}/{repo}/changes/new`
240+///
241+/// A change is not created here — the indexer creates one the moment a change
242+/// id is first seen on a push. What this form does is *propose* already-pushed
243+/// work: pick the change, set its target bookmark, title and description, and
244+/// move it out of draft.
245+///
246+/// That is the honest model for a jj forge. The work exists in the repository
247+/// before anybody opens a review of it, and pretending the review created it
248+/// would mean either inventing a commit or refusing to show work that is
249+/// already pushed.
250+pub async fn new_form(
251+ State(state): State<AppState>,
252+ UrlPath((owner, name)): UrlPath<(String, String)>,
253+ Query(q): Query<NewQuery>,
254+ CurrentUser(user): CurrentUser,
255+ CsrfToken(csrf): CsrfToken,
256+ Nonce(nonce): Nonce,
257+) -> AppResult<Response> {
258+ let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
259+ if user.is_none() {
260+ return Err(AppError::Unauthorized);
261+ }
262+ ctx.require_push()?;
263+
264+ let candidates = load_proposable(&state, ctx.repo.id).await?;
265+ let bookmarks: Vec<String> =
266+ sqlx::query_scalar("SELECT name FROM bookmarks WHERE repo_id = $1 ORDER BY name")
267+ .bind(ctx.repo.id)
268+ .fetch_all(&state.db)
269+ .await?;
270+
271+ let body = maud::html! {
272+ (rv::header(&ctx, "changes"))
273+ (v::new_change_form(&ctx, &csrf, &candidates, &bookmarks, q.error.as_deref()))
274+ };
275+
276+ Ok(views::page(
277+ Chrome {
278+ title: &format!("Open a change · {}/{}", ctx.owner, ctx.repo.name),
279+ user: user.as_deref(),
280+ csrf: &csrf,
281+ nonce: &nonce,
282+ },
283+ body,
284+ )
285+ .into_response())
286+}
287+
288+#[derive(Deserialize, Default)]
289+pub struct NewQuery {
290+ pub error: Option<String>,
291+}
292+
293+#[derive(Deserialize)]
294+pub struct OpenChange {
295+ pub change: String,
296+ pub target_bookmark: String,
297+ pub title: String,
298+ pub description: Option<String>,
299+}
300+
301+/// `POST /{owner}/{repo}/changes`
302+pub async fn create(
303+ State(state): State<AppState>,
304+ UrlPath((owner, name)): UrlPath<(String, String)>,
305+ CurrentUser(user): CurrentUser,
306+ Form(form): Form<OpenChange>,
307+) -> AppResult<Response> {
308+ let Some(user) = user else {
309+ return Err(AppError::Unauthorized);
310+ };
311+ let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?;
312+ ctx.require_push()?;
313+
314+ let reject = |msg: &str| -> Response {
315+ Redirect::to(&format!(
316+ "{}/changes/new?error={}",
317+ ctx.base(),
318+ crate::routes::settings::urlencode(msg)
319+ ))
320+ .into_response()
321+ };
322+
323+ let title: String = form.title.trim().chars().take(300).collect();
324+ if title.is_empty() {
325+ return Ok(reject("A change needs a title."));
326+ }
327+
328+ // The target must be a bookmark this repository actually has. Accepting an
329+ // arbitrary string would produce a change that can never be merged and no
330+ // error until somebody tried.
331+ let bookmark_exists: bool = sqlx::query_scalar(
332+ "SELECT EXISTS (SELECT 1 FROM bookmarks WHERE repo_id = $1 AND name = $2)",
333+ )
334+ .bind(ctx.repo.id)
335+ .bind(form.target_bookmark.trim())
336+ .fetch_one(&state.db)
337+ .await?;
338+ if !bookmark_exists {
339+ return Ok(reject("That bookmark does not exist in this repository."));
340+ }
341+
342+ // Scoped to the repository, so a change id from elsewhere resolves to
343+ // nothing rather than being adopted.
344+ let row: Option<(Uuid, i64)> =
345+ sqlx::query_as("SELECT id, number FROM changes WHERE repo_id = $1 AND change_id = $2")
346+ .bind(ctx.repo.id)
347+ .bind(form.change.trim())
348+ .fetch_optional(&state.db)
349+ .await?;
350+
351+ let Some((change_uuid, number)) = row else {
352+ return Ok(reject("That change is not in this repository."));
353+ };
354+
355+ sqlx::query(
356+ "UPDATE changes
357+ SET title = $2, description = $3, target_bookmark = $4,
358+ author_user_id = COALESCE(author_user_id, $5),
359+ -- Only a draft is promoted. A change already open, merged or
360+ -- abandoned keeps the state it has; this form proposes work, it
361+ -- does not resurrect it.
362+ state = CASE WHEN state = 'draft' THEN 'open'::change_state ELSE state END,
363+ updated_at = now()
364+ WHERE id = $1",
365+ )
366+ .bind(change_uuid)
367+ .bind(&title)
368+ .bind(form.description.as_deref().unwrap_or("").trim())
369+ .bind(form.target_bookmark.trim())
370+ .bind(user.id)
371+ .execute(&state.db)
372+ .await?;
373+
374+ crate::routes::review::event(
375+ &state,
376+ ctx.repo.id,
377+ Some(user.id),
378+ "change.opened",
379+ change_uuid,
380+ serde_json::json!({ "change_id": form.change.trim() }),
381+ )
382+ .await;
383+
384+ Ok(Redirect::to(&format!("{}/changes/{number}", ctx.base())).into_response())
385+}
386+
387+/// Changes that are worth proposing: pushed, not landed, not abandoned.
388+async fn load_proposable(state: &AppState, repo_id: Uuid) -> AppResult<Vec<v::Proposable>> {
389+ let rows: Vec<(String, i64, String, bool, i64, String)> = sqlx::query_as(
390+ "SELECT c.change_id, c.number, c.title, c.synthetic,
391+ (SELECT count(*) FROM revisions r WHERE r.change_id_fk = c.id) AS revcount,
392+ c.state::text
393+ FROM changes c
394+ WHERE c.repo_id = $1 AND c.state IN ('draft', 'open')
395+ ORDER BY c.updated_at DESC
396+ LIMIT 100",
397+ )
398+ .bind(repo_id)
399+ .fetch_all(&state.db)
400+ .await?;
401+
402+ Ok(rows
403+ .into_iter()
404+ .map(|(change_id, number, title, synthetic, revisions, state)| v::Proposable {
405+ change_id,
406+ number,
407+ title,
408+ synthetic,
409+ revisions,
410+ state,
411+ })
412+ .collect())
413+}
Acrates/df-web/src/routes/design.rs+37−0
@@ −0,0 +1,37 @@
1+//! The design system reference pages.
2+//!
3+//! Static: they render tokens and components, and touch neither the database
4+//! nor the store. Public, because a design system is not a secret and the
5+//! implementation team should be able to link to it.
6+
7+use axum::response::{IntoResponse, Response};
8+
9+use crate::error::AppResult;
10+use crate::state::{CsrfToken, CurrentUser, Nonce};
11+use crate::views::{self, Chrome};
12+
13+/// `GET /design`
14+pub async fn sheet(
15+ CurrentUser(user): CurrentUser,
16+ CsrfToken(csrf): CsrfToken,
17+ Nonce(nonce): Nonce,
18+) -> AppResult<Response> {
19+ Ok(views::page(
20+ Chrome { title: "Component sheet", user: user.as_deref(), csrf: &csrf, nonce: &nonce },
21+ views::design::sheet(),
22+ )
23+ .into_response())
24+}
25+
26+/// `GET /design/rationale`
27+pub async fn rationale(
28+ CurrentUser(user): CurrentUser,
29+ CsrfToken(csrf): CsrfToken,
30+ Nonce(nonce): Nonce,
31+) -> AppResult<Response> {
32+ Ok(views::page(
33+ Chrome { title: "Design rationale", user: user.as_deref(), csrf: &csrf, nonce: &nonce },
34+ views::design::rationale(),
35+ )
36+ .into_response())
37+}
Acrates/df-web/src/routes/edit.rs+285−0
@@ −0,0 +1,285 @@
1+//! In-browser file editing.
2+//!
3+//! Not in the spec's route table — an addition on top of v1. It is built to the
4+//! same rules as everything else, and two of them shape the whole design:
5+//!
6+//! * **Progressive enhancement is a hard requirement** (spec §7). The page is a
7+//! form containing a `<textarea>`. CodeMirror replaces it when the bundle
8+//! loads; when it does not, the plain textarea saves exactly the same way.
9+//! * **Edits are commits, not a side channel.** Saving writes a real commit
10+//! whose parent is the bookmark's current tip, through the same store the
11+//! push path uses. It is a fast-forward by construction, so it satisfies the
12+//! protected-bookmark rule rather than bypassing it — and the indexer picks
13+//! it up like any other push.
14+//!
15+//! Editing is a `write`-level action: the same permission a push needs, because
16+//! it is the same effect.
17+
18+use axum::extract::{Path as UrlPath, Query, State};
19+use axum::response::{IntoResponse, Redirect, Response};
20+use axum::Form;
21+use df_store::{EditOutcome, RevId};
22+use serde::Deserialize;
23+
24+use crate::error::{AppError, AppResult};
25+use crate::repo_ctx::RepoContext;
26+use crate::routes::settings::urlencode;
27+use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
28+use crate::views::edit as v;
29+use crate::views::repo as rv;
30+use crate::views::{self, Chrome};
31+
32+/// Largest file the editor will open.
33+///
34+/// Matches the store's own write cap. A file bigger than this is not something
35+/// to edit in a browser, and saying so up front is kinder than letting somebody
36+/// type into it and be refused on save.
37+const MAX_EDIT_BYTES: u64 = 1024 * 1024;
38+
39+#[derive(Deserialize, Default)]
40+pub struct EditQuery {
41+ pub error: Option<String>,
42+}
43+
44+/// `GET /{owner}/{repo}/edit/{rev}/{path}`
45+///
46+/// `rev` must be a **bookmark name**, not an arbitrary revision. An edit has to
47+/// land somewhere, and "commit this on top of a detached revision" has no
48+/// meaning the user could act on.
49+pub async fn show(
50+ State(state): State<AppState>,
51+ UrlPath(p): UrlPath<super::repo::RevPath>,
52+ Query(q): Query<EditQuery>,
53+ CurrentUser(user): CurrentUser,
54+ CsrfToken(csrf): CsrfToken,
55+ Nonce(nonce): Nonce,
56+) -> AppResult<Response> {
57+ let ctx = RepoContext::load(&state, &p.owner, &p.repo, user.as_deref()).await?;
58+ if user.is_none() {
59+ return Err(AppError::Unauthorized);
60+ }
61+ ctx.require_push()?;
62+ if ctx.repo.archived {
63+ return Err(AppError::BadRequest(
64+ "This repository is archived and cannot be edited.".into(),
65+ ));
66+ }
67+
68+ let bookmark = resolve_bookmark(&state, &ctx, &p.rev).await?;
69+ let tip = state
70+ .store
71+ .resolve(ctx.store_id(), &bookmark)
72+ .await
73+ .map_err(|_| AppError::NotFound)?;
74+
75+ let blob = state
76+ .store
77+ .read_blob(ctx.store_id(), &tip, std::path::Path::new(&p.path))
78+ .await
79+ .map_err(|e| match e {
80+ df_store::StoreError::NoSuchPath => AppError::NotFound,
81+ df_store::StoreError::IsDirectory => {
82+ AppError::BadRequest("that path is a directory".into())
83+ }
84+ other => AppError::Internal(anyhow::anyhow!(other)),
85+ })?;
86+
87+ if blob.size > MAX_EDIT_BYTES {
88+ return Err(AppError::BadRequest(format!(
89+ "that file is {} bytes; the editor handles up to {MAX_EDIT_BYTES}",
90+ blob.size
91+ )));
92+ }
93+
94+ // Binary content has no meaningful textarea representation, and round-
95+ // tripping it through one would corrupt it.
96+ let Some(text) = blob.text() else {
97+ return Err(AppError::BadRequest(
98+ "that file is binary and cannot be edited here".into(),
99+ ));
100+ };
101+
102+ let body = maud::html! {
103+ (rv::header(&ctx, "code"))
104+ (v::editor(&ctx, v::Editor {
105+ bookmark: &bookmark,
106+ tip: tip.as_str(),
107+ path: &p.path,
108+ content: text,
109+ language: df_render::highlight::language_for_path(&p.path).map(|l| l.grammar),
110+ author: user.as_deref().map(|u| u.label()).unwrap_or(""),
111+ csrf: &csrf,
112+ error: q.error.as_deref(),
113+ }))
114+ };
115+
116+ Ok(views::page(
117+ Chrome {
118+ title: &format!("Editing {} · {}/{}", p.path, ctx.owner, ctx.repo.name),
119+ user: user.as_deref(),
120+ csrf: &csrf,
121+ nonce: &nonce,
122+ },
123+ // The one page that loads the editor bundle.
124+ maud::html! {
125+ (body)
126+ script src="/assets/editor.js" nonce=(nonce) defer {}
127+ },
128+ )
129+ .into_response())
130+}
131+
132+#[derive(Deserialize)]
133+pub struct SaveForm {
134+ pub content: String,
135+ pub message: Option<String>,
136+ /// The revision the edit was composed against, so a concurrent edit can be
137+ /// detected instead of silently overwritten.
138+ pub tip: String,
139+}
140+
141+/// `POST /{owner}/{repo}/edit/{rev}/{path}`
142+pub async fn save(
143+ State(state): State<AppState>,
144+ UrlPath(p): UrlPath<super::repo::RevPath>,
145+ CurrentUser(user): CurrentUser,
146+ Form(form): Form<SaveForm>,
147+) -> AppResult<Response> {
148+ let Some(user) = user else {
149+ return Err(AppError::Unauthorized);
150+ };
151+ let ctx = RepoContext::load(&state, &p.owner, &p.repo, Some(&user)).await?;
152+ ctx.require_push()?;
153+ if ctx.repo.archived {
154+ return Err(AppError::BadRequest(
155+ "This repository is archived and cannot be edited.".into(),
156+ ));
157+ }
158+
159+ let bookmark = resolve_bookmark(&state, &ctx, &p.rev).await?;
160+
161+ let back = |msg: &str| -> Response {
162+ Redirect::to(&format!(
163+ "{}/edit/{}/{}?error={}",
164+ ctx.base(),
165+ bookmark,
166+ p.path,
167+ urlencode(msg)
168+ ))
169+ .into_response()
170+ };
171+
172+ // Browsers submit CRLF in textarea content regardless of what was typed.
173+ // Normalising here keeps a save from showing up as a diff on every line of
174+ // a file that uses LF — which is every file in a Unix repository.
175+ let content = form.content.replace("\r\n", "\n");
176+
177+ if content.len() as u64 > MAX_EDIT_BYTES {
178+ return Ok(back("That content is too large to save."));
179+ }
180+
181+ let message = form
182+ .message
183+ .as_deref()
184+ .map(str::trim)
185+ .filter(|m| !m.is_empty())
186+ .map(|m| m.chars().take(500).collect::<String>())
187+ .unwrap_or_else(|| format!("Update {}", p.path));
188+
189+ // Attributed to the person who pressed save, with a noreply address — the
190+ // email from the identity provider is a claim we are told not to key on and
191+ // should not scatter into commit objects (spec §6).
192+ let author = df_store::Signature {
193+ name: user.label().to_owned(),
194+ email: format!("{}@users.noreply.{}", user.handle, state.config.host()),
195+ when: chrono::Utc::now(),
196+ };
197+
198+ let outcome = state
199+ .store
200+ .commit_file(
201+ ctx.store_id(),
202+ &bookmark,
203+ &RevId::from_stored(form.tip.clone()),
204+ &p.path,
205+ content.into_bytes(),
206+ &message,
207+ &author,
208+ )
209+ .await;
210+
211+ match outcome {
212+ Ok(EditOutcome::Committed { rev }) => {
213+ // The commit did not arrive over the wire, so no hook fired and
214+ // nothing else would notice it. Index it explicitly.
215+ if let Err(e) = crate::git_http::enqueue_index(&state, ctx.repo.id, Some(user.id)).await
216+ {
217+ tracing::error!(repo = %ctx.repo.id, "enqueuing IndexPush after an edit failed: {e:#}");
218+ }
219+ let _ = sqlx::query("UPDATE repos SET pushed_at = now() WHERE id = $1")
220+ .bind(ctx.repo.id)
221+ .execute(&state.db)
222+ .await;
223+
224+ tracing::info!(
225+ repo = %ctx.repo.id, path = %p.path, rev = %rev, actor = %user.handle,
226+ "file edited in the browser"
227+ );
228+
229+ Ok(Redirect::to(&format!(
230+ "{}/blob/{bookmark}/{}",
231+ ctx.base(),
232+ p.path
233+ ))
234+ .into_response())
235+ }
236+ Ok(EditOutcome::Unchanged) => Ok(Redirect::to(&format!(
237+ "{}/blob/{bookmark}/{}",
238+ ctx.base(),
239+ p.path
240+ ))
241+ .into_response()),
242+ Ok(EditOutcome::Stale { .. }) => Ok(back(
243+ "Somebody else changed this file while you were editing. \
244+ Your text was not saved — reopen the editor and reapply it.",
245+ )),
246+ Err(df_store::StoreError::TooLarge { .. }) => Ok(back("That content is too large to save.")),
247+ Err(df_store::StoreError::Path(e)) => Ok(back(&e.to_string())),
248+ Err(e) => {
249+ tracing::error!(repo = %ctx.repo.id, path = %p.path, "saving an edit failed: {e}");
250+ Ok(back("The file could not be saved."))
251+ }
252+ }
253+}
254+
255+/// Require `rev` to name a bookmark of this repository.
256+///
257+/// An edit has to land on something that moves. Accepting a raw revision would
258+/// let the URL ask for a commit on top of a detached history that nothing
259+/// points at — written, unreachable, and immediately garbage.
260+async fn resolve_bookmark(
261+ state: &AppState,
262+ ctx: &RepoContext,
263+ rev: &str,
264+) -> AppResult<String> {
265+ let known: bool = sqlx::query_scalar(
266+ "SELECT EXISTS (SELECT 1 FROM bookmarks WHERE repo_id = $1 AND name = $2)",
267+ )
268+ .bind(ctx.repo.id)
269+ .bind(rev)
270+ .fetch_one(&state.db)
271+ .await?;
272+
273+ if known {
274+ return Ok(rev.to_string());
275+ }
276+
277+ // A repository that has been pushed to but not indexed yet has no bookmark
278+ // rows. Fall back to the configured default so the first edit is not
279+ // blocked by a lagging worker.
280+ if rev == ctx.repo.default_bookmark {
281+ return Ok(rev.to_string());
282+ }
283+
284+ Err(AppError::NotFound)
285+}
Acrates/df-web/src/routes/health.rs+26−0
@@ −0,0 +1,26 @@
1+//! Liveness and readiness.
2+//!
3+//! `/healthz` answers whether the process is up; `/readyz` answers whether it
4+//! can serve, which means the database is reachable. Container orchestration
5+//! restarts on the former and withholds traffic on the latter, so conflating
6+//! them turns a transient database blip into a restart loop.
7+
8+use axum::extract::State;
9+use axum::http::StatusCode;
10+use axum::response::IntoResponse;
11+
12+use crate::state::AppState;
13+
14+pub async fn healthz() -> impl IntoResponse {
15+ (StatusCode::OK, "ok\n")
16+}
17+
18+pub async fn readyz(State(state): State<AppState>) -> impl IntoResponse {
19+ match df_db::ping(&state.db).await {
20+ Ok(()) => (StatusCode::OK, "ready\n"),
21+ Err(e) => {
22+ tracing::error!("readiness check failed: {e}");
23+ (StatusCode::SERVICE_UNAVAILABLE, "database unreachable\n")
24+ }
25+ }
26+}
Acrates/df-web/src/routes/home.rs+371−0
@@ −0,0 +1,371 @@
1+//! Landing page and dashboard.
2+
3+use axum::extract::State;
4+use axum::response::{IntoResponse, Response};
5+use chrono::{DateTime, Utc};
6+
7+use crate::error::AppResult;
8+use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
9+use crate::views::pages::{ActivityItem, Dashboard, DashChange, FeedItem, RepoSummary};
10+use crate::views::{self, Chrome};
11+
12+/// Repos the viewer can reach: their own, plus any they collaborate on, plus
13+/// any belonging to an org they are a member of, plus public repos.
14+///
15+/// Visibility is enforced *in* the query rather than by filtering afterwards —
16+/// a post-filter is how private repos leak into listings. `$1` is the viewer's
17+/// id and `$2` is whether they are an admin.
18+const VISIBLE_TO_VIEWER: &str = r#"
19+ r.archived = false
20+ AND (
21+ r.visibility = 'public'
22+ OR r.owner_user_id = $1
23+ OR EXISTS (SELECT 1 FROM repo_collaborators c
24+ WHERE c.repo_id = r.id AND c.user_id = $1)
25+ OR EXISTS (SELECT 1 FROM org_members m
26+ WHERE m.org_id = r.owner_org_id AND m.user_id = $1)
27+ OR $2
28+ )
29+"#;
30+
31+/// `/` — landing page when signed out, dashboard when signed in.
32+///
33+/// Kept dual-mode rather than redirecting: every existing link and bookmark to
34+/// `/` keeps working, and `/dashboard` below serves the same body for the
35+/// design's explicit dashboard links.
36+pub async fn index(
37+ State(state): State<AppState>,
38+ CurrentUser(user): CurrentUser,
39+ CsrfToken(csrf): CsrfToken,
40+ Nonce(nonce): Nonce,
41+) -> AppResult<Response> {
42+ let Some(user) = user else {
43+ let hint = state.config.https_clone_url("your-org", "your-repo");
44+ let feed = public_feed(&state).await?;
45+ let repos = public_repos(&state).await?;
46+ return Ok(views::page(
47+ Chrome { title: "Dogfood", user: None, csrf: &csrf, nonce: &nonce },
48+ views::pages::landing(&format!("jj git clone {hint}"), &feed, &repos),
49+ )
50+ .into_response());
51+ };
52+
53+ render_dashboard(&state, &user, &csrf, &nonce).await
54+}
55+
56+/// `/dashboard` — the signed-in dashboard, addressable on its own.
57+///
58+/// A signed-out visitor gets the landing page rather than a login redirect: the
59+/// dashboard is not a secret, it is just empty without an account, and bouncing
60+/// somebody to an SSO round trip to learn that is worse.
61+pub async fn dashboard(
62+ State(state): State<AppState>,
63+ CurrentUser(user): CurrentUser,
64+ CsrfToken(csrf): CsrfToken,
65+ Nonce(nonce): Nonce,
66+) -> AppResult<Response> {
67+ match user {
68+ Some(user) => render_dashboard(&state, &user, &csrf, &nonce).await,
69+ None => Ok(axum::response::Redirect::to("/").into_response()),
70+ }
71+}
72+
73+async fn render_dashboard(
74+ state: &AppState,
75+ user: &df_db::models::User,
76+ csrf: &str,
77+ nonce: &str,
78+) -> AppResult<Response> {
79+ let repos = visible_repos(state, user).await?;
80+ let awaiting = awaiting_review(state, user).await?;
81+ let mine = my_open_changes(state, user).await?;
82+ let activity = watched_activity(state, user).await?;
83+
84+ Ok(views::page(
85+ Chrome { title: "Dashboard", user: Some(user), csrf, nonce },
86+ views::pages::dashboard(Dashboard {
87+ user,
88+ awaiting: &awaiting,
89+ mine: &mine,
90+ activity: &activity,
91+ repos: &repos,
92+ }),
93+ )
94+ .into_response())
95+}
96+
97+// ─── queries ─────────────────────────────────────────────────────────────────
98+
99+type RepoRow = (String, String, Option<String>, bool);
100+
101+fn to_summaries(rows: Vec<RepoRow>) -> Vec<RepoSummary> {
102+ rows.into_iter()
103+ .map(|(owner, name, description, private)| RepoSummary { owner, name, description, private })
104+ .collect()
105+}
106+
107+async fn visible_repos(state: &AppState, user: &df_db::models::User) -> AppResult<Vec<RepoSummary>> {
108+ let sql = format!(
109+ r#"
110+ SELECT COALESCE(ou.handle, og.handle) AS owner,
111+ r.name::text,
112+ r.description,
113+ (r.visibility = 'private') AS private
114+ FROM repos r
115+ LEFT JOIN users ou ON ou.id = r.owner_user_id
116+ LEFT JOIN orgs og ON og.id = r.owner_org_id
117+ WHERE {VISIBLE_TO_VIEWER}
118+ ORDER BY r.pushed_at DESC NULLS LAST, r.created_at DESC
119+ LIMIT 50
120+ "#
121+ );
122+
123+ let rows: Vec<RepoRow> = sqlx::query_as(&sql)
124+ .bind(user.id)
125+ .bind(user.is_admin)
126+ .fetch_all(&state.db)
127+ .await?;
128+
129+ Ok(to_summaries(rows))
130+}
131+
132+/// Public repositories, for the signed-out landing page.
133+async fn public_repos(state: &AppState) -> AppResult<Vec<RepoSummary>> {
134+ let rows: Vec<RepoRow> = sqlx::query_as(
135+ r#"
136+ SELECT COALESCE(ou.handle, og.handle) AS owner,
137+ r.name::text,
138+ r.description,
139+ false AS private
140+ FROM repos r
141+ LEFT JOIN users ou ON ou.id = r.owner_user_id
142+ LEFT JOIN orgs og ON og.id = r.owner_org_id
143+ WHERE r.archived = false AND r.visibility = 'public'
144+ ORDER BY r.pushed_at DESC NULLS LAST, r.created_at DESC
145+ LIMIT 6
146+ "#,
147+ )
148+ .fetch_all(&state.db)
149+ .await?;
150+
151+ Ok(to_summaries(rows))
152+}
153+
154+/// The "shipping right now" feed.
155+///
156+/// Public repositories only, and no drafts: this renders for anonymous
157+/// visitors, so anything it can reach is world-readable by definition.
158+async fn public_feed(state: &AppState) -> AppResult<Vec<FeedItem>> {
159+ let rows: Vec<(String, String, i64, String, bool, String, Option<String>, DateTime<Utc>)> =
160+ sqlx::query_as(
161+ r#"
162+ SELECT COALESCE(ou.handle, og.handle) AS owner,
163+ r.name::text,
164+ c.number,
165+ c.change_id,
166+ c.synthetic,
167+ c.title,
168+ au.handle AS author,
169+ c.updated_at
170+ FROM changes c
171+ JOIN repos r ON r.id = c.repo_id
172+ LEFT JOIN users ou ON ou.id = r.owner_user_id
173+ LEFT JOIN orgs og ON og.id = r.owner_org_id
174+ LEFT JOIN users au ON au.id = c.author_user_id
175+ WHERE r.archived = false
176+ AND r.visibility = 'public'
177+ AND c.state <> 'draft'
178+ ORDER BY c.updated_at DESC
179+ LIMIT 12
180+ "#,
181+ )
182+ .fetch_all(&state.db)
183+ .await?;
184+
185+ Ok(rows
186+ .into_iter()
187+ .map(
188+ |(owner, repo, number, change_id, synthetic, title, author, when)| FeedItem {
189+ owner,
190+ repo,
191+ number,
192+ change_id,
193+ synthetic,
194+ title,
195+ author,
196+ when,
197+ },
198+ )
199+ .collect())
200+}
201+
202+type ChangeRow = (
203+ String,
204+ String,
205+ i64,
206+ String,
207+ bool,
208+ String,
209+ String,
210+ bool,
211+ Option<String>,
212+ DateTime<Utc>,
213+);
214+
215+fn to_dash_changes(rows: Vec<ChangeRow>) -> Vec<DashChange> {
216+ rows.into_iter()
217+ .map(
218+ |(owner, repo, number, change_id, synthetic, title, state, conflicted, author, updated_at)| {
219+ DashChange {
220+ owner,
221+ repo,
222+ number,
223+ change_id,
224+ synthetic,
225+ title,
226+ state,
227+ conflicted,
228+ author,
229+ updated_at,
230+ }
231+ },
232+ )
233+ .collect()
234+}
235+
236+const CHANGE_COLUMNS: &str = r#"
237+ SELECT COALESCE(ou.handle, og.handle) AS owner,
238+ r.name::text,
239+ c.number,
240+ c.change_id,
241+ c.synthetic,
242+ c.title,
243+ c.state::text,
244+ c.conflicted,
245+ au.handle AS author,
246+ c.updated_at
247+ FROM changes c
248+ JOIN repos r ON r.id = c.repo_id
249+ LEFT JOIN users ou ON ou.id = r.owner_user_id
250+ LEFT JOIN orgs og ON og.id = r.owner_org_id
251+ LEFT JOIN users au ON au.id = c.author_user_id
252+"#;
253+
254+/// Open changes waiting on this viewer.
255+///
256+/// There is no "requested reviewers" table — `reviews` records verdicts that
257+/// were *given*, not ones that were asked for. So "awaiting your review" is
258+/// derived: an open change in a repo you can reach, that you did not write, and
259+/// that you have not reviewed at its current head revision. Re-reviewing is
260+/// therefore prompted whenever the author pushes again, which is the behaviour
261+/// a reviewer wants.
262+async fn awaiting_review(
263+ state: &AppState,
264+ user: &df_db::models::User,
265+) -> AppResult<Vec<DashChange>> {
266+ let sql = format!(
267+ r#"
268+ {CHANGE_COLUMNS}
269+ WHERE {VISIBLE_TO_VIEWER}
270+ AND c.state = 'open'
271+ AND c.author_user_id IS DISTINCT FROM $1
272+ AND NOT EXISTS (
273+ SELECT 1 FROM reviews rv
274+ WHERE rv.change_id_fk = c.id
275+ AND rv.reviewer_id = $1
276+ AND rv.revision_id = c.head_revision_id
277+ )
278+ ORDER BY c.updated_at DESC
279+ LIMIT 10
280+ "#
281+ );
282+
283+ let rows: Vec<ChangeRow> = sqlx::query_as(&sql)
284+ .bind(user.id)
285+ .bind(user.is_admin)
286+ .fetch_all(&state.db)
287+ .await?;
288+
289+ Ok(to_dash_changes(rows))
290+}
291+
292+async fn my_open_changes(
293+ state: &AppState,
294+ user: &df_db::models::User,
295+) -> AppResult<Vec<DashChange>> {
296+ let sql = format!(
297+ r#"
298+ {CHANGE_COLUMNS}
299+ WHERE {VISIBLE_TO_VIEWER}
300+ AND c.author_user_id = $1
301+ AND c.state IN ('open', 'draft')
302+ ORDER BY c.updated_at DESC
303+ LIMIT 10
304+ "#
305+ );
306+
307+ let rows: Vec<ChangeRow> = sqlx::query_as(&sql)
308+ .bind(user.id)
309+ .bind(user.is_admin)
310+ .fetch_all(&state.db)
311+ .await?;
312+
313+ Ok(to_dash_changes(rows))
314+}
315+
316+/// Recent events across every repository the viewer can reach.
317+async fn watched_activity(
318+ state: &AppState,
319+ user: &df_db::models::User,
320+) -> AppResult<Vec<ActivityItem>> {
321+ let sql = format!(
322+ r#"
323+ SELECT COALESCE(ou.handle, og.handle) AS owner,
324+ r.name::text,
325+ ac.handle AS actor,
326+ e.kind,
327+ ch.number,
328+ ch.title,
329+ e.created_at
330+ FROM events e
331+ JOIN repos r ON r.id = e.repo_id
332+ LEFT JOIN users ou ON ou.id = r.owner_user_id
333+ LEFT JOIN orgs og ON og.id = r.owner_org_id
334+ LEFT JOIN users ac ON ac.id = e.actor_id
335+ LEFT JOIN changes ch
336+ ON ch.id = e.subject_id AND e.subject_type = 'change'
337+ WHERE {VISIBLE_TO_VIEWER}
338+ ORDER BY e.created_at DESC
339+ LIMIT 15
340+ "#
341+ );
342+
343+ let rows: Vec<(
344+ String,
345+ String,
346+ Option<String>,
347+ String,
348+ Option<i64>,
349+ Option<String>,
350+ DateTime<Utc>,
351+ )> = sqlx::query_as(&sql)
352+ .bind(user.id)
353+ .bind(user.is_admin)
354+ .fetch_all(&state.db)
355+ .await?;
356+
357+ Ok(rows
358+ .into_iter()
359+ .map(
360+ |(owner, repo, actor, kind, change_number, change_title, when)| ActivityItem {
361+ owner,
362+ repo,
363+ actor,
364+ kind,
365+ change_number,
366+ change_title,
367+ when,
368+ },
369+ )
370+ .collect())
371+}
Acrates/df-web/src/routes/issue.rs832 lines+832−0
@@ −0,0 +1,832 @@
1+//! Issues (M5).
2+//!
3+//! Issues share the `comments` table with changes — the `one_target` CHECK on
4+//! that table is what keeps a comment from belonging to both. Sharing it means
5+//! the sanitiser, the rendering, and the comment view are the same in both
6+//! places rather than two implementations that drift.
7+
8+use axum::extract::{Path as UrlPath, Query, State};
9+use axum::response::{IntoResponse, Redirect, Response};
10+use axum::Form;
11+use df_db::ids::new_id;
12+use serde::Deserialize;
13+use uuid::Uuid;
14+
15+use crate::error::{AppError, AppResult};
16+use crate::repo_ctx::RepoContext;
17+use crate::routes::settings::urlencode;
18+use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
19+use crate::views::issue as v;
20+use crate::views::repo as rv;
21+use crate::views::review::CommentRow;
22+use crate::views::{self, Chrome};
23+
24+#[derive(Deserialize, Default)]
25+pub struct ListQuery {
26+ pub state: Option<String>,
27+ pub label: Option<String>,
28+ pub assignee: Option<String>,
29+ pub error: Option<String>,
30+}
31+
32+/// `GET /{owner}/{repo}/issues`
33+pub async fn list(
34+ State(state): State<AppState>,
35+ UrlPath((owner, name)): UrlPath<(String, String)>,
36+ Query(q): Query<ListQuery>,
37+ CurrentUser(user): CurrentUser,
38+ CsrfToken(csrf): CsrfToken,
39+ Nonce(nonce): Nonce,
40+) -> AppResult<Response> {
41+ let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
42+
43+ let state_filter = q.state.as_deref().unwrap_or("open");
44+ let label = q.label.as_deref().filter(|s| !s.is_empty());
45+ let assignee = q.assignee.as_deref().filter(|s| !s.is_empty());
46+
47+ let rows = load_list(&state, ctx.repo.id, state_filter, label, assignee).await?;
48+ let all_labels = load_labels(&state, ctx.repo.id).await?;
49+
50+ let body = maud::html! {
51+ (rv::header(&ctx, "issues"))
52+ (v::list(&ctx, &rows, v::ListFilters {
53+ state: state_filter,
54+ label,
55+ assignee,
56+ all_labels: &all_labels,
57+ }))
58+ };
59+
60+ Ok(views::page(
61+ Chrome {
62+ title: &format!("Issues · {}/{}", ctx.owner, ctx.repo.name),
63+ user: user.as_deref(),
64+ csrf: &csrf,
65+ nonce: &nonce,
66+ },
67+ body,
68+ )
69+ .into_response())
70+}
71+
72+/// `GET /{owner}/{repo}/issues/new`
73+pub async fn new_form(
74+ State(state): State<AppState>,
75+ UrlPath((owner, name)): UrlPath<(String, String)>,
76+ Query(q): Query<ListQuery>,
77+ CurrentUser(user): CurrentUser,
78+ CsrfToken(csrf): CsrfToken,
79+ Nonce(nonce): Nonce,
80+) -> AppResult<Response> {
81+ let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
82+ if user.is_none() {
83+ return Err(AppError::Unauthorized);
84+ }
85+ // Filing an issue is a comment-level action: anybody who can read a public
86+ // repository can open one.
87+ if !ctx.access.can_comment() {
88+ return Err(AppError::Forbidden);
89+ }
90+
91+ let labels = load_labels(&state, ctx.repo.id).await?;
92+
93+ let body = maud::html! {
94+ (rv::header(&ctx, "issues"))
95+ (v::new_form(&ctx, v::NewIssue { csrf: &csrf, labels: &labels, error: q.error.as_deref() }))
96+ };
97+
98+ Ok(views::page(
99+ Chrome {
100+ title: &format!("New issue · {}/{}", ctx.owner, ctx.repo.name),
101+ user: user.as_deref(),
102+ csrf: &csrf,
103+ nonce: &nonce,
104+ },
105+ body,
106+ )
107+ .into_response())
108+}
109+
110+#[derive(Deserialize)]
111+pub struct CreateIssue {
112+ pub title: String,
113+ pub body: Option<String>,
114+ /// Repeated checkbox; absent when none are ticked.
115+ #[serde(default)]
116+ pub labels: Vec<String>,
117+}
118+
119+/// `POST /{owner}/{repo}/issues`
120+pub async fn create(
121+ State(state): State<AppState>,
122+ UrlPath((owner, name)): UrlPath<(String, String)>,
123+ CurrentUser(user): CurrentUser,
124+ Form(form): Form<CreateIssue>,
125+) -> AppResult<Response> {
126+ let Some(user) = user else {
127+ return Err(AppError::Unauthorized);
128+ };
129+ let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?;
130+ if !ctx.access.can_comment() {
131+ return Err(AppError::Forbidden);
132+ }
133+
134+ let title: String = form.title.trim().chars().take(300).collect();
135+ if title.is_empty() {
136+ return Ok(Redirect::to(&format!(
137+ "{}/issues/new?error={}",
138+ ctx.base(),
139+ urlencode("An issue needs a title.")
140+ ))
141+ .into_response());
142+ }
143+
144+ let body = form.body.as_deref().unwrap_or("").trim();
145+ if body.len() > 256 * 1024 {
146+ return Ok(Redirect::to(&format!(
147+ "{}/issues/new?error={}",
148+ ctx.base(),
149+ urlencode("That description is too long.")
150+ ))
151+ .into_response());
152+ }
153+
154+ let mut tx = state.db.begin().await?;
155+
156+ // Same row-lock pattern the change counter uses, so two concurrent issues
157+ // cannot take the same number.
158+ let (number,): (i64,) = sqlx::query_as(
159+ "UPDATE repo_counters SET next_issue = next_issue + 1
160+ WHERE repo_id = $1 RETURNING next_issue - 1",
161+ )
162+ .bind(ctx.repo.id)
163+ .fetch_one(&mut *tx)
164+ .await?;
165+
166+ let issue_id = new_id();
167+ sqlx::query(
168+ "INSERT INTO issues (id, repo_id, number, title, body, author_user_id)
169+ VALUES ($1, $2, $3, $4, $5, $6)",
170+ )
171+ .bind(issue_id)
172+ .bind(ctx.repo.id)
173+ .bind(number)
174+ .bind(&title)
175+ .bind(body)
176+ .bind(user.id)
177+ .execute(&mut *tx)
178+ .await?;
179+
180+ for label in form.labels.iter().take(20) {
181+ sqlx::query(
182+ "INSERT INTO issue_labels (issue_id, label_id)
183+ SELECT $1, id FROM labels WHERE repo_id = $2 AND name = $3
184+ ON CONFLICT DO NOTHING",
185+ )
186+ .bind(issue_id)
187+ .bind(ctx.repo.id)
188+ .bind(label)
189+ .execute(&mut *tx)
190+ .await?;
191+ }
192+
193+ tx.commit().await?;
194+
195+ record_references(&state, ctx.repo.id, "issue", issue_id, body).await;
196+
197+ crate::routes::review::event(
198+ &state,
199+ ctx.repo.id,
200+ Some(user.id),
201+ "issue.opened",
202+ issue_id,
203+ serde_json::json!({ "number": number }),
204+ )
205+ .await;
206+
207+ Ok(Redirect::to(&format!("{}/issues/{number}", ctx.base())).into_response())
208+}
209+
210+/// `GET /{owner}/{repo}/issues/{number}`
211+pub async fn detail(
212+ State(state): State<AppState>,
213+ UrlPath((owner, name, number)): UrlPath<(String, String, i64)>,
214+ CurrentUser(user): CurrentUser,
215+ CsrfToken(csrf): CsrfToken,
216+ Nonce(nonce): Nonce,
217+) -> AppResult<Response> {
218+ let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
219+ let issue = load_issue(&state, ctx.repo.id, number)
220+ .await?
221+ .ok_or(AppError::NotFound)?;
222+
223+ let labels = load_issue_labels(&state, issue.id).await?;
224+ let all_labels = load_labels(&state, ctx.repo.id).await?;
225+ let assignees = load_assignees(&state, issue.id).await?;
226+ let comments = load_comments(&state, issue.id, &ctx).await?;
227+ let referenced_by = load_referenced_by(&state, ctx.repo.id, "issue", issue.id).await?;
228+
229+ let is_author = matches!((&user, &issue.author), (Some(u), Some(a)) if &u.handle == a);
230+ let can_manage = ctx.access.can_manage_changes() || is_author;
231+
232+ let body_html = render(&ctx, &issue.body);
233+
234+ let body = maud::html! {
235+ (rv::header(&ctx, "issues"))
236+ (v::detail(&ctx, v::Detail {
237+ number: issue.number,
238+ title: &issue.title,
239+ body_html: &body_html,
240+ state: &issue.state,
241+ author: issue.author.as_deref(),
242+ created_at: issue.created_at,
243+ labels: &labels,
244+ all_labels: &all_labels,
245+ assignees: &assignees,
246+ comments: &comments,
247+ referenced_by: &referenced_by,
248+ can_comment: user.is_some() && ctx.access.can_comment(),
249+ can_manage,
250+ csrf: &csrf,
251+ }))
252+ };
253+
254+ Ok(views::page(
255+ Chrome {
256+ title: &format!("{} · {}/{}", issue.title, ctx.owner, ctx.repo.name),
257+ user: user.as_deref(),
258+ csrf: &csrf,
259+ nonce: &nonce,
260+ },
261+ body,
262+ )
263+ .into_response())
264+}
265+
266+#[derive(Deserialize)]
267+pub struct IssueComment {
268+ pub body: String,
269+ /// Set by the "comment and close" button.
270+ pub state: Option<String>,
271+}
272+
273+/// `POST /{owner}/{repo}/issues/{number}/comments`
274+pub async fn comment(
275+ State(state): State<AppState>,
276+ UrlPath((owner, name, number)): UrlPath<(String, String, i64)>,
277+ CurrentUser(user): CurrentUser,
278+ Form(form): Form<IssueComment>,
279+) -> AppResult<Response> {
280+ let Some(user) = user else {
281+ return Err(AppError::Unauthorized);
282+ };
283+ let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?;
284+ if !ctx.access.can_comment() {
285+ return Err(AppError::Forbidden);
286+ }
287+ let issue = load_issue(&state, ctx.repo.id, number)
288+ .await?
289+ .ok_or(AppError::NotFound)?;
290+
291+ let body = form.body.trim();
292+ if !body.is_empty() && body.len() <= 64 * 1024 {
293+ let comment_id = new_id();
294+ sqlx::query(
295+ "INSERT INTO comments (id, repo_id, issue_id, author_user_id, body)
296+ VALUES ($1, $2, $3, $4, $5)",
297+ )
298+ .bind(comment_id)
299+ .bind(ctx.repo.id)
300+ .bind(issue.id)
301+ .bind(user.id)
302+ .execute(&state.db)
303+ .await
304+ .map(|_| ())
305+ .or_else(|e| {
306+ tracing::error!("inserting an issue comment failed: {e}");
307+ Err(e)
308+ })?;
309+
310+ record_references(&state, ctx.repo.id, "comment", comment_id, body).await;
311+ }
312+
313+ // "Comment and close" is one button and must be one action; two round trips
314+ // would let the comment land and the close fail.
315+ if let Some(next) = form.state.as_deref() {
316+ let is_author = issue.author.as_deref() == Some(user.handle.as_str());
317+ if (ctx.access.can_manage_changes() || is_author)
318+ && matches!(next, "open" | "closed")
319+ {
320+ set_issue_state(&state, &ctx, &issue, next, user.id).await?;
321+ }
322+ }
323+
324+ sqlx::query("UPDATE issues SET updated_at = now() WHERE id = $1")
325+ .bind(issue.id)
326+ .execute(&state.db)
327+ .await?;
328+
329+ Ok(Redirect::to(&format!("{}/issues/{number}", ctx.base())).into_response())
330+}
331+
332+#[derive(Deserialize)]
333+pub struct SetState {
334+ pub state: String,
335+}
336+
337+/// `POST /{owner}/{repo}/issues/{number}/state`
338+pub async fn set_state(
339+ State(state): State<AppState>,
340+ UrlPath((owner, name, number)): UrlPath<(String, String, i64)>,
341+ CurrentUser(user): CurrentUser,
342+ Form(form): Form<SetState>,
343+) -> AppResult<Response> {
344+ let Some(user) = user else {
345+ return Err(AppError::Unauthorized);
346+ };
347+ let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?;
348+ let issue = load_issue(&state, ctx.repo.id, number)
349+ .await?
350+ .ok_or(AppError::NotFound)?;
351+
352+ let is_author = issue.author.as_deref() == Some(user.handle.as_str());
353+ if !ctx.access.can_manage_changes() && !is_author {
354+ return Err(AppError::Forbidden);
355+ }
356+ if !matches!(form.state.as_str(), "open" | "closed") {
357+ return Err(AppError::BadRequest("unknown state".into()));
358+ }
359+
360+ set_issue_state(&state, &ctx, &issue, &form.state, user.id).await?;
361+
362+ Ok(Redirect::to(&format!("{}/issues/{number}", ctx.base())).into_response())
363+}
364+
365+#[derive(Deserialize)]
366+pub struct SetLabels {
367+ #[serde(default)]
368+ pub labels: Vec<String>,
369+}
370+
371+/// `POST /{owner}/{repo}/issues/{number}/labels`
372+pub async fn set_labels(
373+ State(state): State<AppState>,
374+ UrlPath((owner, name, number)): UrlPath<(String, String, i64)>,
375+ CurrentUser(user): CurrentUser,
376+ Form(form): Form<SetLabels>,
377+) -> AppResult<Response> {
378+ let Some(user) = user else {
379+ return Err(AppError::Unauthorized);
380+ };
381+ let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?;
382+ if !ctx.access.can_manage_changes() {
383+ return Err(AppError::Forbidden);
384+ }
385+ let issue = load_issue(&state, ctx.repo.id, number)
386+ .await?
387+ .ok_or(AppError::NotFound)?;
388+
389+ let mut tx = state.db.begin().await?;
390+ sqlx::query("DELETE FROM issue_labels WHERE issue_id = $1")
391+ .bind(issue.id)
392+ .execute(&mut *tx)
393+ .await?;
394+
395+ for label in form.labels.iter().take(20) {
396+ sqlx::query(
397+ "INSERT INTO issue_labels (issue_id, label_id)
398+ SELECT $1, id FROM labels WHERE repo_id = $2 AND name = $3
399+ ON CONFLICT DO NOTHING",
400+ )
401+ .bind(issue.id)
402+ .bind(ctx.repo.id)
403+ .bind(label)
404+ .execute(&mut *tx)
405+ .await?;
406+ }
407+ tx.commit().await?;
408+
409+ Ok(Redirect::to(&format!("{}/issues/{number}", ctx.base())).into_response())
410+}
411+
412+#[derive(Deserialize)]
413+pub struct SetAssignees {
414+ pub assignees: String,
415+}
416+
417+/// `POST /{owner}/{repo}/issues/{number}/assignees`
418+pub async fn set_assignees(
419+ State(state): State<AppState>,
420+ UrlPath((owner, name, number)): UrlPath<(String, String, i64)>,
421+ CurrentUser(user): CurrentUser,
422+ Form(form): Form<SetAssignees>,
423+) -> AppResult<Response> {
424+ let Some(user) = user else {
425+ return Err(AppError::Unauthorized);
426+ };
427+ let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?;
428+ if !ctx.access.can_manage_changes() {
429+ return Err(AppError::Forbidden);
430+ }
431+ let issue = load_issue(&state, ctx.repo.id, number)
432+ .await?
433+ .ok_or(AppError::NotFound)?;
434+
435+ let handles: Vec<String> = form
436+ .assignees
437+ .split(',')
438+ .map(|s| s.trim().trim_start_matches('@').to_lowercase())
439+ .filter(|s| !s.is_empty())
440+ .take(20)
441+ .collect();
442+
443+ let mut tx = state.db.begin().await?;
444+ sqlx::query("DELETE FROM issue_assignees WHERE issue_id = $1")
445+ .bind(issue.id)
446+ .execute(&mut *tx)
447+ .await?;
448+
449+ for handle in &handles {
450+ // Only users who can actually read the repository. Assigning somebody
451+ // to an issue they cannot open would be a way to learn that a private
452+ // repository exists.
453+ sqlx::query(
454+ "INSERT INTO issue_assignees (issue_id, user_id)
455+ SELECT $1, u.id FROM users u
456+ WHERE u.handle = $2
457+ AND ($3 = 'public'
458+ OR u.is_admin
459+ OR u.id = $4
460+ OR EXISTS (SELECT 1 FROM repo_collaborators c
461+ WHERE c.repo_id = $5 AND c.user_id = u.id)
462+ OR EXISTS (SELECT 1 FROM org_members m
463+ WHERE m.org_id = $6 AND m.user_id = u.id))
464+ ON CONFLICT DO NOTHING",
465+ )
466+ .bind(issue.id)
467+ .bind(handle)
468+ .bind(if ctx.repo.is_public() { "public" } else { "private" })
469+ .bind(ctx.repo.owner_user_id)
470+ .bind(ctx.repo.id)
471+ .bind(ctx.repo.owner_org_id)
472+ .execute(&mut *tx)
473+ .await?;
474+ }
475+ tx.commit().await?;
476+
477+ Ok(Redirect::to(&format!("{}/issues/{number}", ctx.base())).into_response())
478+}
479+
480+// ─── loading ─────────────────────────────────────────────────────────────────
481+
482+pub struct Issue {
483+ pub id: Uuid,
484+ pub number: i64,
485+ pub title: String,
486+ pub body: String,
487+ pub state: String,
488+ pub author: Option<String>,
489+ pub created_at: chrono::DateTime<chrono::Utc>,
490+}
491+
492+async fn load_issue(state: &AppState, repo_id: Uuid, number: i64) -> AppResult<Option<Issue>> {
493+ let row: Option<(
494+ Uuid,
495+ i64,
496+ String,
497+ String,
498+ String,
499+ Option<String>,
500+ chrono::DateTime<chrono::Utc>,
501+ )> = sqlx::query_as(
502+ "SELECT i.id, i.number, i.title, i.body, i.state::text, u.handle::text, i.created_at
503+ FROM issues i LEFT JOIN users u ON u.id = i.author_user_id
504+ WHERE i.repo_id = $1 AND i.number = $2",
505+ )
506+ .bind(repo_id)
507+ .bind(number)
508+ .fetch_optional(&state.db)
509+ .await?;
510+
511+ Ok(row.map(
512+ |(id, number, title, body, st, author, created_at)| Issue {
513+ id,
514+ number,
515+ title,
516+ body,
517+ state: st,
518+ author,
519+ created_at,
520+ },
521+ ))
522+}
523+
524+async fn load_list(
525+ state: &AppState,
526+ repo_id: Uuid,
527+ state_filter: &str,
528+ label: Option<&str>,
529+ assignee: Option<&str>,
530+) -> AppResult<Vec<v::IssueRow>> {
531+ let rows: Vec<(i64, String, String, Option<String>, chrono::DateTime<chrono::Utc>, i64)> =
532+ sqlx::query_as(
533+ r#"
534+ SELECT i.number, i.title, i.state::text, u.handle::text, i.updated_at,
535+ (SELECT count(*) FROM comments c WHERE c.issue_id = i.id) AS comments
536+ FROM issues i
537+ LEFT JOIN users u ON u.id = i.author_user_id
538+ WHERE i.repo_id = $1
539+ AND ($2 = 'all' OR i.state::text = $2)
540+ AND ($3::text IS NULL OR EXISTS (
541+ SELECT 1 FROM issue_labels il JOIN labels l ON l.id = il.label_id
542+ WHERE il.issue_id = i.id AND l.name = $3))
543+ AND ($4::text IS NULL OR EXISTS (
544+ SELECT 1 FROM issue_assignees ia JOIN users au ON au.id = ia.user_id
545+ WHERE ia.issue_id = i.id AND au.handle = $4))
546+ ORDER BY i.updated_at DESC
547+ LIMIT 100
548+ "#,
549+ )
550+ .bind(repo_id)
551+ .bind(state_filter)
552+ .bind(label)
553+ .bind(assignee)
554+ .fetch_all(&state.db)
555+ .await?;
556+
557+ // Labels and assignees are loaded per row rather than aggregated in the
558+ // query above: the list is capped at 100, and two small extra queries per
559+ // row read far better than three levels of array_agg.
560+ let mut out = Vec::with_capacity(rows.len());
561+ for (number, title, st, author, updated_at, comment_count) in rows {
562+ let id: Uuid =
563+ sqlx::query_scalar("SELECT id FROM issues WHERE repo_id = $1 AND number = $2")
564+ .bind(repo_id)
565+ .bind(number)
566+ .fetch_one(&state.db)
567+ .await?;
568+
569+ out.push(v::IssueRow {
570+ number,
571+ title,
572+ state: st,
573+ author,
574+ updated_at,
575+ comment_count,
576+ labels: load_issue_labels(state, id).await?,
577+ assignees: load_assignees(state, id).await?,
578+ });
579+ }
580+ Ok(out)
581+}
582+
583+async fn load_labels(state: &AppState, repo_id: Uuid) -> AppResult<Vec<v::Label>> {
584+ let rows: Vec<(String, String)> =
585+ sqlx::query_as("SELECT name, color FROM labels WHERE repo_id = $1 ORDER BY name")
586+ .bind(repo_id)
587+ .fetch_all(&state.db)
588+ .await?;
589+ Ok(rows
590+ .into_iter()
591+ .map(|(name, color)| v::Label { name, color })
592+ .collect())
593+}
594+
595+async fn load_issue_labels(state: &AppState, issue_id: Uuid) -> AppResult<Vec<v::Label>> {
596+ let rows: Vec<(String, String)> = sqlx::query_as(
597+ "SELECT l.name, l.color FROM labels l
598+ JOIN issue_labels il ON il.label_id = l.id
599+ WHERE il.issue_id = $1 ORDER BY l.name",
600+ )
601+ .bind(issue_id)
602+ .fetch_all(&state.db)
603+ .await?;
604+ Ok(rows
605+ .into_iter()
606+ .map(|(name, color)| v::Label { name, color })
607+ .collect())
608+}
609+
610+async fn load_assignees(state: &AppState, issue_id: Uuid) -> AppResult<Vec<String>> {
611+ Ok(sqlx::query_scalar(
612+ "SELECT u.handle::text FROM users u
613+ JOIN issue_assignees a ON a.user_id = u.id
614+ WHERE a.issue_id = $1 ORDER BY u.handle",
615+ )
616+ .bind(issue_id)
617+ .fetch_all(&state.db)
618+ .await?)
619+}
620+
621+async fn load_comments(
622+ state: &AppState,
623+ issue_id: Uuid,
624+ ctx: &RepoContext,
625+) -> AppResult<Vec<CommentRow>> {
626+ let rows: Vec<(
627+ Uuid,
628+ String,
629+ String,
630+ chrono::DateTime<chrono::Utc>,
631+ Option<chrono::DateTime<chrono::Utc>>,
632+ )> = sqlx::query_as(
633+ "SELECT c.id, u.handle::text, c.body, c.created_at, c.edited_at
634+ FROM comments c JOIN users u ON u.id = c.author_user_id
635+ WHERE c.issue_id = $1 ORDER BY c.created_at",
636+ )
637+ .bind(issue_id)
638+ .fetch_all(&state.db)
639+ .await?;
640+
641+ Ok(rows
642+ .into_iter()
643+ .map(|(id, author, body, created_at, edited_at)| CommentRow {
644+ id,
645+ author,
646+ body_html: render(ctx, &body),
647+ created_at,
648+ edited: edited_at.is_some(),
649+ anchor_path: None,
650+ anchor_line: None,
651+ anchor_side: None,
652+ anchor_state: "current".into(),
653+ anchor_context: None,
654+ resolved: false,
655+ })
656+ .collect())
657+}
658+
659+async fn load_referenced_by(
660+ state: &AppState,
661+ repo_id: Uuid,
662+ target_type: &str,
663+ target_id: Uuid,
664+) -> AppResult<Vec<(String, i64, String)>> {
665+ Ok(sqlx::query_as(
666+ r#"
667+ SELECT 'change'::text, c.number, c.title
668+ FROM cross_references x JOIN changes c ON c.id = x.source_id
669+ WHERE x.repo_id = $1 AND x.target_type = $2 AND x.target_id = $3
670+ AND x.source_type = 'change'
671+ UNION ALL
672+ SELECT 'issue'::text, i.number, i.title
673+ FROM cross_references x JOIN issues i ON i.id = x.source_id
674+ WHERE x.repo_id = $1 AND x.target_type = $2 AND x.target_id = $3
675+ AND x.source_type = 'issue'
676+ LIMIT 50
677+ "#,
678+ )
679+ .bind(repo_id)
680+ .bind(target_type)
681+ .bind(target_id)
682+ .fetch_all(&state.db)
683+ .await?)
684+}
685+
686+// ─── helpers ─────────────────────────────────────────────────────────────────
687+
688+/// Render markdown and then resolve cross-references, in that order.
689+///
690+/// Autolinking after rendering is what keeps `#123` inside a code fence a
691+/// literal (spec §8, and `df_render::autolink`'s own tests).
692+pub fn render(ctx: &RepoContext, source: &str) -> String {
693+ let html = df_render::comment_to_html(source);
694+ df_render::autolink::autolink(
695+ &html,
696+ &df_render::autolink::LinkContext { repo_base: &ctx.base() },
697+ )
698+}
699+
700+async fn set_issue_state(
701+ state: &AppState,
702+ ctx: &RepoContext,
703+ issue: &Issue,
704+ next: &str,
705+ actor: Uuid,
706+) -> AppResult<()> {
707+ sqlx::query(
708+ "UPDATE issues
709+ SET state = $2::issue_state,
710+ closed_at = CASE WHEN $2 = 'closed' THEN now() ELSE NULL END,
711+ updated_at = now()
712+ WHERE id = $1",
713+ )
714+ .bind(issue.id)
715+ .bind(next)
716+ .execute(&state.db)
717+ .await?;
718+
719+ crate::routes::review::event(
720+ state,
721+ ctx.repo.id,
722+ Some(actor),
723+ if next == "closed" { "issue.closed" } else { "issue.reopened" },
724+ issue.id,
725+ serde_json::json!({ "number": issue.number }),
726+ )
727+ .await;
728+
729+ Ok(())
730+}
731+
732+/// Extract `#123` references from a body and record them.
733+///
734+/// Best-effort and idempotent: the unique constraint makes a re-run a no-op,
735+/// and a failure loses a cross-reference rather than the text that contained it.
736+pub async fn record_references(
737+ state: &AppState,
738+ repo_id: Uuid,
739+ source_type: &str,
740+ source_id: Uuid,
741+ body: &str,
742+) {
743+ for number in issue_references(body) {
744+ let target: Result<Option<Uuid>, _> =
745+ sqlx::query_scalar("SELECT id FROM issues WHERE repo_id = $1 AND number = $2")
746+ .bind(repo_id)
747+ .bind(number)
748+ .fetch_optional(&state.db)
749+ .await;
750+
751+ let Ok(Some(target_id)) = target else { continue };
752+ if target_id == source_id {
753+ continue;
754+ }
755+
756+ let _ = sqlx::query(
757+ "INSERT INTO cross_references
758+ (id, repo_id, source_type, source_id, target_type, target_id)
759+ VALUES ($1, $2, $3, $4, 'issue', $5)
760+ ON CONFLICT DO NOTHING",
761+ )
762+ .bind(new_id())
763+ .bind(repo_id)
764+ .bind(source_type)
765+ .bind(source_id)
766+ .bind(target_id)
767+ .execute(&state.db)
768+ .await;
769+ }
770+}
771+
772+/// Issue numbers referenced in a body, at word boundaries.
773+///
774+/// Deliberately simpler than the renderer's autolinker: this feeds a database
775+/// lookup, so a false positive costs a wasted query and a false negative costs
776+/// a missing back-reference. Neither is a correctness problem, which is why it
777+/// does not need to know about code fences.
778+fn issue_references(body: &str) -> Vec<i64> {
779+ let b = body.as_bytes();
780+ let mut out = Vec::new();
781+ let mut i = 0;
782+
783+ while i < b.len() {
784+ if b[i] == b'#' && (i == 0 || !b[i - 1].is_ascii_alphanumeric()) {
785+ let mut j = i + 1;
786+ while j < b.len() && b[j].is_ascii_digit() {
787+ j += 1;
788+ }
789+ if j > i + 1 && j - i - 1 <= 9 {
790+ if let Ok(n) = body[i + 1..j].parse::<i64>() {
791+ if !out.contains(&n) {
792+ out.push(n);
793+ }
794+ }
795+ }
796+ i = j;
797+ continue;
798+ }
799+ i += 1;
800+ }
801+
802+ out.truncate(50);
803+ out
804+}
805+
806+#[cfg(test)]
807+mod tests {
808+ use super::issue_references;
809+
810+ #[test]
811+ fn finds_issue_references() {
812+ assert_eq!(issue_references("fixes #12 and #7"), vec![12, 7]);
813+ assert_eq!(issue_references("no references here"), Vec::<i64>::new());
814+ }
815+
816+ #[test]
817+ fn ignores_mid_word_hashes_and_duplicates() {
818+ assert_eq!(issue_references("abc#12"), Vec::<i64>::new());
819+ assert_eq!(issue_references("#3 and #3 again"), vec![3]);
820+ }
821+
822+ #[test]
823+ fn absurd_numbers_are_ignored() {
824+ assert_eq!(issue_references("#12345678901"), Vec::<i64>::new());
825+ }
826+
827+ #[test]
828+ fn a_body_full_of_references_is_capped() {
829+ let body: String = (1..200).map(|n| format!("#{n} ")).collect();
830+ assert_eq!(issue_references(&body).len(), 50);
831+ }
832+}
Acrates/df-web/src/routes/metrics.rs+157−0
@@ −0,0 +1,157 @@
1+//! `GET /metrics` — Prometheus exposition (spec §7, §10).
2+//!
3+//! > `GET /metrics` — **bind to loopback only**.
4+//!
5+//! Dogfood serves one port, so "bind to loopback only" is enforced per request
6+//! rather than per socket: the peer address must be a loopback address, or the
7+//! response is the same 404 any unknown path gets. A 403 would confirm the
8+//! endpoint exists, and the metrics say how many private repositories there are.
9+//!
10+//! The peer address comes from `ConnectInfo` — the real TCP peer — and never
11+//! from `X-Forwarded-For`, which the client controls. Behind the edge proxy that
12+//! means metrics are unreachable from outside the host, which is the point;
13+//! scrape it with `docker exec` or over the compose network.
14+//!
15+//! The metric set is the one §10 says to alert on: job queue depth, indexer
16+//! failures, and push latency. Everything else here is cheap context.
17+
18+use axum::extract::State;
19+use axum::response::{IntoResponse, Response};
20+
21+use crate::error::{AppError, AppResult};
22+use crate::state::{AppState, PeerAddr};
23+
24+pub async fn metrics(State(state): State<AppState>, peer: PeerAddr) -> AppResult<Response> {
25+ // A peer that cannot be proven local is treated exactly like a remote one.
26+ // Defaulting the other way would serve metrics to anybody the moment the
27+ // server was reconfigured without connect info.
28+ if !peer.is_loopback() {
29+ tracing::debug!(peer = ?peer.ip(), "refusing /metrics: not a loopback peer");
30+ return Err(AppError::NotFound);
31+ }
32+
33+ let mut out = String::with_capacity(2048);
34+
35+ // ── the three §10 alerting signals ───────────────────────────────────────
36+
37+ let (ready, failed, oldest): (i64, i64, Option<f64>) = sqlx::query_as(
38+ "SELECT
39+ count(*) FILTER (WHERE locked_at IS NULL AND attempts < max_attempts),
40+ count(*) FILTER (WHERE attempts >= max_attempts),
41+ EXTRACT(EPOCH FROM (now() - min(run_at)))::float8
42+ FILTER (WHERE locked_at IS NULL)
43+ FROM jobs",
44+ )
45+ .fetch_one(&state.db)
46+ .await
47+ .unwrap_or((0, 0, None));
48+
49+ gauge(&mut out, "dogfood_jobs_queued", "Jobs waiting to run.", ready as f64);
50+ gauge(
51+ &mut out,
52+ "dogfood_jobs_failed",
53+ "Jobs that exhausted their retries. Alert on any increase.",
54+ failed as f64,
55+ );
56+ gauge(
57+ &mut out,
58+ "dogfood_jobs_oldest_seconds",
59+ "Age of the oldest queued job. The queue-depth alert that actually matters.",
60+ oldest.unwrap_or(0.0),
61+ );
62+
63+ // Push latency, as the lag between a push landing and its index job being
64+ // picked up. Measured from the rows rather than instrumented in-process so
65+ // it survives a restart of either service.
66+ let push_lag: Option<f64> = sqlx::query_scalar(
67+ "SELECT EXTRACT(EPOCH FROM (now() - max(pushed_at)))::float8 FROM revisions",
68+ )
69+ .fetch_one(&state.db)
70+ .await
71+ .unwrap_or(None);
72+ gauge(
73+ &mut out,
74+ "dogfood_seconds_since_last_push",
75+ "Seconds since the most recent indexed revision.",
76+ push_lag.unwrap_or(-1.0),
77+ );
78+
79+ // ── inventory ────────────────────────────────────────────────────────────
80+
81+ for (metric, help, sql) in [
82+ ("dogfood_users", "Provisioned users.", "SELECT count(*) FROM users"),
83+ ("dogfood_repos", "Repositories.", "SELECT count(*) FROM repos"),
84+ (
85+ "dogfood_repos_private",
86+ "Private repositories.",
87+ "SELECT count(*) FROM repos WHERE visibility = 'private'",
88+ ),
89+ ("dogfood_changes", "Changes indexed.", "SELECT count(*) FROM changes"),
90+ (
91+ "dogfood_changes_conflicted",
92+ "Changes whose head revision is conflicted.",
93+ "SELECT count(*) FROM changes WHERE conflicted",
94+ ),
95+ ("dogfood_revisions", "Revisions indexed.", "SELECT count(*) FROM revisions"),
96+ ("dogfood_issues_open", "Open issues.", "SELECT count(*) FROM issues WHERE state = 'open'"),
97+ (
98+ "dogfood_comments_orphaned",
99+ "Inline comments whose anchor no longer exists.",
100+ "SELECT count(*) FROM comments WHERE anchor_state = 'orphaned'",
101+ ),
102+ (
103+ "dogfood_highlight_cache_entries",
104+ "Cached syntax-highlighted blobs.",
105+ "SELECT count(*) FROM highlight_cache",
106+ ),
107+ ] {
108+ let n: i64 = sqlx::query_scalar(sql).fetch_one(&state.db).await.unwrap_or(-1);
109+ gauge(&mut out, metric, help, n as f64);
110+ }
111+
112+ // Reachability of the database is already the substance of every query
113+ // above; a separate probe would only be able to disagree with them.
114+ gauge(&mut out, "dogfood_up", "Always 1 when the process is serving.", 1.0);
115+
116+ Ok((
117+ [(
118+ axum::http::header::CONTENT_TYPE,
119+ "text/plain; version=0.0.4; charset=utf-8",
120+ )],
121+ out,
122+ )
123+ .into_response())
124+}
125+
126+fn gauge(out: &mut String, name: &str, help: &str, value: f64) {
127+ use std::fmt::Write as _;
128+ let _ = writeln!(out, "# HELP {name} {help}");
129+ let _ = writeln!(out, "# TYPE {name} gauge");
130+ let _ = writeln!(out, "{name} {value}");
131+}
132+
133+#[cfg(test)]
134+mod tests {
135+ use super::gauge;
136+
137+ #[test]
138+ fn exposition_format_is_well_formed() {
139+ let mut s = String::new();
140+ gauge(&mut s, "dogfood_x", "A thing.", 3.0);
141+ assert_eq!(
142+ s,
143+ "# HELP dogfood_x A thing.\n# TYPE dogfood_x gauge\ndogfood_x 3\n"
144+ );
145+ }
146+
147+ #[test]
148+ fn loopback_detection_covers_both_families() {
149+ use std::net::IpAddr;
150+ assert!("127.0.0.1".parse::<IpAddr>().unwrap().is_loopback());
151+ assert!("::1".parse::<IpAddr>().unwrap().is_loopback());
152+ // The container's own bridge address is *not* loopback, which is what
153+ // keeps /metrics off the compose network by default.
154+ assert!(!"172.17.0.2".parse::<IpAddr>().unwrap().is_loopback());
155+ assert!(!"10.0.0.1".parse::<IpAddr>().unwrap().is_loopback());
156+ }
157+}
Acrates/df-web/src/routes/mod.rs+18−0
@@ −0,0 +1,18 @@
1+//! HTTP routes (spec §7).
2+
3+pub mod auth;
4+pub mod change;
5+pub mod design;
6+pub mod health;
7+pub mod home;
8+pub mod edit;
9+pub mod issue;
10+pub mod metrics;
11+pub mod org;
12+pub mod profile;
13+pub mod repo;
14+pub mod repo_settings;
15+pub mod review;
16+pub mod search;
17+pub mod settings;
18+pub mod setup;
Acrates/df-web/src/routes/org.rs450 lines+450−0
@@ −0,0 +1,450 @@
1+//! Organizations and membership (M5).
2+//!
3+//! Orgs share the handle namespace with users — enforced by a trigger in the
4+//! schema, not here — so `/{handle}` stays unambiguous and an org cannot be
5+//! created that shadows a person.
6+//!
7+//! Org membership grants repository access through `permissions::resolve`:
8+//! admins get repo `admin`, members get `read`. Nothing in this module makes a
9+//! permission decision of its own.
10+
11+use axum::extract::{Path as UrlPath, Query, State};
12+use axum::response::{IntoResponse, Redirect, Response};
13+use axum::Form;
14+use df_db::ids::new_id;
15+use df_db::models::OrgRole;
16+use serde::Deserialize;
17+use uuid::Uuid;
18+
19+use crate::error::{AppError, AppResult};
20+use crate::routes::settings::{audit, urlencode};
21+use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
22+use crate::views::{self, Chrome};
23+
24+#[derive(Deserialize, Default)]
25+pub struct Flash {
26+ pub error: Option<String>,
27+ pub notice: Option<String>,
28+}
29+
30+/// `GET /orgs/new`
31+pub async fn new_form(
32+ Query(flash): Query<Flash>,
33+ CurrentUser(user): CurrentUser,
34+ CsrfToken(csrf): CsrfToken,
35+ Nonce(nonce): Nonce,
36+) -> AppResult<Response> {
37+ let Some(user) = user else {
38+ return Err(AppError::Unauthorized);
39+ };
40+
41+ Ok(views::page(
42+ Chrome { title: "New organization", user: Some(&user), csrf: &csrf, nonce: &nonce },
43+ maud::html! {
44+ div .panel {
45+ h1 { "New organization" }
46+ p .lede {
47+ "An organization owns repositories on behalf of a group. You become \
48+ its first administrator."
49+ }
50+ @if let Some(e) = &flash.error { div .banner.banner-error role="alert" { (e) } }
51+
52+ form method="post" action="/orgs" .stack {
53+ input type="hidden" name="_csrf" value=(csrf);
54+ div .field {
55+ label for="handle" { "Handle" }
56+ input type="text" id="handle" name="handle" required
57+ pattern="[a-z0-9][a-z0-9-]*" maxlength="39" autofocus;
58+ p .hint {
59+ "Appears in URLs, like " code { "dogfood.sh/your-org/repo" } ". \
60+ Shares a namespace with user handles."
61+ }
62+ }
63+ div .field {
64+ label for="display_name" { "Display name" }
65+ input type="text" id="display_name" name="display_name" maxlength="100";
66+ }
67+ div .field {
68+ label for="description" { "Description" }
69+ input type="text" id="description" name="description" maxlength="500";
70+ }
71+ button .btn.btn-primary type="submit" { "Create organization" }
72+ }
73+ }
74+ },
75+ )
76+ .into_response())
77+}
78+
79+#[derive(Deserialize)]
80+pub struct CreateOrg {
81+ pub handle: String,
82+ pub display_name: Option<String>,
83+ pub description: Option<String>,
84+}
85+
86+/// `POST /orgs`
87+pub async fn create(
88+ State(state): State<AppState>,
89+ CurrentUser(user): CurrentUser,
90+ Form(form): Form<CreateOrg>,
91+) -> AppResult<Response> {
92+ let Some(user) = user else {
93+ return Err(AppError::Unauthorized);
94+ };
95+
96+ let handle = form.handle.trim().to_lowercase();
97+ if !valid_handle(&handle) {
98+ return Ok(reject("Handles are lowercase letters, digits and hyphens, up to 39 characters."));
99+ }
100+
101+ // Reserved handles are top-level routes; one held by an org would shadow
102+ // them (spec §7 puts /settings, /new and /search at the root).
103+ let reserved: bool =
104+ sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM reserved_handles WHERE handle = $1)")
105+ .bind(&handle)
106+ .fetch_one(&state.db)
107+ .await?;
108+ if reserved {
109+ return Ok(reject("That handle is reserved."));
110+ }
111+
112+ let org_id = new_id();
113+ let mut tx = state.db.begin().await?;
114+
115+ // The unique constraint and the namespace trigger both surface as a unique
116+ // violation; either way the handle is taken, and saying which would leak
117+ // whether a private account exists.
118+ let inserted = sqlx::query(
119+ "INSERT INTO orgs (id, handle, display_name, description)
120+ VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
121+ )
122+ .bind(org_id)
123+ .bind(&handle)
124+ .bind(form.display_name.as_deref().map(str::trim).filter(|s| !s.is_empty()))
125+ .bind(form.description.as_deref().map(str::trim).filter(|s| !s.is_empty()))
126+ .execute(&mut *tx)
127+ .await;
128+
129+ match inserted {
130+ Ok(r) if r.rows_affected() == 0 => return Ok(reject("That handle is taken.")),
131+ Err(e) if is_unique_violation(&e) => return Ok(reject("That handle is taken.")),
132+ Err(e) => return Err(e.into()),
133+ Ok(_) => {}
134+ }
135+
136+ sqlx::query("INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, 'admin')")
137+ .bind(org_id)
138+ .bind(user.id)
139+ .execute(&mut *tx)
140+ .await?;
141+
142+ tx.commit().await?;
143+
144+ audit(&state, user.id, "org.created", &handle).await;
145+
146+ Ok(Redirect::to(&format!("/{handle}")).into_response())
147+}
148+
149+/// `GET /{handle}/-/members` — org membership management.
150+///
151+/// Under `/-/` rather than `/{handle}/members` so it cannot collide with a
152+/// repository called `members`.
153+pub async fn members(
154+ State(state): State<AppState>,
155+ UrlPath(handle): UrlPath<String>,
156+ Query(flash): Query<Flash>,
157+ CurrentUser(user): CurrentUser,
158+ CsrfToken(csrf): CsrfToken,
159+ Nonce(nonce): Nonce,
160+) -> AppResult<Response> {
161+ let Some(user) = user else {
162+ return Err(AppError::Unauthorized);
163+ };
164+
165+ let org = load_org(&state, &handle).await?.ok_or(AppError::NotFound)?;
166+ let role = member_role(&state, org.id, user.id).await?;
167+
168+ // Not a member: the same 404 a nonexistent org gets. Membership of an org
169+ // is not public information here.
170+ if role.is_none() && !user.is_admin {
171+ return Err(AppError::NotFound);
172+ }
173+ let is_admin = user.is_admin || role == Some(OrgRole::Admin);
174+
175+ let rows: Vec<(String, OrgRole)> = sqlx::query_as(
176+ "SELECT u.handle::text, m.role FROM org_members m JOIN users u ON u.id = m.user_id
177+ WHERE m.org_id = $1 ORDER BY m.role DESC, u.handle",
178+ )
179+ .bind(org.id)
180+ .fetch_all(&state.db)
181+ .await?;
182+
183+ Ok(views::page(
184+ Chrome {
185+ title: &format!("{handle} members"),
186+ user: Some(&user),
187+ csrf: &csrf,
188+ nonce: &nonce,
189+ },
190+ maud::html! {
191+ div .panel {
192+ h1 { (org.display_name.as_deref().unwrap_or(&org.handle)) }
193+ p .dim { "@" (org.handle) " · organization" }
194+ p { a href=(format!("/{handle}")) { "Back to the profile" } }
195+ }
196+
197+ @if let Some(e) = &flash.error { div .banner.banner-error role="alert" { (e) } }
198+ @if let Some(n) = &flash.notice { div .banner.banner-ok role="status" { (n) } }
199+
200+ div .panel {
201+ h2 { "Members" }
202+ table .listing {
203+ thead { tr { th scope="col" { "User" } th scope="col" { "Role" } th scope="col" {} } }
204+ tbody {
205+ @for (h, r) in &rows {
206+ tr {
207+ td { a href=(format!("/{h}")) { (h) } }
208+ td { (role_str(*r)) }
209+ td {
210+ @if is_admin {
211+ form method="post"
212+ action=(format!("/{handle}/-/members/remove")) {
213+ input type="hidden" name="_csrf" value=(csrf);
214+ input type="hidden" name="handle" value=(h);
215+ button .btn.btn-danger type="submit" { "Remove" }
216+ }
217+ }
218+ }
219+ }
220+ }
221+ }
222+ }
223+
224+ @if is_admin {
225+ form method="post" action=(format!("/{handle}/-/members")) .stack {
226+ input type="hidden" name="_csrf" value=(csrf);
227+ div .field {
228+ label for="member" { "Add a member" }
229+ input type="text" id="member" name="handle" required
230+ pattern="[a-z0-9][a-z0-9-]*" maxlength="39" placeholder="handle";
231+ }
232+ div .field {
233+ label for="role" { "Role" }
234+ select id="role" name="role" {
235+ option value="member" selected { "member — read access to org repositories" }
236+ option value="admin" { "admin — full access, and can manage membership" }
237+ }
238+ }
239+ button .btn.btn-primary type="submit" { "Add" }
240+ }
241+ }
242+ }
243+ },
244+ )
245+ .into_response())
246+}
247+
248+#[derive(Deserialize)]
249+pub struct MemberForm {
250+ pub handle: String,
251+ pub role: Option<String>,
252+}
253+
254+/// `POST /{handle}/-/members`
255+pub async fn add_member(
256+ State(state): State<AppState>,
257+ UrlPath(handle): UrlPath<String>,
258+ CurrentUser(user): CurrentUser,
259+ Form(form): Form<MemberForm>,
260+) -> AppResult<Response> {
261+ let (org, user) = require_org_admin(&state, &handle, user).await?;
262+
263+ let role = match form.role.as_deref() {
264+ Some("admin") => "admin",
265+ _ => "member",
266+ };
267+
268+ let target: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM users WHERE handle = $1")
269+ .bind(form.handle.trim())
270+ .fetch_optional(&state.db)
271+ .await?;
272+
273+ let Some((target_id,)) = target else {
274+ return Ok(member_err(&handle, "No such user."));
275+ };
276+
277+ sqlx::query(
278+ "INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, $3::org_role)
279+ ON CONFLICT (org_id, user_id) DO UPDATE SET role = EXCLUDED.role",
280+ )
281+ .bind(org.id)
282+ .bind(target_id)
283+ .bind(role)
284+ .execute(&state.db)
285+ .await?;
286+
287+ audit(
288+ &state,
289+ user.id,
290+ "org.member_added",
291+ &format!("{handle} {} = {role}", form.handle.trim()),
292+ )
293+ .await;
294+
295+ Ok(member_ok(&handle, "Member added."))
296+}
297+
298+/// `POST /{handle}/-/members/remove`
299+pub async fn remove_member(
300+ State(state): State<AppState>,
301+ UrlPath(handle): UrlPath<String>,
302+ CurrentUser(user): CurrentUser,
303+ Form(form): Form<MemberForm>,
304+) -> AppResult<Response> {
305+ let (org, user) = require_org_admin(&state, &handle, user).await?;
306+
307+ // An org with no administrator cannot be administered again — there is no
308+ // site-wide "adopt this org" path — so the last one cannot leave.
309+ let admins: i64 = sqlx::query_scalar(
310+ "SELECT count(*) FROM org_members WHERE org_id = $1 AND role = 'admin'",
311+ )
312+ .bind(org.id)
313+ .fetch_one(&state.db)
314+ .await?;
315+
316+ let target_is_admin: bool = sqlx::query_scalar(
317+ "SELECT EXISTS (SELECT 1 FROM org_members m JOIN users u ON u.id = m.user_id
318+ WHERE m.org_id = $1 AND u.handle = $2 AND m.role = 'admin')",
319+ )
320+ .bind(org.id)
321+ .bind(form.handle.trim())
322+ .fetch_one(&state.db)
323+ .await?;
324+
325+ if target_is_admin && admins <= 1 {
326+ return Ok(member_err(
327+ &handle,
328+ "That is the last administrator; promote somebody else first.",
329+ ));
330+ }
331+
332+ sqlx::query(
333+ "DELETE FROM org_members
334+ WHERE org_id = $1 AND user_id = (SELECT id FROM users WHERE handle = $2)",
335+ )
336+ .bind(org.id)
337+ .bind(form.handle.trim())
338+ .execute(&state.db)
339+ .await?;
340+
341+ audit(
342+ &state,
343+ user.id,
344+ "org.member_removed",
345+ &format!("{handle} {}", form.handle.trim()),
346+ )
347+ .await;
348+
349+ Ok(member_ok(&handle, "Member removed."))
350+}
351+
352+// ─── helpers ─────────────────────────────────────────────────────────────────
353+
354+pub struct Org {
355+ pub id: Uuid,
356+ pub handle: String,
357+ pub display_name: Option<String>,
358+}
359+
360+async fn load_org(state: &AppState, handle: &str) -> AppResult<Option<Org>> {
361+ let row: Option<(Uuid, String, Option<String>)> =
362+ sqlx::query_as("SELECT id, handle::text, display_name FROM orgs WHERE handle = $1")
363+ .bind(handle)
364+ .fetch_optional(&state.db)
365+ .await?;
366+ Ok(row.map(|(id, handle, display_name)| Org { id, handle, display_name }))
367+}
368+
369+async fn member_role(state: &AppState, org_id: Uuid, user_id: Uuid) -> AppResult<Option<OrgRole>> {
370+ Ok(
371+ sqlx::query_scalar("SELECT role FROM org_members WHERE org_id = $1 AND user_id = $2")
372+ .bind(org_id)
373+ .bind(user_id)
374+ .fetch_optional(&state.db)
375+ .await?,
376+ )
377+}
378+
379+/// Resolve the org and require the viewer to administer it.
380+///
381+/// Returns the same 404 for "no such org" and "you are not a member" — org
382+/// membership is not public here, so a 403 would disclose that the org exists.
383+async fn require_org_admin(
384+ state: &AppState,
385+ handle: &str,
386+ user: Option<std::sync::Arc<df_db::models::User>>,
387+) -> AppResult<(Org, std::sync::Arc<df_db::models::User>)> {
388+ let Some(user) = user else {
389+ return Err(AppError::Unauthorized);
390+ };
391+ let org = load_org(state, handle).await?.ok_or(AppError::NotFound)?;
392+ let role = member_role(state, org.id, user.id).await?;
393+
394+ if role.is_none() && !user.is_admin {
395+ return Err(AppError::NotFound);
396+ }
397+ if role != Some(OrgRole::Admin) && !user.is_admin {
398+ return Err(AppError::Forbidden);
399+ }
400+ Ok((org, user))
401+}
402+
403+fn valid_handle(h: &str) -> bool {
404+ !h.is_empty()
405+ && h.len() <= 39
406+ && h.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
407+ && h.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
408+}
409+
410+fn is_unique_violation(e: &sqlx::Error) -> bool {
411+ matches!(e, sqlx::Error::Database(db) if db.code().as_deref() == Some("23505"))
412+}
413+
414+fn role_str(r: OrgRole) -> &'static str {
415+ match r {
416+ OrgRole::Member => "member",
417+ OrgRole::Admin => "admin",
418+ }
419+}
420+
421+fn reject(msg: &str) -> Response {
422+ Redirect::to(&format!("/orgs/new?error={}", urlencode(msg))).into_response()
423+}
424+
425+fn member_ok(handle: &str, msg: &str) -> Response {
426+ Redirect::to(&format!("/{handle}/-/members?notice={}", urlencode(msg))).into_response()
427+}
428+
429+fn member_err(handle: &str, msg: &str) -> Response {
430+ Redirect::to(&format!("/{handle}/-/members?error={}", urlencode(msg))).into_response()
431+}
432+
433+#[cfg(test)]
434+mod tests {
435+ use super::valid_handle;
436+
437+ #[test]
438+ fn handle_validation_matches_the_database_constraint() {
439+ assert!(valid_handle("acme"));
440+ assert!(valid_handle("acme-corp"));
441+ assert!(valid_handle("9lives"));
442+
443+ assert!(!valid_handle(""));
444+ assert!(!valid_handle("-leading"));
445+ assert!(!valid_handle("Upper"));
446+ assert!(!valid_handle("with space"));
447+ assert!(!valid_handle("under_score"));
448+ assert!(!valid_handle(&"a".repeat(40)));
449+ }
450+}
Acrates/df-web/src/routes/profile.rs+180−0
@@ −0,0 +1,180 @@
1+//! `GET /{owner}` — a user or organization profile (spec §7).
2+//!
3+//! Users and organizations share one handle namespace (enforced by a trigger in
4+//! the schema), so one route serves both and the lookup tries users first.
5+//!
6+//! The repository list is filtered by the same rule the repository pages use:
7+//! public repos to everyone, private repos only where the viewer has a
8+//! collaborator row, an org membership, ownership, or site admin. Getting this
9+//! query wrong is the private-repo leak in its most direct form — a profile page
10+//! that enumerates repositories the viewer cannot open.
11+
12+use axum::extract::{Path as UrlPath, State};
13+use axum::response::{IntoResponse, Response};
14+use serde::Deserialize;
15+use uuid::Uuid;
16+
17+use crate::error::{AppError, AppResult};
18+use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
19+use crate::views::settings as v;
20+use crate::views::{self, Chrome};
21+
22+/// `GET /{owner}`
23+pub async fn show(
24+ State(state): State<AppState>,
25+ UrlPath(handle): UrlPath<String>,
26+ CurrentUser(user): CurrentUser,
27+ CsrfToken(csrf): CsrfToken,
28+ Nonce(nonce): Nonce,
29+) -> AppResult<Response> {
30+ let viewer = user.as_ref().map(|u| u.id);
31+ let is_site_admin = user.as_ref().is_some_and(|u| u.is_admin);
32+
33+ let owner = load_owner(&state, &handle).await?.ok_or(AppError::NotFound)?;
34+
35+ let repos = load_repos(&state, &owner, viewer, is_site_admin).await?;
36+
37+ let members = if owner.is_org {
38+ load_members(&state, owner.id).await?
39+ } else {
40+ Vec::new()
41+ };
42+
43+ Ok(views::page(
44+ Chrome {
45+ title: &owner.handle,
46+ user: user.as_deref(),
47+ csrf: &csrf,
48+ nonce: &nonce,
49+ },
50+ v::profile(v::Profile {
51+ handle: &owner.handle,
52+ display_name: owner.display_name.as_deref(),
53+ description: owner.description.as_deref(),
54+ is_org: owner.is_org,
55+ repos: &repos,
56+ members: &members,
57+ joined: owner.created_at,
58+ }),
59+ )
60+ .into_response())
61+}
62+
63+struct Owner {
64+ id: Uuid,
65+ handle: String,
66+ display_name: Option<String>,
67+ description: Option<String>,
68+ is_org: bool,
69+ created_at: chrono::DateTime<chrono::Utc>,
70+}
71+
72+#[derive(sqlx::FromRow)]
73+struct OwnerRow {
74+ id: Uuid,
75+ handle: String,
76+ display_name: Option<String>,
77+ description: Option<String>,
78+ is_org: bool,
79+ created_at: chrono::DateTime<chrono::Utc>,
80+}
81+
82+async fn load_owner(state: &AppState, handle: &str) -> AppResult<Option<Owner>> {
83+ // One statement over both tables. The handle namespace is shared, so at most
84+ // one row can match — and doing it in one query means a user and an org can
85+ // never both be found and silently resolved in some arbitrary order.
86+ let row: Option<OwnerRow> = sqlx::query_as(
87+ r#"
88+ SELECT id, handle::text AS handle, display_name, NULL::text AS description,
89+ false AS is_org, created_at
90+ FROM users WHERE handle = $1
91+ UNION ALL
92+ SELECT id, handle::text AS handle, display_name, description,
93+ true AS is_org, created_at
94+ FROM orgs WHERE handle = $1
95+ "#,
96+ )
97+ .bind(handle)
98+ .fetch_optional(&state.db)
99+ .await?;
100+
101+ Ok(row.map(|r| Owner {
102+ id: r.id,
103+ handle: r.handle,
104+ display_name: r.display_name,
105+ description: r.description,
106+ is_org: r.is_org,
107+ created_at: r.created_at,
108+ }))
109+}
110+
111+#[derive(Deserialize, sqlx::FromRow)]
112+struct RepoRow {
113+ name: String,
114+ description: Option<String>,
115+ private: bool,
116+ pushed_at: Option<chrono::DateTime<chrono::Utc>>,
117+}
118+
119+async fn load_repos(
120+ state: &AppState,
121+ owner: &Owner,
122+ viewer: Option<Uuid>,
123+ is_site_admin: bool,
124+) -> AppResult<Vec<v::ProfileRepo>> {
125+ // The visibility predicate is written out here rather than filtered in Rust
126+ // so a private repository is never loaded in the first place. Every branch
127+ // after the first requires `$2` (the viewer) to be non-null, so an anonymous
128+ // request can only ever match the public branch.
129+ let rows: Vec<RepoRow> = sqlx::query_as(
130+ r#"
131+ SELECT r.name::text AS name,
132+ r.description,
133+ (r.visibility = 'private') AS private,
134+ r.pushed_at
135+ FROM repos r
136+ WHERE (r.owner_user_id = $1 OR r.owner_org_id = $1)
137+ AND (
138+ r.visibility = 'public'
139+ OR $3
140+ OR r.owner_user_id = $2
141+ OR EXISTS (SELECT 1 FROM repo_collaborators c
142+ WHERE c.repo_id = r.id AND c.user_id = $2)
143+ OR EXISTS (SELECT 1 FROM org_members m
144+ WHERE m.org_id = r.owner_org_id AND m.user_id = $2)
145+ )
146+ ORDER BY r.pushed_at DESC NULLS LAST, r.name
147+ LIMIT 200
148+ "#,
149+ )
150+ .bind(owner.id)
151+ .bind(viewer)
152+ .bind(is_site_admin)
153+ .fetch_all(&state.db)
154+ .await?;
155+
156+ Ok(rows
157+ .into_iter()
158+ .map(|r| v::ProfileRepo {
159+ name: r.name,
160+ description: r.description,
161+ private: r.private,
162+ pushed_at: r.pushed_at,
163+ })
164+ .collect())
165+}
166+
167+async fn load_members(
168+ state: &AppState,
169+ org_id: Uuid,
170+) -> AppResult<Vec<(String, df_db::models::OrgRole)>> {
171+ Ok(sqlx::query_as(
172+ "SELECT u.handle::text, m.role
173+ FROM org_members m JOIN users u ON u.id = m.user_id
174+ WHERE m.org_id = $1
175+ ORDER BY m.role DESC, u.handle",
176+ )
177+ .bind(org_id)
178+ .fetch_all(&state.db)
179+ .await?)
180+}
Acrates/df-web/src/routes/repo.rs754 lines+754−0
@@ −0,0 +1,754 @@
1+//! Repository browsing (M2) and creation.
2+
3+use std::path::Path;
4+
5+use axum::extract::{Path as UrlPath, Query, State};
6+use axum::response::{IntoResponse, Redirect, Response};
7+use axum::Form;
8+use df_db::ids::new_id;
9+use df_store::StoreError;
10+use serde::Deserialize;
11+use uuid::Uuid;
12+
13+use crate::error::{AppError, AppResult};
14+use crate::repo_ctx::RepoContext;
15+use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
16+use crate::views::repo as v;
17+use crate::views::{self, Chrome};
18+
19+/// Map a store error to an HTTP outcome.
20+///
21+/// `NoSuchRepo` becomes a 404 rather than a 500: it means the database and the
22+/// filesystem disagree, which is a real state after a restore (spec §10) and
23+/// should look like a missing repository, not a crash.
24+fn store_err(e: StoreError) -> AppError {
25+ match e {
26+ StoreError::NoSuchRepo | StoreError::NoSuchRevision | StoreError::NoSuchPath => {
27+ AppError::NotFound
28+ }
29+ StoreError::IsDirectory => AppError::BadRequest("that path is a directory".into()),
30+ StoreError::Path(p) => AppError::BadRequest(p.to_string()),
31+ other => AppError::Internal(anyhow::anyhow!(other)),
32+ }
33+}
34+
35+/// `GET /{owner}/{repo}` — code view at the default bookmark.
36+pub async fn index(
37+ State(state): State<AppState>,
38+ UrlPath((owner, name)): UrlPath<(String, String)>,
39+ CurrentUser(user): CurrentUser,
40+ CsrfToken(csrf): CsrfToken,
41+ Nonce(nonce): Nonce,
42+) -> AppResult<Response> {
43+ let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
44+
45+ let empty = state
46+ .store
47+ .is_empty(ctx.store_id())
48+ .await
49+ .unwrap_or(true);
50+
51+ let body = if empty {
52+ v::empty_repo(
53+ &state.config.https_clone_url(&ctx.owner, &ctx.repo.name),
54+ &state.config.ssh_clone_url(&ctx.owner, &ctx.repo.name),
55+ )
56+ } else {
57+ let rev_label = ctx.repo.default_bookmark.clone();
58+ let rev = state
59+ .store
60+ .resolve(ctx.store_id(), &rev_label)
61+ .await
62+ .map_err(store_err)?;
63+ let entries = state
64+ .store
65+ .list_tree(ctx.store_id(), &rev, Path::new(""))
66+ .await
67+ .map_err(store_err)?;
68+
69+ let readme = render_readme(&state, &ctx, &rev, &entries).await;
70+ let tip = tip_commit(&state, &ctx, &rev).await;
71+ v::tree_listing(&ctx, &rev_label, "", &entries, tip.as_ref(), readme.as_ref())
72+ };
73+
74+ Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
75+}
76+
77+#[derive(Deserialize)]
78+pub struct RevPath {
79+ pub owner: String,
80+ pub repo: String,
81+ pub rev: String,
82+ #[serde(default)]
83+ pub path: String,
84+}
85+
86+/// `GET /{owner}/{repo}/tree/{rev}/{path...}`
87+pub async fn tree(
88+ State(state): State<AppState>,
89+ UrlPath(p): UrlPath<RevPath>,
90+ CurrentUser(user): CurrentUser,
91+ CsrfToken(csrf): CsrfToken,
92+ Nonce(nonce): Nonce,
93+) -> AppResult<Response> {
94+ let ctx = RepoContext::load(&state, &p.owner, &p.repo, user.as_deref()).await?;
95+
96+ let rev = state
97+ .store
98+ .resolve(ctx.store_id(), &p.rev)
99+ .await
100+ .map_err(store_err)?;
101+
102+ let entries = state
103+ .store
104+ .list_tree(ctx.store_id(), &rev, Path::new(&p.path))
105+ .await
106+ .map_err(store_err)?;
107+
108+ let readme = if p.path.is_empty() {
109+ render_readme(&state, &ctx, &rev, &entries).await
110+ } else {
111+ None
112+ };
113+
114+ let tip = tip_commit(&state, &ctx, &rev).await;
115+
116+ let body = v::tree_listing(&ctx, &p.rev, &p.path, &entries, tip.as_ref(), readme.as_ref());
117+ Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
118+}
119+
120+/// The revision at the tip of what is being browsed.
121+///
122+/// Best-effort: the listing is the point of the page, so a store that cannot
123+/// produce a log still renders the files rather than failing the request.
124+async fn tip_commit(
125+ state: &AppState,
126+ ctx: &RepoContext,
127+ rev: &df_store::RevId,
128+) -> Option<v::TipCommit> {
129+ let revisions = state.store.log(ctx.store_id(), rev, 1).await.ok()?;
130+ let r = revisions.into_iter().next()?;
131+
132+ Some(v::TipCommit {
133+ author: r.author.name.clone(),
134+ summary: r.summary().to_string(),
135+ when: r.author.when,
136+ change_id: r.change_id.clone(),
137+ })
138+}
139+
140+/// Is this path a markdown document?
141+///
142+/// Extension-based on purpose: sniffing content would mean a file that happens
143+/// to start with a `#` gets rendered as a heading, which is worse than a `.txt`
144+/// missing out on rendering.
145+fn is_markdown(path: &str) -> bool {
146+ let lower = path.to_lowercase();
147+ lower.ends_with(".md") || lower.ends_with(".markdown")
148+}
149+
150+#[derive(Deserialize, Default)]
151+pub struct BlobQuery {
152+ /// `source` shows the highlighted source of a markdown file instead of the
153+ /// rendered document. Anything else — including absent — renders.
154+ #[serde(default)]
155+ pub view: Option<String>,
156+ /// When `blame=1`, show per-line blame annotations.
157+ #[serde(default)]
158+ pub blame: Option<String>,
159+}
160+
161+/// `GET /{owner}/{repo}/blob/{rev}/{path...}`
162+pub async fn blob(
163+ State(state): State<AppState>,
164+ UrlPath(p): UrlPath<RevPath>,
165+ Query(q): Query<BlobQuery>,
166+ CurrentUser(user): CurrentUser,
167+ CsrfToken(csrf): CsrfToken,
168+ Nonce(nonce): Nonce,
169+) -> AppResult<Response> {
170+ let ctx = RepoContext::load(&state, &p.owner, &p.repo, user.as_deref()).await?;
171+
172+ let rev = state
173+ .store
174+ .resolve(ctx.store_id(), &p.rev)
175+ .await
176+ .map_err(store_err)?;
177+
178+ let wants_source = q.view.as_deref() == Some("source");
179+ let wants_blame = q.blame.as_deref() == Some("1");
180+ let markdown_path = is_markdown(&p.path);
181+
182+ // Fetch the file tree for the sidebar (directory containing this file).
183+ let tree_dir = match p.path.rsplit_once('/') {
184+ Some((parent, _)) => parent.to_string(),
185+ None => String::new(),
186+ };
187+ let sidebar_entries = state
188+ .store
189+ .list_tree(ctx.store_id(), &rev, Path::new(&tree_dir))
190+ .await
191+ .unwrap_or_default();
192+
193+ // Fetch the last commit that modified this file.
194+ let last_commit = state
195+ .store
196+ .last_commit_for_path(ctx.store_id(), &rev, Path::new(&p.path))
197+ .await
198+ .ok()
199+ .flatten();
200+
201+ // Optionally fetch blame.
202+ let blame_lines = if wants_blame {
203+ state
204+ .store
205+ .blame(ctx.store_id(), &rev, Path::new(&p.path))
206+ .await
207+ .ok()
208+ } else {
209+ None
210+ };
211+
212+ let body = match state
213+ .store
214+ .read_blob(ctx.store_id(), &rev, Path::new(&p.path))
215+ .await
216+ {
217+ Ok(b) => {
218+ let limit = state.config.max_blob_render_bytes as u64;
219+ if b.size > limit {
220+ v::blob_view(
221+ &ctx,
222+ &p.rev,
223+ &p.path,
224+ v::BlobBody::TooLarge { size: b.size, limit },
225+ None,
226+ &v::BlobExtras::default(),
227+ )
228+ } else if b.text().is_some() {
229+ // Highlighting is cached by content address, so re-viewing a
230+ // file — or viewing it at a revision that did not change it —
231+ // never re-parses (spec §8).
232+ let hl = crate::highlight_cache::render(&state.db, &b).await;
233+ let text = b.text().unwrap_or("");
234+ let lines = text.lines().count();
235+
236+ // Always sanitised — never trust repository content (spec §9).
237+ let rendered = (markdown_path && !wants_source)
238+ .then(|| v::rendered_markdown(&df_render::markdown_to_html(text)));
239+
240+ let markdown = match (markdown_path, &rendered) {
241+ (true, Some(doc)) => Some(v::MarkdownView::Rendered(doc)),
242+ (true, None) => Some(v::MarkdownView::Source),
243+ (false, _) => None,
244+ };
245+
246+ // Extract symbols for the outline panel.
247+ let symbols = df_render::symbols::extract_symbols(&p.path, text);
248+
249+ let extras = v::BlobExtras {
250+ sidebar_entries: &sidebar_entries,
251+ sidebar_dir: &tree_dir,
252+ symbols: &symbols,
253+ last_commit: last_commit.as_ref(),
254+ blame: blame_lines.as_deref(),
255+ wants_blame,
256+ };
257+
258+ v::blob_view(
259+ &ctx,
260+ &p.rev,
261+ &p.path,
262+ v::BlobBody::Text {
263+ content: text,
264+ lines,
265+ highlighted: &hl.lines,
266+ language: hl.language.as_deref(),
267+ plain_reason: hl.skipped.and_then(plain_reason),
268+ },
269+ markdown,
270+ &extras,
271+ )
272+ } else {
273+ v::blob_view(
274+ &ctx,
275+ &p.rev,
276+ &p.path,
277+ v::BlobBody::Binary { size: b.size },
278+ None,
279+ &v::BlobExtras::default(),
280+ )
281+ }
282+ }
283+ // The store's own cap fired before ours.
284+ Err(StoreError::TooLarge { size, limit }) => {
285+ v::blob_view(
286+ &ctx,
287+ &p.rev,
288+ &p.path,
289+ v::BlobBody::TooLarge { size, limit },
290+ None,
291+ &v::BlobExtras::default(),
292+ )
293+ }
294+ Err(e) => return Err(store_err(e)),
295+ };
296+
297+ Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
298+}
299+
300+/// `GET /{owner}/{repo}/raw/{rev}/{path...}`
301+///
302+/// Spec §9: "Blob content served ... with `Content-Disposition: attachment` and
303+/// `X-Content-Type-Options: nosniff` — never serve user-controlled HTML on the
304+/// app origin." Everything here is `application/octet-stream` and downloaded,
305+/// so a repository containing an HTML page cannot execute against our origin.
306+pub async fn raw(
307+ State(state): State<AppState>,
308+ UrlPath(p): UrlPath<RevPath>,
309+ CurrentUser(user): CurrentUser,
310+) -> AppResult<Response> {
311+ let ctx = RepoContext::load(&state, &p.owner, &p.repo, user.as_deref()).await?;
312+
313+ let rev = state
314+ .store
315+ .resolve(ctx.store_id(), &p.rev)
316+ .await
317+ .map_err(store_err)?;
318+
319+ let blob = state
320+ .store
321+ .read_blob(ctx.store_id(), &rev, Path::new(&p.path))
322+ .await
323+ .map_err(store_err)?;
324+
325+ let filename = p
326+ .path
327+ .rsplit('/')
328+ .next()
329+ .filter(|s| !s.is_empty())
330+ .unwrap_or("file");
331+
332+ // The filename reaches a header; strip anything that could break out of the
333+ // quoted string or inject a second header.
334+ let safe_name: String = filename
335+ .chars()
336+ .filter(|c| !matches!(c, '"' | '\\' | '\r' | '\n') && !c.is_control())
337+ .take(200)
338+ .collect();
339+
340+ Ok((
341+ [
342+ (
343+ axum::http::header::CONTENT_TYPE,
344+ "application/octet-stream".to_string(),
345+ ),
346+ (
347+ axum::http::header::CONTENT_DISPOSITION,
348+ format!("attachment; filename=\"{safe_name}\""),
349+ ),
350+ (
351+ axum::http::header::X_CONTENT_TYPE_OPTIONS,
352+ "nosniff".to_string(),
353+ ),
354+ ],
355+ blob.content,
356+ )
357+ .into_response())
358+}
359+
360+#[derive(Deserialize)]
361+pub struct LogQuery {
362+ pub rev: Option<String>,
363+ pub limit: Option<usize>,
364+}
365+
366+/// `GET /{owner}/{repo}/log`
367+pub async fn log(
368+ State(state): State<AppState>,
369+ UrlPath((owner, name)): UrlPath<(String, String)>,
370+ Query(q): Query<LogQuery>,
371+ CurrentUser(user): CurrentUser,
372+ CsrfToken(csrf): CsrfToken,
373+ Nonce(nonce): Nonce,
374+) -> AppResult<Response> {
375+ let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
376+
377+ let rev_label = q.rev.unwrap_or_else(|| ctx.repo.default_bookmark.clone());
378+ let rev = state
379+ .store
380+ .resolve(ctx.store_id(), &rev_label)
381+ .await
382+ .map_err(store_err)?;
383+
384+ // Clamped: a URL must not be able to ask for unbounded history.
385+ let limit = q.limit.unwrap_or(50).clamp(1, 200);
386+ let revs = state
387+ .store
388+ .log(ctx.store_id(), &rev, limit)
389+ .await
390+ .map_err(store_err)?;
391+
392+ let body = v::log_view(&ctx, &rev_label, &revs);
393+ Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
394+}
395+
396+/// `GET /{owner}/{repo}/bookmarks`
397+pub async fn bookmarks(
398+ State(state): State<AppState>,
399+ UrlPath((owner, name)): UrlPath<(String, String)>,
400+ CurrentUser(user): CurrentUser,
401+ CsrfToken(csrf): CsrfToken,
402+ Nonce(nonce): Nonce,
403+) -> AppResult<Response> {
404+ let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
405+ let marks = state
406+ .store
407+ .bookmarks(ctx.store_id())
408+ .await
409+ .unwrap_or_default();
410+
411+ let body = v::bookmarks_view(&ctx, &marks);
412+ Ok(render(&ctx, "bookmarks", &csrf, &nonce, user.as_deref(), body))
413+}
414+
415+/// Explain a plain-text fallback to the reader.
416+///
417+/// "No grammar for this file type" is not worth saying — most files in a
418+/// repository have no grammar and the absence of colour is unremarkable. A file
419+/// that *would* have been highlighted but was skipped for size is worth saying,
420+/// because the reader can otherwise not tell it from a broken renderer.
421+fn plain_reason(s: df_render::highlight::Skipped) -> Option<&'static str> {
422+ use df_render::highlight::Skipped;
423+ match s {
424+ Skipped::NoGrammar => None,
425+ Skipped::TooLarge => Some("too large to highlight"),
426+ Skipped::LineTooLong => Some("lines too long to highlight"),
427+ Skipped::Failed => Some("could not be highlighted"),
428+ }
429+}
430+
431+// ─── creation ────────────────────────────────────────────────────────────────
432+
433+/// `GET /new`
434+pub async fn new_form(
435+ State(state): State<AppState>,
436+ CurrentUser(user): CurrentUser,
437+ CsrfToken(csrf): CsrfToken,
438+ Nonce(nonce): Nonce,
439+) -> AppResult<Response> {
440+ let Some(user) = user else {
441+ return Err(AppError::Unauthorized);
442+ };
443+ let owners = owner_choices(&state, &user).await?;
444+
445+ Ok(views::page(
446+ Chrome { title: "New repository", user: Some(&user), csrf: &csrf, nonce: &nonce },
447+ v::new_repo_form(&csrf, None, &owners),
448+ )
449+ .into_response())
450+}
451+
452+#[derive(Deserialize)]
453+pub struct CreateRepo {
454+ pub owner: String,
455+ pub name: String,
456+ pub description: Option<String>,
457+ pub default_bookmark: String,
458+ pub private: Option<String>,
459+}
460+
461+/// `POST /repos`
462+pub async fn create(
463+ State(state): State<AppState>,
464+ CurrentUser(user): CurrentUser,
465+ CsrfToken(csrf): CsrfToken,
466+ Nonce(nonce): Nonce,
467+ Form(form): Form<CreateRepo>,
468+) -> AppResult<Response> {
469+ let Some(user) = user else {
470+ return Err(AppError::Unauthorized);
471+ };
472+
473+ let owners = owner_choices(&state, &user).await?;
474+ let reject = |msg: &str| -> Response {
475+ views::page(
476+ Chrome { title: "New repository", user: Some(&user), csrf: &csrf, nonce: &nonce },
477+ v::new_repo_form(&csrf, Some(msg), &owners),
478+ )
479+ .into_response()
480+ };
481+
482+ let name = form.name.trim();
483+ if !valid_repo_name(name) {
484+ return Ok(reject(
485+ "Repository names must start with a letter or digit and may contain \
486+ letters, digits, dots, hyphens and underscores.",
487+ ));
488+ }
489+
490+ let bookmark = form.default_bookmark.trim();
491+ if !valid_bookmark_name(bookmark) {
492+ return Ok(reject("That is not a valid bookmark name."));
493+ }
494+
495+ // The namespace must be the user's own, or an org they administer. Checked
496+ // against the database rather than against the form's own option list — the
497+ // list is a convenience, not a control.
498+ let owner_org: Option<Uuid> = if form.owner == user.handle {
499+ None
500+ } else {
501+ let org: Option<(Uuid,)> = sqlx::query_as(
502+ "SELECT o.id FROM orgs o
503+ JOIN org_members m ON m.org_id = o.id
504+ WHERE o.handle = $1 AND m.user_id = $2 AND m.role = 'admin'",
505+ )
506+ .bind(&form.owner)
507+ .bind(user.id)
508+ .fetch_optional(&state.db)
509+ .await?;
510+
511+ match org {
512+ Some((id,)) => Some(id),
513+ None => return Err(AppError::Forbidden),
514+ }
515+ };
516+
517+ let repo_id = new_id();
518+ let visibility = if form.private.is_some() { "private" } else { "public" };
519+
520+ // Create the database row first. If storage creation then fails we can
521+ // roll the row back; the reverse ordering would leave an orphan directory
522+ // with no owner and no way to reach it.
523+ let mut tx = state.db.begin().await?;
524+
525+ let inserted = sqlx::query(
526+ "INSERT INTO repos (id, owner_kind, owner_user_id, owner_org_id, name, description,
527+ visibility, default_bookmark)
528+ VALUES ($1,
529+ CASE WHEN $7::uuid IS NULL THEN 'user' ELSE 'org' END::owner_kind,
530+ CASE WHEN $7::uuid IS NULL THEN $2 ELSE NULL END,
531+ $7, $3, $4, $5::visibility, $6)
532+ ON CONFLICT DO NOTHING",
533+ )
534+ .bind(repo_id)
535+ .bind(user.id)
536+ .bind(name)
537+ .bind(form.description.as_deref().filter(|d| !d.trim().is_empty()))
538+ .bind(visibility)
539+ .bind(bookmark)
540+ .bind(owner_org)
541+ .execute(&mut *tx)
542+ .await?;
543+
544+ if inserted.rows_affected() == 0 {
545+ return Ok(reject("That namespace already has a repository with that name."));
546+ }
547+
548+ sqlx::query("INSERT INTO repo_counters (repo_id) VALUES ($1)")
549+ .bind(repo_id)
550+ .execute(&mut *tx)
551+ .await?;
552+
553+ // Default labels, so the issue form has something to offer on day one.
554+ // Kept in step with the backfill in migration 0003.
555+ for (label, color) in [
556+ ("bug", "#d06b6b"),
557+ ("enhancement", "#6ba9b8"),
558+ ("question", "#d9a441"),
559+ ("documentation", "#8b7fd4"),
560+ ] {
561+ sqlx::query(
562+ "INSERT INTO labels (id, repo_id, name, color) VALUES ($1, $2, $3, $4)
563+ ON CONFLICT DO NOTHING",
564+ )
565+ .bind(new_id())
566+ .bind(repo_id)
567+ .bind(label)
568+ .bind(color)
569+ .execute(&mut *tx)
570+ .await?;
571+ }
572+
573+ sqlx::query(
574+ "INSERT INTO events (id, repo_id, actor_id, kind, subject_type, subject_id)
575+ VALUES ($1, $2, $3, 'repo.created', 'repo', $2)",
576+ )
577+ .bind(new_id())
578+ .bind(repo_id)
579+ .bind(user.id)
580+ .execute(&mut *tx)
581+ .await?;
582+
583+ if let Err(e) = state
584+ .store
585+ .create(df_store::RepoId(repo_id), bookmark)
586+ .await
587+ {
588+ tracing::error!("creating repository storage failed: {e}");
589+ tx.rollback().await?;
590+ return Ok(reject(
591+ "The repository could not be created on disk. Please try again.",
592+ ));
593+ }
594+
595+ // Push validation, before the repository can receive a push. A repository
596+ // without this hook accepts anything: no ref-name allowlist, no protected
597+ // bookmarks (spec §9). Failing to install it fails the creation rather than
598+ // producing a repository that is quietly unvalidated.
599+ if let Err(e) =
600+ crate::hooks::install(state.store.as_ref(), repo_id, &state.config.hook_binary).await
601+ {
602+ tracing::error!(repo = %repo_id, "{e:#}");
603+ tx.rollback().await?;
604+ let _ = state.store.delete(df_store::RepoId(repo_id)).await;
605+ return Ok(reject(
606+ "The repository could not be prepared to receive pushes. Please try again.",
607+ ));
608+ }
609+
610+ tx.commit().await?;
611+ tracing::info!(repo = %repo_id, owner = %form.owner, name = %name, "repository created");
612+
613+ Ok(Redirect::to(&format!("/{}/{}", form.owner, name)).into_response())
614+}
615+
616+// ─── helpers ─────────────────────────────────────────────────────────────────
617+
618+/// Namespaces this user may create a repository in: their own, plus every org
619+/// they administer.
620+///
621+/// Org *members* are deliberately excluded — membership grants read access to
622+/// the org's repositories, not the right to add one.
623+async fn owner_choices(state: &AppState, user: &df_db::models::User) -> AppResult<Vec<String>> {
624+ let mut owners = vec![user.handle.clone()];
625+ let orgs: Vec<String> = sqlx::query_scalar(
626+ "SELECT o.handle::text FROM orgs o
627+ JOIN org_members m ON m.org_id = o.id
628+ WHERE m.user_id = $1 AND m.role = 'admin'
629+ ORDER BY o.handle",
630+ )
631+ .bind(user.id)
632+ .fetch_all(&state.db)
633+ .await?;
634+ owners.extend(orgs);
635+ Ok(owners)
636+}
637+
638+/// Mirror of the `name_format` CHECK on `repos`.
639+fn valid_repo_name(n: &str) -> bool {
640+ !n.is_empty()
641+ && n.len() <= 100
642+ && !n.contains("..")
643+ && n.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
644+ && n.chars()
645+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
646+}
647+
648+/// Git ref-name rules, plus the stricter allowlist from spec §9.
649+pub fn valid_bookmark_name(n: &str) -> bool {
650+ !n.is_empty()
651+ && n.len() <= 100
652+ && !n.contains("..")
653+ && !n.ends_with(".lock")
654+ && !n.starts_with('-')
655+ && !n.starts_with('/')
656+ && !n.ends_with('/')
657+ && !n.contains("//")
658+ && !n.contains('\\')
659+ && !n.contains("@{")
660+ && n.chars()
661+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-'))
662+}
663+
664+fn render(
665+ ctx: &RepoContext,
666+ tab: &str,
667+ csrf: &str,
668+ nonce: &str,
669+ user: Option<&df_db::models::User>,
670+ body: maud::Markup,
671+) -> Response {
672+ views::page(
673+ Chrome {
674+ title: &format!("{}/{}", ctx.owner, ctx.repo.name),
675+ user,
676+ csrf,
677+ nonce,
678+ },
679+ maud::html! { (v::header(ctx, tab)) (body) },
680+ )
681+ .into_response()
682+}
683+
684+/// Render a README if the directory has one.
685+///
686+/// Returns the file's own name alongside the markup so the card can be labelled
687+/// with what was actually found — a repository with a bare `README` should not
688+/// be told it has a `README.md`.
689+async fn render_readme(
690+ state: &AppState,
691+ ctx: &RepoContext,
692+ rev: &df_store::RevId,
693+ entries: &[df_store::TreeEntry],
694+) -> Option<(String, maud::Markup)> {
695+ let readme = entries.iter().find(|e| {
696+ !e.is_dir()
697+ && matches!(
698+ e.name.to_lowercase().as_str(),
699+ "readme.md" | "readme" | "readme.markdown" | "readme.txt"
700+ )
701+ })?;
702+
703+ let blob = state
704+ .store
705+ .read_blob(ctx.store_id(), rev, Path::new(&readme.path))
706+ .await
707+ .ok()?;
708+ let text = blob.text()?;
709+
710+ // Always sanitised — never trust repository content (spec §9).
711+ let html_str = df_render::markdown_to_html(text);
712+ Some((readme.name.clone(), v::rendered_markdown(&html_str)))
713+}
714+
715+#[cfg(test)]
716+mod tests {
717+ use super::*;
718+
719+ #[test]
720+ fn repo_name_validation_mirrors_the_db_constraint() {
721+ assert!(valid_repo_name("dogfood"));
722+ assert!(valid_repo_name("my.repo_name-1"));
723+ assert!(valid_repo_name("9lives"));
724+
725+ assert!(!valid_repo_name(""));
726+ assert!(!valid_repo_name(".hidden"), "must start alphanumeric");
727+ assert!(!valid_repo_name("-dash"));
728+ assert!(!valid_repo_name("a..b"), "traversal must be rejected");
729+ assert!(!valid_repo_name("with space"));
730+ assert!(!valid_repo_name("with/slash"));
731+ assert!(!valid_repo_name(&"a".repeat(101)));
732+ }
733+
734+ #[test]
735+ fn bookmark_validation_follows_the_ref_rules() {
736+ assert!(valid_bookmark_name("main"));
737+ assert!(valid_bookmark_name("feature/thing"));
738+ assert!(valid_bookmark_name("v1.2.3"));
739+
740+ // Spec §9: reject refs containing `..`, ending in `.lock`, starting
741+ // with `-`, or containing control characters.
742+ assert!(!valid_bookmark_name("a..b"));
743+ assert!(!valid_bookmark_name("main.lock"));
744+ assert!(!valid_bookmark_name("-main"));
745+ assert!(!valid_bookmark_name("main\n"));
746+ assert!(!valid_bookmark_name("main\x1b[31m"));
747+ assert!(!valid_bookmark_name("a//b"));
748+ assert!(!valid_bookmark_name("/main"));
749+ assert!(!valid_bookmark_name("main/"));
750+ assert!(!valid_bookmark_name("main@{1}"));
751+ assert!(!valid_bookmark_name("back\\slash"));
752+ assert!(!valid_bookmark_name(""));
753+ }
754+}
Acrates/df-web/src/routes/repo_settings.rs444 lines+444−0
@@ −0,0 +1,444 @@
1+//! Repository settings, collaborators, bookmark protection, archive and delete
2+//! (spec §7).
3+//!
4+//! Every handler here starts with `RepoContext::load` — which authorizes *read*
5+//! and 404s otherwise — and then calls `require_settings` or `require_delete`.
6+//! That ordering matters: a stranger poking at a private repo's settings URL
7+//! gets the same 404 they get for the repo itself, and only somebody who can
8+//! already see it ever receives a 403 (spec §9).
9+
10+use axum::extract::{Path as UrlPath, Query, State};
11+use axum::response::{IntoResponse, Redirect, Response};
12+use axum::Form;
13+use df_db::models::RepoRole;
14+use serde::Deserialize;
15+use uuid::Uuid;
16+
17+use crate::error::{AppError, AppResult};
18+use crate::repo_ctx::RepoContext;
19+use crate::routes::settings::{audit, urlencode};
20+use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
21+use crate::views::settings as v;
22+use crate::views::{self, Chrome};
23+
24+#[derive(Deserialize, Default)]
25+pub struct SettingsQuery {
26+ pub tab: Option<String>,
27+ pub error: Option<String>,
28+ pub notice: Option<String>,
29+}
30+
31+/// `GET /{owner}/{repo}/settings`
32+pub async fn show(
33+ State(state): State<AppState>,
34+ UrlPath((owner, name)): UrlPath<(String, String)>,
35+ Query(q): Query<SettingsQuery>,
36+ CurrentUser(user): CurrentUser,
37+ CsrfToken(csrf): CsrfToken,
38+ Nonce(nonce): Nonce,
39+) -> AppResult<Response> {
40+ let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
41+ ctx.require_settings()?;
42+
43+ let collaborators = load_collaborators(&state, ctx.repo.id).await?;
44+ let bookmarks = load_bookmarks(&state, &ctx).await?;
45+ let size_bytes = state
46+ .store
47+ .size_bytes(ctx.store_id())
48+ .await
49+ .unwrap_or(ctx.repo.size_bytes.max(0) as u64);
50+
51+ let tab = q.tab.as_deref().unwrap_or("general").to_owned();
52+
53+ let body = maud::html! {
54+ (crate::views::repo::header(&ctx, "settings"))
55+ (v::repo_settings(v::RepoSettings {
56+ ctx: &ctx,
57+ csrf: &csrf,
58+ tab: &tab,
59+ collaborators: &collaborators,
60+ bookmarks: &bookmarks,
61+ size_bytes,
62+ error: q.error.as_deref(),
63+ notice: q.notice.as_deref(),
64+ }))
65+ };
66+
67+ Ok(views::page(
68+ Chrome {
69+ title: &format!("{}/{} settings", ctx.owner, ctx.repo.name),
70+ user: user.as_deref(),
71+ csrf: &csrf,
72+ nonce: &nonce,
73+ },
74+ body,
75+ )
76+ .into_response())
77+}
78+
79+// ─── general ─────────────────────────────────────────────────────────────────
80+
81+#[derive(Deserialize)]
82+pub struct General {
83+ pub description: Option<String>,
84+ pub default_bookmark: Option<String>,
85+ /// Present only when the checkbox is ticked.
86+ pub private: Option<String>,
87+}
88+
89+/// `POST /{owner}/{repo}/settings/general`
90+pub async fn update_general(
91+ State(state): State<AppState>,
92+ UrlPath((owner, name)): UrlPath<(String, String)>,
93+ CurrentUser(user): CurrentUser,
94+ Form(form): Form<General>,
95+) -> AppResult<Response> {
96+ let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
97+ ctx.require_settings()?;
98+ let actor = user.as_ref().ok_or(AppError::Unauthorized)?.id;
99+
100+ let description: Option<String> = form
101+ .description
102+ .as_deref()
103+ .map(str::trim)
104+ .filter(|s| !s.is_empty())
105+ .map(|s| s.chars().take(500).collect());
106+
107+ let bookmark = form
108+ .default_bookmark
109+ .as_deref()
110+ .map(str::trim)
111+ .filter(|s| !s.is_empty())
112+ .unwrap_or(&ctx.repo.default_bookmark);
113+
114+ // The same validation the create form uses. A settings form is not a more
115+ // trusted input than a create form.
116+ if !super::repo::valid_bookmark_name(bookmark) {
117+ return Ok(err(&ctx, "general", "That is not a valid bookmark name."));
118+ }
119+
120+ let visibility = if form.private.is_some() { "private" } else { "public" };
121+
122+ sqlx::query(
123+ "UPDATE repos SET description = $2, default_bookmark = $3, visibility = $4::visibility
124+ WHERE id = $1",
125+ )
126+ .bind(ctx.repo.id)
127+ .bind(&description)
128+ .bind(bookmark)
129+ .bind(visibility)
130+ .execute(&state.db)
131+ .await?;
132+
133+ // Visibility changes are the ones worth an audit entry — they are how a
134+ // private repository becomes public, deliberately or otherwise.
135+ if visibility != visibility_str(ctx.repo.visibility) {
136+ audit(
137+ &state,
138+ actor,
139+ "repo.visibility_changed",
140+ &format!("{}/{} -> {visibility}", ctx.owner, ctx.repo.name),
141+ )
142+ .await;
143+ }
144+
145+ Ok(ok(&ctx, "general", "Settings saved."))
146+}
147+
148+// ─── collaborators ───────────────────────────────────────────────────────────
149+
150+#[derive(Deserialize)]
151+pub struct Collaborator {
152+ pub handle: String,
153+ pub role: Option<String>,
154+}
155+
156+/// `POST /{owner}/{repo}/settings/collaborators` — add or update.
157+pub async fn upsert_collaborator(
158+ State(state): State<AppState>,
159+ UrlPath((owner, name)): UrlPath<(String, String)>,
160+ CurrentUser(user): CurrentUser,
161+ Form(form): Form<Collaborator>,
162+) -> AppResult<Response> {
163+ let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
164+ ctx.require_settings()?;
165+ let actor = user.as_ref().ok_or(AppError::Unauthorized)?.id;
166+
167+ let Some(role) = form.role.as_deref().and_then(parse_role) else {
168+ return Ok(err(&ctx, "collaborators", "Unknown role."));
169+ };
170+
171+ // Granting a role you do not hold yourself is privilege escalation: a
172+ // `maintain` collaborator could otherwise make themselves `admin` by adding
173+ // themselves again. Capped at the granter's own effective role.
174+ let granter = ctx.access.role().unwrap_or(RepoRole::Read);
175+ if role > granter {
176+ return Ok(err(
177+ &ctx,
178+ "collaborators",
179+ "You cannot grant a role higher than your own.",
180+ ));
181+ }
182+
183+ let target: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM users WHERE handle = $1")
184+ .bind(form.handle.trim())
185+ .fetch_optional(&state.db)
186+ .await?;
187+
188+ let Some((target_id,)) = target else {
189+ return Ok(err(&ctx, "collaborators", "No such user."));
190+ };
191+
192+ // The owner already has admin implicitly, and a collaborator row for them
193+ // would be a confusing no-op that also could not be removed meaningfully.
194+ if ctx.repo.owner_user_id == Some(target_id) {
195+ return Ok(err(
196+ &ctx,
197+ "collaborators",
198+ "The owner already has full access.",
199+ ));
200+ }
201+
202+ sqlx::query(
203+ "INSERT INTO repo_collaborators (repo_id, user_id, role, added_by)
204+ VALUES ($1, $2, $3::repo_role, $4)
205+ ON CONFLICT (repo_id, user_id) DO UPDATE SET role = EXCLUDED.role",
206+ )
207+ .bind(ctx.repo.id)
208+ .bind(target_id)
209+ .bind(v::role_str(role))
210+ .bind(actor)
211+ .execute(&state.db)
212+ .await?;
213+
214+ audit(
215+ &state,
216+ actor,
217+ "repo.collaborator_set",
218+ &format!("{}/{} {} = {}", ctx.owner, ctx.repo.name, form.handle.trim(), v::role_str(role)),
219+ )
220+ .await;
221+
222+ Ok(ok(&ctx, "collaborators", "Collaborator saved."))
223+}
224+
225+/// `POST /{owner}/{repo}/settings/collaborators/remove`
226+pub async fn remove_collaborator(
227+ State(state): State<AppState>,
228+ UrlPath((owner, name)): UrlPath<(String, String)>,
229+ CurrentUser(user): CurrentUser,
230+ Form(form): Form<Collaborator>,
231+) -> AppResult<Response> {
232+ let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
233+ ctx.require_settings()?;
234+ let actor = user.as_ref().ok_or(AppError::Unauthorized)?.id;
235+
236+ sqlx::query(
237+ "DELETE FROM repo_collaborators
238+ WHERE repo_id = $1
239+ AND user_id = (SELECT id FROM users WHERE handle = $2)",
240+ )
241+ .bind(ctx.repo.id)
242+ .bind(form.handle.trim())
243+ .execute(&state.db)
244+ .await?;
245+
246+ audit(
247+ &state,
248+ actor,
249+ "repo.collaborator_removed",
250+ &format!("{}/{} {}", ctx.owner, ctx.repo.name, form.handle.trim()),
251+ )
252+ .await;
253+
254+ Ok(ok(&ctx, "collaborators", "Collaborator removed."))
255+}
256+
257+// ─── bookmarks ───────────────────────────────────────────────────────────────
258+
259+#[derive(Deserialize)]
260+pub struct ProtectBookmark {
261+ pub name: String,
262+ pub protected: String,
263+}
264+
265+/// `POST /{owner}/{repo}/settings/bookmarks`
266+pub async fn protect_bookmark(
267+ State(state): State<AppState>,
268+ UrlPath((owner, name)): UrlPath<(String, String)>,
269+ CurrentUser(user): CurrentUser,
270+ Form(form): Form<ProtectBookmark>,
271+) -> AppResult<Response> {
272+ let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
273+ ctx.require_settings()?;
274+
275+ let protected = form.protected == "1";
276+
277+ sqlx::query("UPDATE bookmarks SET protected = $3 WHERE repo_id = $1 AND name = $2")
278+ .bind(ctx.repo.id)
279+ .bind(form.name.trim())
280+ .bind(protected)
281+ .execute(&state.db)
282+ .await?;
283+
284+ Ok(ok(
285+ &ctx,
286+ "bookmarks",
287+ if protected { "Bookmark protected." } else { "Bookmark unprotected." },
288+ ))
289+}
290+
291+// ─── danger zone ─────────────────────────────────────────────────────────────
292+
293+#[derive(Deserialize)]
294+pub struct Archive {
295+ pub archived: String,
296+}
297+
298+/// `POST /{owner}/{repo}/settings/archive`
299+pub async fn archive(
300+ State(state): State<AppState>,
301+ UrlPath((owner, name)): UrlPath<(String, String)>,
302+ CurrentUser(user): CurrentUser,
303+ Form(form): Form<Archive>,
304+) -> AppResult<Response> {
305+ let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
306+ ctx.require_settings()?;
307+ let actor = user.as_ref().ok_or(AppError::Unauthorized)?.id;
308+
309+ let archived = form.archived == "1";
310+ sqlx::query("UPDATE repos SET archived = $2 WHERE id = $1")
311+ .bind(ctx.repo.id)
312+ .bind(archived)
313+ .execute(&state.db)
314+ .await?;
315+
316+ audit(
317+ &state,
318+ actor,
319+ if archived { "repo.archived" } else { "repo.unarchived" },
320+ &format!("{}/{}", ctx.owner, ctx.repo.name),
321+ )
322+ .await;
323+
324+ Ok(ok(
325+ &ctx,
326+ "danger",
327+ if archived { "Repository archived." } else { "Repository unarchived." },
328+ ))
329+}
330+
331+#[derive(Deserialize)]
332+pub struct DeleteRepo {
333+ pub confirm: String,
334+}
335+
336+/// `POST /{owner}/{repo}/settings/delete`
337+pub async fn delete(
338+ State(state): State<AppState>,
339+ UrlPath((owner, name)): UrlPath<(String, String)>,
340+ CurrentUser(user): CurrentUser,
341+ Form(form): Form<DeleteRepo>,
342+) -> AppResult<Response> {
343+ let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
344+ if !ctx.access.can_delete() {
345+ return Err(AppError::Forbidden);
346+ }
347+ let actor = user.as_ref().ok_or(AppError::Unauthorized)?.id;
348+
349+ let expected = format!("{}/{}", ctx.owner, ctx.repo.name);
350+ if form.confirm.trim() != expected {
351+ return Ok(err(&ctx, "danger", "The confirmation did not match."));
352+ }
353+
354+ // Database first: the row is the only thing that makes the repository
355+ // reachable, so removing it is what actually revokes access. If the disk
356+ // removal then fails, the result is an orphaned directory — recoverable and
357+ // invisible — rather than a live row pointing at nothing.
358+ sqlx::query("DELETE FROM repos WHERE id = $1")
359+ .bind(ctx.repo.id)
360+ .execute(&state.db)
361+ .await?;
362+
363+ if let Err(e) = state.store.delete(ctx.store_id()).await {
364+ tracing::error!(repo = %ctx.repo.id, "repository row deleted but storage removal failed: {e}");
365+ }
366+
367+ audit(&state, actor, "repo.deleted", &expected).await;
368+ tracing::warn!(repo = %ctx.repo.id, %expected, actor = %actor, "repository deleted");
369+
370+ Ok(Redirect::to("/").into_response())
371+}
372+
373+// ─── loading ─────────────────────────────────────────────────────────────────
374+
375+async fn load_collaborators(state: &AppState, repo_id: Uuid) -> AppResult<Vec<v::CollaboratorRow>> {
376+ let rows: Vec<(String, RepoRole)> = sqlx::query_as(
377+ "SELECT u.handle::text, c.role
378+ FROM repo_collaborators c JOIN users u ON u.id = c.user_id
379+ WHERE c.repo_id = $1
380+ ORDER BY u.handle",
381+ )
382+ .bind(repo_id)
383+ .fetch_all(&state.db)
384+ .await?;
385+
386+ Ok(rows
387+ .into_iter()
388+ .map(|(handle, role)| v::CollaboratorRow { handle, role })
389+ .collect())
390+}
391+
392+async fn load_bookmarks(state: &AppState, ctx: &RepoContext) -> AppResult<Vec<v::BookmarkRow>> {
393+ let rows: Vec<(String, bool)> =
394+ sqlx::query_as("SELECT name, protected FROM bookmarks WHERE repo_id = $1 ORDER BY name")
395+ .bind(ctx.repo.id)
396+ .fetch_all(&state.db)
397+ .await?;
398+
399+ Ok(rows
400+ .into_iter()
401+ .map(|(name, protected)| v::BookmarkRow {
402+ is_default: name == ctx.repo.default_bookmark,
403+ name,
404+ protected,
405+ })
406+ .collect())
407+}
408+
409+// ─── helpers ─────────────────────────────────────────────────────────────────
410+
411+fn parse_role(s: &str) -> Option<RepoRole> {
412+ match s {
413+ "read" => Some(RepoRole::Read),
414+ "write" => Some(RepoRole::Write),
415+ "maintain" => Some(RepoRole::Maintain),
416+ "admin" => Some(RepoRole::Admin),
417+ _ => None,
418+ }
419+}
420+
421+fn visibility_str(v: df_db::models::Visibility) -> &'static str {
422+ match v {
423+ df_db::models::Visibility::Public => "public",
424+ df_db::models::Visibility::Private => "private",
425+ }
426+}
427+
428+fn ok(ctx: &RepoContext, tab: &str, msg: &str) -> Response {
429+ Redirect::to(&format!(
430+ "{}/settings?tab={tab}&notice={}",
431+ ctx.base(),
432+ urlencode(msg)
433+ ))
434+ .into_response()
435+}
436+
437+fn err(ctx: &RepoContext, tab: &str, msg: &str) -> Response {
438+ Redirect::to(&format!(
439+ "{}/settings?tab={tab}&error={}",
440+ ctx.base(),
441+ urlencode(msg)
442+ ))
443+ .into_response()
444+}
Acrates/df-web/src/routes/review.rs1275 lines+1275−0
@@ −0,0 +1,1275 @@
1+//! Change detail, files, revisions, conflicts, comments, reviews, stacks
2+//! (M3, M4).
3+//!
4+//! Split from `routes::change` — which owns the *list* — because the review
5+//! surface is where most of the product's behaviour lives and the two have very
6+//! little in common beyond resolving a change.
7+//!
8+//! Two rules every handler here follows:
9+//!
10+//! * `RepoContext::load` first, so an unauthorised private repository 404s
11+//! before anything else runs.
12+//! * Permission checks name the capability, not the role: `can_comment`,
13+//! `can_manage_changes`. The mapping from role to capability lives in
14+//! `df_auth::permissions` and nowhere else (spec §6).
15+
16+use axum::extract::{Path as UrlPath, Query, State};
17+use axum::response::{IntoResponse, Redirect, Response};
18+use axum::Form;
19+use df_db::ids::new_id;
20+use df_store::{DiffOpts, RevId};
21+use serde::Deserialize;
22+use uuid::Uuid;
23+
24+use crate::error::{AppError, AppResult};
25+use crate::repo_ctx::RepoContext;
26+use crate::routes::change::{resolve_change, ChangeRecord, Resolution};
27+use crate::routes::settings::urlencode;
28+use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
29+use crate::views::change as cv;
30+use crate::views::repo as rv;
31+use crate::views::review as v;
32+use crate::views::{self, Chrome};
33+
34+/// Everything the change pages load before they diverge.
35+struct Loaded {
36+ ctx: RepoContext,
37+ change: ChangeRecord,
38+ /// (seq, rev), oldest first.
39+ revisions: Vec<(i32, String)>,
40+ author: Option<String>,
41+ can_manage: bool,
42+}
43+
44+impl Loaded {
45+ fn head(&self) -> Option<&str> {
46+ self.revisions.last().map(|(_, r)| r.as_str())
47+ }
48+}
49+
50+/// Resolve the repo and the change, or produce the response that stands in for
51+/// it — a 404, or the disambiguation page for an ambiguous prefix.
52+async fn load(
53+ state: &AppState,
54+ owner: &str,
55+ name: &str,
56+ reference: &str,
57+ user: Option<&df_db::models::User>,
58+ csrf: &str,
59+ nonce: &str,
60+) -> AppResult<Result<Loaded, Response>> {
61+ let ctx = RepoContext::load(state, owner, name, user).await?;
62+
63+ let change = match resolve_change(state, ctx.repo.id, reference).await? {
64+ Resolution::One(c) => *c,
65+ Resolution::Ambiguous(candidates) => {
66+ let body = maud::html! {
67+ (rv::header(&ctx, "changes"))
68+ (cv::ambiguous(&ctx, reference, &candidates))
69+ };
70+ return Ok(Err(views::page(
71+ Chrome { title: "Ambiguous change id", user, csrf, nonce },
72+ body,
73+ )
74+ .into_response()));
75+ }
76+ Resolution::None => return Err(AppError::NotFound),
77+ };
78+
79+ let revisions: Vec<(i32, String)> =
80+ sqlx::query_as("SELECT seq, rev FROM revisions WHERE change_id_fk = $1 ORDER BY seq")
81+ .bind(change.id)
82+ .fetch_all(&state.db)
83+ .await?;
84+
85+ let author: Option<String> = sqlx::query_scalar(
86+ "SELECT u.handle::text FROM users u
87+ JOIN changes c ON c.author_user_id = u.id WHERE c.id = $1",
88+ )
89+ .bind(change.id)
90+ .fetch_optional(&state.db)
91+ .await?;
92+
93+ // The author of a change manages it even without the maintain role — it is
94+ // their work, and requiring a maintainer to retitle your own change would be
95+ // absurd. Everything else still needs the role.
96+ let is_author = matches!((user, &author), (Some(u), Some(a)) if &u.handle == a);
97+ let can_manage = ctx.access.can_manage_changes() || is_author;
98+
99+ Ok(Ok(Loaded { ctx, change, revisions, author, can_manage }))
100+}
101+
102+impl Loaded {
103+ fn head_view<'a>(&'a self, csrf: &'a str, can_comment: bool) -> v::ChangeHead<'a> {
104+ v::ChangeHead {
105+ number: self.change.number,
106+ change_id: &self.change.change_id,
107+ synthetic: self.change.synthetic,
108+ title: &self.change.title,
109+ state: &self.change.state,
110+ conflicted: self.change.conflicted,
111+ target_bookmark: &self.change.target_bookmark,
112+ author: self.author.as_deref(),
113+ revision_count: self.revisions.len(),
114+ can_manage: self.can_manage,
115+ can_comment,
116+ csrf,
117+ }
118+ }
119+}
120+
121+// ─── overview ────────────────────────────────────────────────────────────────
122+
123+#[derive(Deserialize, Default)]
124+pub struct Flash {
125+ pub error: Option<String>,
126+ pub notice: Option<String>,
127+}
128+
129+/// `GET /{owner}/{repo}/changes/{ref}`
130+pub async fn overview(
131+ State(state): State<AppState>,
132+ UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
133+ Query(flash): Query<Flash>,
134+ CurrentUser(user): CurrentUser,
135+ CsrfToken(csrf): CsrfToken,
136+ Nonce(nonce): Nonce,
137+) -> AppResult<Response> {
138+ let l = match load(&state, &owner, &name, &reference, user.as_deref(), &csrf, &nonce).await? {
139+ Ok(l) => l,
140+ Err(res) => return Ok(res),
141+ };
142+
143+ let can_comment = user.is_some() && l.ctx.access.can_comment();
144+ let head = l.head_view(&csrf, can_comment);
145+
146+ let all_comments = load_comments(&state, &l.ctx, l.change.id).await?;
147+ // Top-level and orphaned comments live in the timeline; the rest belong in
148+ // the diff, where the line they are about is (spec §5 step 5).
149+ let (orphaned, rest): (Vec<_>, Vec<_>) = all_comments
150+ .into_iter()
151+ .partition(|c| c.anchor_state == "orphaned");
152+ let top_level: Vec<v::CommentRow> =
153+ rest.into_iter().filter(|c| c.anchor_path.is_none()).collect();
154+
155+ let reviews = load_reviews(&state, &l.ctx, l.change.id, l.head()).await?;
156+ let events = load_events(&state, l.ctx.repo.id, l.change.id).await?;
157+
158+ let viewer_reviewed = match (&user, l.head()) {
159+ (Some(u), Some(_)) => reviews
160+ .iter()
161+ .any(|r| r.reviewer == u.handle && r.is_head),
162+ _ => false,
163+ };
164+
165+ // Cross-references resolve against this repository (spec §8).
166+ let description_html = crate::routes::issue::render(&l.ctx, &l.change.description);
167+
168+ let body = maud::html! {
169+ (rv::header(&l.ctx, "changes"))
170+ (v::header(&l.ctx, &head, "overview"))
171+ @if let Some(e) = &flash.error { div .banner.banner-error role="alert" { (e) } }
172+ @if let Some(n) = &flash.notice { div .banner.banner-ok role="status" { (n) } }
173+ (v::overview(&l.ctx, &head, v::Overview {
174+ description_html: &description_html,
175+ description_raw: &l.change.description,
176+ comments: &top_level,
177+ orphaned: &orphaned,
178+ reviews: &reviews,
179+ events: &events,
180+ viewer_reviewed,
181+ }))
182+ };
183+
184+ Ok(views::page(
185+ Chrome {
186+ title: &format!("{} · {}/{}", l.change.title, l.ctx.owner, l.ctx.repo.name),
187+ user: user.as_deref(),
188+ csrf: &csrf,
189+ nonce: &nonce,
190+ },
191+ body,
192+ )
193+ .into_response())
194+}
195+
196+// ─── files ───────────────────────────────────────────────────────────────────
197+
198+#[derive(Deserialize, Default)]
199+pub struct FilesQuery {
200+ pub rev: Option<String>,
201+ pub against: Option<String>,
202+}
203+
204+/// `GET /{owner}/{repo}/changes/{ref}/files`
205+///
206+/// `rev` and `against` are revision tokens, and both are validated against the
207+/// revisions *of this change* before reaching the store. That check is what
208+/// stops the query string being used to diff arbitrary objects out of a
209+/// repository the viewer can only partly see.
210+pub async fn files(
211+ State(state): State<AppState>,
212+ UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
213+ Query(q): Query<FilesQuery>,
214+ CurrentUser(user): CurrentUser,
215+ CsrfToken(csrf): CsrfToken,
216+ Nonce(nonce): Nonce,
217+) -> AppResult<Response> {
218+ let l = match load(&state, &owner, &name, &reference, user.as_deref(), &csrf, &nonce).await? {
219+ Ok(l) => l,
220+ Err(res) => return Ok(res),
221+ };
222+
223+ let can_comment = user.is_some() && l.ctx.access.can_comment();
224+ let head = l.head_view(&csrf, can_comment);
225+
226+ let known = |r: &str| l.revisions.iter().any(|(_, rev)| rev == r);
227+
228+ let rev = match q.rev.as_deref() {
229+ Some(r) if known(r) => Some(r.to_owned()),
230+ Some(_) => return Err(AppError::NotFound),
231+ None => l.head().map(str::to_owned),
232+ };
233+ let against = match q.against.as_deref().filter(|s| !s.is_empty()) {
234+ Some(r) if known(r) => Some(r.to_owned()),
235+ Some(_) => return Err(AppError::NotFound),
236+ None => None,
237+ };
238+
239+ let opts = DiffOpts {
240+ context_lines: 3,
241+ max_files: state.config.max_diff_files,
242+ max_lines: state.config.max_diff_lines,
243+ };
244+
245+ let diff = match (&rev, &against) {
246+ (Some(r), Some(a)) => state
247+ .store
248+ .diff(
249+ l.ctx.store_id(),
250+ &RevId::from_stored(a.clone()),
251+ &RevId::from_stored(r.clone()),
252+ opts,
253+ )
254+ .await
255+ .ok(),
256+ (Some(r), None) => state
257+ .store
258+ .diff_from_parent(l.ctx.store_id(), &RevId::from_stored(r.clone()), opts)
259+ .await
260+ .ok(),
261+ _ => None,
262+ };
263+
264+ // Only inline comments that still have a home in the diff.
265+ let comments: Vec<v::CommentRow> = load_comments(&state, &l.ctx, l.change.id)
266+ .await?
267+ .into_iter()
268+ .filter(|c| c.anchor_path.is_some() && c.anchor_state != "orphaned")
269+ .collect();
270+
271+ let body = maud::html! {
272+ (rv::header(&l.ctx, "changes"))
273+ (v::header(&l.ctx, &head, "files"))
274+ (v::files(&l.ctx, &head, v::FilesView {
275+ diff: diff.as_ref(),
276+ comments: &comments,
277+ rev: rev.as_deref().unwrap_or(""),
278+ against: against.as_deref(),
279+ revisions: &l.revisions,
280+ }))
281+ };
282+
283+ Ok(views::page(
284+ Chrome {
285+ title: &format!("Files · {}", l.change.title),
286+ user: user.as_deref(),
287+ csrf: &csrf,
288+ nonce: &nonce,
289+ },
290+ body,
291+ )
292+ .into_response())
293+}
294+
295+// ─── revisions ───────────────────────────────────────────────────────────────
296+
297+/// `GET /{owner}/{repo}/changes/{ref}/revisions`
298+pub async fn revisions(
299+ State(state): State<AppState>,
300+ UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
301+ CurrentUser(user): CurrentUser,
302+ CsrfToken(csrf): CsrfToken,
303+ Nonce(nonce): Nonce,
304+) -> AppResult<Response> {
305+ let l = match load(&state, &owner, &name, &reference, user.as_deref(), &csrf, &nonce).await? {
306+ Ok(l) => l,
307+ Err(res) => return Ok(res),
308+ };
309+ let head = l.head_view(&csrf, false);
310+
311+ let rows: Vec<(
312+ i32,
313+ String,
314+ String,
315+ String,
316+ chrono::DateTime<chrono::Utc>,
317+ bool,
318+ Option<String>,
319+ )> = sqlx::query_as(
320+ "SELECT r.seq, r.rev, r.message, r.author_name, r.pushed_at, r.conflicted,
321+ u.handle::text
322+ FROM revisions r
323+ LEFT JOIN users u ON u.id = r.pushed_by
324+ WHERE r.change_id_fk = $1 ORDER BY r.seq",
325+ )
326+ .bind(l.change.id)
327+ .fetch_all(&state.db)
328+ .await?;
329+
330+ let revs: Vec<v::RevisionDetail> = rows
331+ .into_iter()
332+ .map(
333+ |(seq, rev, message, author_name, pushed_at, conflicted, pushed_by)| {
334+ v::RevisionDetail {
335+ seq,
336+ rev,
337+ message,
338+ author_name,
339+ pushed_at,
340+ conflicted,
341+ pushed_by,
342+ }
343+ },
344+ )
345+ .collect();
346+
347+ let body = maud::html! {
348+ (rv::header(&l.ctx, "changes"))
349+ (v::header(&l.ctx, &head, "revisions"))
350+ (v::revisions(&l.ctx, &head, &revs))
351+ };
352+
353+ Ok(views::page(
354+ Chrome {
355+ title: &format!("Revisions · {}", l.change.title),
356+ user: user.as_deref(),
357+ csrf: &csrf,
358+ nonce: &nonce,
359+ },
360+ body,
361+ )
362+ .into_response())
363+}
364+
365+// ─── conflicts ───────────────────────────────────────────────────────────────
366+
367+/// `GET /{owner}/{repo}/changes/{ref}/conflicts`
368+pub async fn conflicts(
369+ State(state): State<AppState>,
370+ UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
371+ CurrentUser(user): CurrentUser,
372+ CsrfToken(csrf): CsrfToken,
373+ Nonce(nonce): Nonce,
374+) -> AppResult<Response> {
375+ let l = match load(&state, &owner, &name, &reference, user.as_deref(), &csrf, &nonce).await? {
376+ Ok(l) => l,
377+ Err(res) => return Ok(res),
378+ };
379+ let head = l.head_view(&csrf, false);
380+
381+ let files = match l.head() {
382+ Some(rev) => state
383+ .store
384+ .conflicts(l.ctx.store_id(), &RevId::from_stored(rev.to_owned()))
385+ .await
386+ .unwrap_or_else(|e| {
387+ tracing::warn!(change = %l.change.id, "reading conflicts failed: {e}");
388+ Vec::new()
389+ }),
390+ None => Vec::new(),
391+ };
392+
393+ let body = maud::html! {
394+ (rv::header(&l.ctx, "changes"))
395+ (v::header(&l.ctx, &head, "conflicts"))
396+ (v::conflicts(&l.ctx, &head, &files))
397+ };
398+
399+ Ok(views::page(
400+ Chrome {
401+ title: &format!("Conflicts · {}", l.change.title),
402+ user: user.as_deref(),
403+ csrf: &csrf,
404+ nonce: &nonce,
405+ },
406+ body,
407+ )
408+ .into_response())
409+}
410+
411+// ─── comments ────────────────────────────────────────────────────────────────
412+
413+#[derive(Deserialize)]
414+pub struct NewComment {
415+ pub body: String,
416+ /// Present for inline comments.
417+ pub path: Option<String>,
418+ pub line: Option<i32>,
419+ pub side: Option<String>,
420+ pub rev: Option<String>,
421+ pub context: Option<String>,
422+}
423+
424+/// `POST /{owner}/{repo}/changes/{ref}/comments`
425+pub async fn create_comment(
426+ State(state): State<AppState>,
427+ UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
428+ CurrentUser(user): CurrentUser,
429+ CsrfToken(csrf): CsrfToken,
430+ Form(form): Form<NewComment>,
431+) -> AppResult<Response> {
432+ let Some(user) = user else {
433+ return Err(AppError::Unauthorized);
434+ };
435+ let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? {
436+ Ok(l) => l,
437+ Err(_) => return Err(AppError::NotFound),
438+ };
439+
440+ if !l.ctx.access.can_comment() {
441+ return Err(AppError::Forbidden);
442+ }
443+
444+ let body = form.body.trim();
445+ if body.is_empty() {
446+ return Ok(back(&l, "Comments cannot be empty."));
447+ }
448+ // A comment long enough to be a denial-of-service is not a comment.
449+ if body.len() > 64 * 1024 {
450+ return Ok(back(&l, "That comment is too long."));
451+ }
452+
453+ // The anchor revision is resolved to a row id, and only from revisions that
454+ // belong to this change — a forged `rev` cannot attach a comment to somebody
455+ // else's change.
456+ let anchor_revision: Option<Uuid> = match form.rev.as_deref() {
457+ Some(rev) => sqlx::query_scalar(
458+ "SELECT id FROM revisions WHERE change_id_fk = $1 AND rev = $2",
459+ )
460+ .bind(l.change.id)
461+ .bind(rev)
462+ .fetch_optional(&state.db)
463+ .await?,
464+ None => None,
465+ };
466+
467+ let is_inline = form.path.is_some() && form.line.is_some();
468+ let side = form
469+ .side
470+ .as_deref()
471+ .filter(|s| *s == "old" || *s == "new")
472+ .unwrap_or("new");
473+
474+ sqlx::query(
475+ "INSERT INTO comments (id, repo_id, change_id_fk, author_user_id, body,
476+ anchor_revision, anchor_path, anchor_line, anchor_side,
477+ anchor_context)
478+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
479+ )
480+ .bind(new_id())
481+ .bind(l.ctx.repo.id)
482+ .bind(l.change.id)
483+ .bind(user.id)
484+ .bind(body)
485+ .bind(anchor_revision)
486+ .bind(is_inline.then(|| form.path.clone()).flatten())
487+ .bind(is_inline.then_some(form.line).flatten())
488+ .bind(is_inline.then_some(side))
489+ // The line's text at the time of writing. This is what anchor rebasing
490+ // matches on, and what an outdated comment still shows (spec §5).
491+ .bind(is_inline.then(|| form.context.clone()).flatten())
492+ .execute(&state.db)
493+ .await?;
494+
495+ crate::routes::issue::record_references(
496+ &state,
497+ l.ctx.repo.id,
498+ "change",
499+ l.change.id,
500+ body,
501+ )
502+ .await;
503+
504+ event(
505+ &state,
506+ l.ctx.repo.id,
507+ Some(user.id),
508+ "change.commented",
509+ l.change.id,
510+ serde_json::json!({}),
511+ )
512+ .await;
513+
514+ touch(&state, l.change.id).await;
515+
516+ // Back to where the comment was written: the diff for an inline comment,
517+ // the overview for a top-level one.
518+ Ok(if is_inline {
519+ Redirect::to(&format!(
520+ "{}/changes/{}/files{}",
521+ l.ctx.base(),
522+ l.change.number,
523+ form.rev
524+ .as_deref()
525+ .map(|r| format!("?rev={r}"))
526+ .unwrap_or_default()
527+ ))
528+ .into_response()
529+ } else {
530+ Redirect::to(&format!("{}/changes/{}", l.ctx.base(), l.change.number)).into_response()
531+ })
532+}
533+
534+/// `POST /{owner}/{repo}/changes/{ref}/comments/{id}/resolve`
535+pub async fn resolve_comment(
536+ State(state): State<AppState>,
537+ UrlPath((owner, name, reference, comment_id)): UrlPath<(String, String, String, Uuid)>,
538+ CurrentUser(user): CurrentUser,
539+ CsrfToken(csrf): CsrfToken,
540+) -> AppResult<Response> {
541+ let Some(user) = user else {
542+ return Err(AppError::Unauthorized);
543+ };
544+ let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? {
545+ Ok(l) => l,
546+ Err(_) => return Err(AppError::NotFound),
547+ };
548+ if !l.can_manage {
549+ return Err(AppError::Forbidden);
550+ }
551+
552+ // Scoped to this change, so a comment id from another repository does
553+ // nothing here.
554+ sqlx::query(
555+ "UPDATE comments SET resolved_at = now(), resolved_by = $3
556+ WHERE id = $1 AND change_id_fk = $2 AND resolved_at IS NULL",
557+ )
558+ .bind(comment_id)
559+ .bind(l.change.id)
560+ .bind(user.id)
561+ .execute(&state.db)
562+ .await?;
563+
564+ Ok(back(&l, ""))
565+}
566+
567+// ─── reviews ─────────────────────────────────────────────────────────────────
568+
569+#[derive(Deserialize)]
570+pub struct NewReview {
571+ pub verdict: String,
572+ pub body: Option<String>,
573+}
574+
575+/// `POST /{owner}/{repo}/changes/{ref}/reviews`
576+pub async fn create_review(
577+ State(state): State<AppState>,
578+ UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
579+ CurrentUser(user): CurrentUser,
580+ CsrfToken(csrf): CsrfToken,
581+ Form(form): Form<NewReview>,
582+) -> AppResult<Response> {
583+ let Some(user) = user else {
584+ return Err(AppError::Unauthorized);
585+ };
586+ let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? {
587+ Ok(l) => l,
588+ Err(_) => return Err(AppError::NotFound),
589+ };
590+ if !l.ctx.access.can_comment() {
591+ return Err(AppError::Forbidden);
592+ }
593+
594+ if !matches!(form.verdict.as_str(), "approve" | "request_changes" | "comment") {
595+ return Ok(back(&l, "Unknown verdict."));
596+ }
597+
598+ // A review is *of a revision*, not of a change. That is what makes an
599+ // approval go stale when the author pushes again, and it is the whole point
600+ // of indexing revisions separately.
601+ let Some(head) = l.head() else {
602+ return Ok(back(&l, "This change has no revisions to review."));
603+ };
604+ let revision_id: Uuid =
605+ sqlx::query_scalar("SELECT id FROM revisions WHERE change_id_fk = $1 AND rev = $2")
606+ .bind(l.change.id)
607+ .bind(head)
608+ .fetch_one(&state.db)
609+ .await?;
610+
611+ sqlx::query(
612+ "INSERT INTO reviews (id, change_id_fk, revision_id, reviewer_id, verdict, body)
613+ VALUES ($1, $2, $3, $4, $5::review_verdict, $6)",
614+ )
615+ .bind(new_id())
616+ .bind(l.change.id)
617+ .bind(revision_id)
618+ .bind(user.id)
619+ .bind(&form.verdict)
620+ .bind(form.body.as_deref().map(str::trim).filter(|s| !s.is_empty()))
621+ .execute(&state.db)
622+ .await?;
623+
624+ event(
625+ &state,
626+ l.ctx.repo.id,
627+ Some(user.id),
628+ "change.reviewed",
629+ l.change.id,
630+ serde_json::json!({ "verdict": form.verdict }),
631+ )
632+ .await;
633+ touch(&state, l.change.id).await;
634+
635+ Ok(back(&l, ""))
636+}
637+
638+// ─── state and editing ───────────────────────────────────────────────────────
639+
640+#[derive(Deserialize)]
641+pub struct EditChange {
642+ pub title: String,
643+ pub description: Option<String>,
644+}
645+
646+/// `POST /{owner}/{repo}/changes/{ref}/edit`
647+pub async fn edit(
648+ State(state): State<AppState>,
649+ UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
650+ CurrentUser(user): CurrentUser,
651+ CsrfToken(csrf): CsrfToken,
652+ Form(form): Form<EditChange>,
653+) -> AppResult<Response> {
654+ let Some(user) = user else {
655+ return Err(AppError::Unauthorized);
656+ };
657+ let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? {
658+ Ok(l) => l,
659+ Err(_) => return Err(AppError::NotFound),
660+ };
661+ if !l.can_manage {
662+ return Err(AppError::Forbidden);
663+ }
664+
665+ let title: String = form.title.trim().chars().take(300).collect();
666+ if title.is_empty() {
667+ return Ok(back(&l, "A change needs a title."));
668+ }
669+
670+ let description = form.description.as_deref().unwrap_or("").trim();
671+
672+ sqlx::query(
673+ "UPDATE changes SET title = $2, description = $3, updated_at = now() WHERE id = $1",
674+ )
675+ .bind(l.change.id)
676+ .bind(&title)
677+ .bind(description)
678+ .execute(&state.db)
679+ .await?;
680+
681+ // `#123` in a change description makes the issue show the change under
682+ // "Referenced by". Recorded on write rather than scanned on read.
683+ crate::routes::issue::record_references(
684+ &state,
685+ l.ctx.repo.id,
686+ "change",
687+ l.change.id,
688+ description,
689+ )
690+ .await;
691+
692+ Ok(back(&l, ""))
693+}
694+
695+#[derive(Deserialize)]
696+pub struct SetState {
697+ pub state: String,
698+}
699+
700+/// `POST /{owner}/{repo}/changes/{ref}/state`
701+///
702+/// Draft, abandon, and reopen. These are the states the *author* owns; the
703+/// indexer computes merged and must never overwrite them (decided, and pinned
704+/// by `indexer::next_state`).
705+pub async fn set_state(
706+ State(state): State<AppState>,
707+ UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
708+ CurrentUser(user): CurrentUser,
709+ CsrfToken(csrf): CsrfToken,
710+ Form(form): Form<SetState>,
711+) -> AppResult<Response> {
712+ let Some(user) = user else {
713+ return Err(AppError::Unauthorized);
714+ };
715+ let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? {
716+ Ok(l) => l,
717+ Err(_) => return Err(AppError::NotFound),
718+ };
719+ if !l.can_manage {
720+ return Err(AppError::Forbidden);
721+ }
722+
723+ // `merged` is not settable here: it is a fact about the target bookmark, and
724+ // letting the UI assert it would make the state lie about the repository.
725+ let next = match form.state.as_str() {
726+ "draft" => "draft",
727+ "open" => "open",
728+ "abandoned" => "abandoned",
729+ _ => return Ok(back(&l, "Unknown state.")),
730+ };
731+
732+ sqlx::query("UPDATE changes SET state = $2::change_state, updated_at = now() WHERE id = $1")
733+ .bind(l.change.id)
734+ .bind(next)
735+ .execute(&state.db)
736+ .await?;
737+
738+ let kind = match (l.change.state.as_str(), next) {
739+ (_, "draft") => "change.drafted",
740+ (_, "abandoned") => "change.abandoned",
741+ ("draft", "open") => "change.ready",
742+ (_, "open") => "change.reopened",
743+ _ => "change.updated",
744+ };
745+ event(&state, l.ctx.repo.id, Some(user.id), kind, l.change.id, serde_json::json!({})).await;
746+
747+ Ok(back(&l, ""))
748+}
749+
750+// ─── stacks ──────────────────────────────────────────────────────────────────
751+
752+/// `GET /{owner}/{repo}/stacks/{change_id}`
753+pub async fn stack(
754+ State(state): State<AppState>,
755+ UrlPath((owner, name, change_id)): UrlPath<(String, String, String)>,
756+ CurrentUser(user): CurrentUser,
757+ CsrfToken(csrf): CsrfToken,
758+ Nonce(nonce): Nonce,
759+) -> AppResult<Response> {
760+ let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
761+
762+ let nodes = load_stack(&state, ctx.repo.id, &change_id).await?;
763+ if nodes.is_empty() {
764+ return Err(AppError::NotFound);
765+ }
766+
767+ let body = maud::html! {
768+ (rv::header(&ctx, "changes"))
769+ (v::stack(&ctx, &nodes, &change_id))
770+ @if nodes.len() > 1 && ctx.access.can_manage_changes() {
771+ div .panel {
772+ form method="post" action=(format!("{}/stacks/{change_id}/merge", ctx.base())) {
773+ input type="hidden" name="_csrf" value=(csrf);
774+ button .btn.btn-primary type="submit" { "Merge stack" }
775+ p .hint {
776+ "Lands the whole chain bottom-up in one action. If any change in \
777+ the stack is conflicted or not ready, nothing is merged."
778+ }
779+ }
780+ }
781+ }
782+ };
783+
784+ Ok(views::page(
785+ Chrome {
786+ title: &format!("Stack · {}/{}", ctx.owner, ctx.repo.name),
787+ user: user.as_deref(),
788+ csrf: &csrf,
789+ nonce: &nonce,
790+ },
791+ body,
792+ )
793+ .into_response())
794+}
795+
796+/// Walk `change_edges` down to the bottom of the stack and back up.
797+///
798+/// Reads the precomputed edges rather than the commit graph — spec §4: "do not
799+/// recompute the graph on page render".
800+async fn load_stack(
801+ state: &AppState,
802+ repo_id: Uuid,
803+ change_id: &str,
804+) -> AppResult<Vec<v::StackNode>> {
805+ // A recursive CTE in both directions from the requested change. Depth is
806+ // bounded so a cycle in the edge table — which should be impossible, but the
807+ // page must not hang if one ever appears — terminates.
808+ let rows: Vec<(i64, String, bool, String, String, bool, i32)> = sqlx::query_as(
809+ r#"
810+ WITH RECURSIVE start AS (
811+ SELECT id FROM changes WHERE repo_id = $1 AND change_id = $2
812+ ),
813+ down AS (
814+ SELECT c.id, 0 AS depth FROM changes c JOIN start s ON s.id = c.id
815+ UNION ALL
816+ SELECT e.parent_change, d.depth - 1
817+ FROM change_edges e JOIN down d ON d.id = e.child_change
818+ WHERE e.repo_id = $1 AND d.depth > -50
819+ ),
820+ up AS (
821+ SELECT c.id, 0 AS depth FROM changes c JOIN start s ON s.id = c.id
822+ UNION ALL
823+ SELECT e.child_change, u.depth + 1
824+ FROM change_edges e JOIN up u ON u.id = e.parent_change
825+ WHERE e.repo_id = $1 AND u.depth < 50
826+ ),
827+ all_nodes AS (
828+ SELECT id, min(depth) AS depth FROM (
829+ SELECT id, depth FROM down UNION ALL SELECT id, depth FROM up
830+ ) x GROUP BY id
831+ )
832+ SELECT c.number, c.change_id, c.synthetic, c.title, c.state::text, c.conflicted,
833+ (n.depth - (SELECT min(depth) FROM all_nodes))::int AS rel_depth
834+ FROM all_nodes n JOIN changes c ON c.id = n.id
835+ ORDER BY n.depth
836+ "#,
837+ )
838+ .bind(repo_id)
839+ .bind(change_id)
840+ .fetch_all(&state.db)
841+ .await?;
842+
843+ Ok(rows
844+ .into_iter()
845+ .map(
846+ |(number, cid, synthetic, title, st, conflicted, depth)| v::StackNode {
847+ is_current: cid == change_id,
848+ number,
849+ change_id: cid,
850+ synthetic,
851+ title,
852+ state: st,
853+ conflicted,
854+ depth: depth.max(0) as usize,
855+ },
856+ )
857+ .collect())
858+}
859+
860+// ─── loading helpers ─────────────────────────────────────────────────────────
861+
862+async fn load_comments(
863+ state: &AppState,
864+ ctx: &RepoContext,
865+ change_id: Uuid,
866+) -> AppResult<Vec<v::CommentRow>> {
867+ type Row = (
868+ Uuid,
869+ String,
870+ String,
871+ chrono::DateTime<chrono::Utc>,
872+ Option<chrono::DateTime<chrono::Utc>>,
873+ Option<String>,
874+ Option<i32>,
875+ Option<String>,
876+ String,
877+ Option<String>,
878+ Option<chrono::DateTime<chrono::Utc>>,
879+ );
880+
881+ let rows: Vec<Row> = sqlx::query_as(
882+ "SELECT c.id, u.handle::text, c.body, c.created_at, c.edited_at,
883+ c.anchor_path, c.anchor_line, c.anchor_side, c.anchor_state::text,
884+ c.anchor_context, c.resolved_at
885+ FROM comments c JOIN users u ON u.id = c.author_user_id
886+ WHERE c.change_id_fk = $1
887+ ORDER BY c.created_at",
888+ )
889+ .bind(change_id)
890+ .fetch_all(&state.db)
891+ .await?;
892+
893+ Ok(rows
894+ .into_iter()
895+ .map(|r| v::CommentRow {
896+ id: r.0,
897+ author: r.1,
898+ // Comment bodies are user input rendered on our origin, so they go
899+ // through the same sanitiser a README does, and then get their
900+ // cross-references resolved.
901+ body_html: crate::routes::issue::render(ctx, &r.2),
902+ created_at: r.3,
903+ edited: r.4.is_some(),
904+ anchor_path: r.5,
905+ anchor_line: r.6,
906+ anchor_side: r.7,
907+ anchor_state: r.8,
908+ anchor_context: r.9,
909+ resolved: r.10.is_some(),
910+ })
911+ .collect())
912+}
913+
914+async fn load_reviews(
915+ state: &AppState,
916+ ctx: &RepoContext,
917+ change_id: Uuid,
918+ head: Option<&str>,
919+) -> AppResult<Vec<v::ReviewRow>> {
920+ let rows: Vec<(String, String, Option<String>, chrono::DateTime<chrono::Utc>, String)> =
921+ sqlx::query_as(
922+ "SELECT u.handle::text, r.verdict::text, r.body, r.created_at, rev.rev
923+ FROM reviews r
924+ JOIN users u ON u.id = r.reviewer_id
925+ JOIN revisions rev ON rev.id = r.revision_id
926+ WHERE r.change_id_fk = $1
927+ ORDER BY r.created_at DESC",
928+ )
929+ .bind(change_id)
930+ .fetch_all(&state.db)
931+ .await?;
932+
933+ Ok(rows
934+ .into_iter()
935+ .map(|(reviewer, verdict, body, created_at, rev)| v::ReviewRow {
936+ is_head: head == Some(rev.as_str()),
937+ rev: df_store::abbreviate_rev(&rev).to_owned(),
938+ reviewer,
939+ verdict,
940+ body_html: body.map(|b| crate::routes::issue::render(ctx, &b)).unwrap_or_default(),
941+ created_at,
942+ })
943+ .collect())
944+}
945+
946+async fn load_events(
947+ state: &AppState,
948+ repo_id: Uuid,
949+ change_id: Uuid,
950+) -> AppResult<Vec<v::EventRow>> {
951+ let rows: Vec<(String, Option<String>, chrono::DateTime<chrono::Utc>, serde_json::Value)> =
952+ sqlx::query_as(
953+ "SELECT e.kind, u.handle::text, e.created_at, e.payload
954+ FROM events e LEFT JOIN users u ON u.id = e.actor_id
955+ WHERE e.repo_id = $1 AND e.subject_type = 'change' AND e.subject_id = $2
956+ -- `change.commented` duplicates the comment itself in the
957+ -- timeline; the comment is the better rendering of it.
958+ AND e.kind <> 'change.commented'
959+ ORDER BY e.created_at
960+ LIMIT 200",
961+ )
962+ .bind(repo_id)
963+ .bind(change_id)
964+ .fetch_all(&state.db)
965+ .await?;
966+
967+ Ok(rows
968+ .into_iter()
969+ .map(|(kind, actor, created_at, payload)| v::EventRow {
970+ kind,
971+ actor,
972+ created_at,
973+ payload,
974+ })
975+ .collect())
976+}
977+
978+// ─── small helpers ───────────────────────────────────────────────────────────
979+
980+/// Record a timeline event. Best-effort — losing one must not fail the action
981+/// that caused it, but it is logged loudly because a gap in the timeline is not
982+/// something a reader can detect.
983+pub async fn event(
984+ state: &AppState,
985+ repo_id: Uuid,
986+ actor: Option<Uuid>,
987+ kind: &str,
988+ subject: Uuid,
989+ payload: serde_json::Value,
990+) {
991+ if let Err(e) = sqlx::query(
992+ "INSERT INTO events (id, repo_id, actor_id, kind, subject_type, subject_id, payload)
993+ VALUES ($1, $2, $3, $4, 'change', $5, $6)",
994+ )
995+ .bind(new_id())
996+ .bind(repo_id)
997+ .bind(actor)
998+ .bind(kind)
999+ .bind(subject)
1000+ .bind(payload)
1001+ .execute(&state.db)
1002+ .await
1003+ {
1004+ tracing::error!(%kind, "writing a timeline event failed: {e}");
1005+ }
1006+}
1007+
1008+/// Bump `updated_at` so the change list orders by real activity, not just pushes.
1009+async fn touch(state: &AppState, change_id: Uuid) {
1010+ let _ = sqlx::query("UPDATE changes SET updated_at = now() WHERE id = $1")
1011+ .bind(change_id)
1012+ .execute(&state.db)
1013+ .await;
1014+}
1015+
1016+fn back(l: &Loaded, error: &str) -> Response {
1017+ let base = format!("{}/changes/{}", l.ctx.base(), l.change.number);
1018+ if error.is_empty() {
1019+ Redirect::to(&base).into_response()
1020+ } else {
1021+ Redirect::to(&format!("{base}?error={}", urlencode(error))).into_response()
1022+ }
1023+}
1024+
1025+// ─── merging (decided §13.2, §13.3) ──────────────────────────────────────────
1026+
1027+/// `POST /{owner}/{repo}/changes/{ref}/merge`
1028+///
1029+/// Server-side fast-forward, falling back to a merge commit (decided §13.2).
1030+/// Refuses on conflict without writing anything.
1031+pub async fn merge(
1032+ State(state): State<AppState>,
1033+ UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
1034+ CurrentUser(user): CurrentUser,
1035+ CsrfToken(csrf): CsrfToken,
1036+) -> AppResult<Response> {
1037+ let Some(user) = user else {
1038+ return Err(AppError::Unauthorized);
1039+ };
1040+ let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? {
1041+ Ok(l) => l,
1042+ Err(_) => return Err(AppError::NotFound),
1043+ };
1044+
1045+ // Merging is a maintainer action even for your own change: landing work on
1046+ // a shared bookmark is not the same permission as writing it.
1047+ if !l.ctx.access.can_manage_changes() {
1048+ return Err(AppError::Forbidden);
1049+ }
1050+ if l.ctx.repo.archived {
1051+ return Ok(back(&l, "This repository is archived."));
1052+ }
1053+
1054+ match land(&state, &l.ctx, &l.change, &user).await? {
1055+ Landed::Ok(msg) => {
1056+ enqueue_reindex(&state, l.ctx.repo.id).await;
1057+ Ok(back_notice(&l, &msg))
1058+ }
1059+ Landed::Refused(msg) => Ok(back(&l, &msg)),
1060+ }
1061+}
1062+
1063+/// `POST /{owner}/{repo}/stacks/{change_id}/merge`
1064+///
1065+/// Decided §13.3: "Merge stack" lands the whole chain bottom-up in one action.
1066+///
1067+/// Bottom-up matters: landing the top of a stack first would either fail or
1068+/// pull the changes below it in as an unreviewed side effect. If any change in
1069+/// the chain refuses, the ones already landed stay landed and the rest do not —
1070+/// reported precisely, because pretending it was atomic would be a lie about
1071+/// what is on the bookmark.
1072+pub async fn merge_stack(
1073+ State(state): State<AppState>,
1074+ UrlPath((owner, name, change_id)): UrlPath<(String, String, String)>,
1075+ CurrentUser(user): CurrentUser,
1076+) -> AppResult<Response> {
1077+ let Some(user) = user else {
1078+ return Err(AppError::Unauthorized);
1079+ };
1080+ let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?;
1081+ if !ctx.access.can_manage_changes() {
1082+ return Err(AppError::Forbidden);
1083+ }
1084+ if ctx.repo.archived {
1085+ return Err(AppError::BadRequest("This repository is archived.".into()));
1086+ }
1087+
1088+ let nodes = load_stack(&state, ctx.repo.id, &change_id).await?;
1089+ if nodes.is_empty() {
1090+ return Err(AppError::NotFound);
1091+ }
1092+
1093+ // `load_stack` returns bottom-first; that is the order to land in.
1094+ let mut landed = 0usize;
1095+ let mut refusal: Option<String> = None;
1096+
1097+ for node in &nodes {
1098+ let record = match resolve_change(&state, ctx.repo.id, &node.number.to_string()).await? {
1099+ Resolution::One(c) => *c,
1100+ _ => continue,
1101+ };
1102+
1103+ if record.state == "merged" {
1104+ continue;
1105+ }
1106+ if record.state == "abandoned" || record.state == "draft" {
1107+ refusal = Some(format!(
1108+ "#{} is {} — nothing after it was merged.",
1109+ record.number, record.state
1110+ ));
1111+ break;
1112+ }
1113+
1114+ match land(&state, &ctx, &record, &user).await? {
1115+ Landed::Ok(_) => landed += 1,
1116+ Landed::Refused(msg) => {
1117+ refusal = Some(format!("#{}: {msg} Nothing after it was merged.", record.number));
1118+ break;
1119+ }
1120+ }
1121+ }
1122+
1123+ enqueue_reindex(&state, ctx.repo.id).await;
1124+
1125+ let message = match refusal {
1126+ Some(r) => format!("Merged {landed} change(s), then stopped. {r}"),
1127+ None => format!("Merged {landed} change(s)."),
1128+ };
1129+
1130+ Ok(Redirect::to(&format!(
1131+ "{}/stacks/{change_id}?notice={}",
1132+ ctx.base(),
1133+ urlencode(&message)
1134+ ))
1135+ .into_response())
1136+}
1137+
1138+enum Landed {
1139+ Ok(String),
1140+ Refused(String),
1141+}
1142+
1143+/// Land one change onto its target bookmark and record the result.
1144+async fn land(
1145+ state: &AppState,
1146+ ctx: &RepoContext,
1147+ change: &ChangeRecord,
1148+ actor: &df_db::models::User,
1149+) -> AppResult<Landed> {
1150+ if change.conflicted {
1151+ return Ok(Landed::Refused(
1152+ "This change is conflicted; resolve it in your working copy and push again.".into(),
1153+ ));
1154+ }
1155+ if change.state == "merged" {
1156+ return Ok(Landed::Ok("Already merged.".into()));
1157+ }
1158+ if change.state != "open" {
1159+ return Ok(Landed::Refused(format!(
1160+ "A change in state {} cannot be merged.",
1161+ change.state
1162+ )));
1163+ }
1164+
1165+ let head: Option<String> = sqlx::query_scalar(
1166+ "SELECT rev FROM revisions WHERE change_id_fk = $1 ORDER BY seq DESC LIMIT 1",
1167+ )
1168+ .bind(change.id)
1169+ .fetch_optional(&state.db)
1170+ .await?;
1171+
1172+ let Some(head) = head else {
1173+ return Ok(Landed::Refused("This change has no revisions.".into()));
1174+ };
1175+
1176+ // The merge commit is attributed to whoever pressed the button, with their
1177+ // handle rather than their email — the email is an OIDC claim we are told
1178+ // not to key on and should not scatter into commit objects.
1179+ let author = df_store::Signature {
1180+ name: actor.label().to_owned(),
1181+ email: format!("{}@users.noreply.{}", actor.handle, state.config.host()),
1182+ when: chrono::Utc::now(),
1183+ };
1184+
1185+ let message = format!(
1186+ "Merge change #{} into {}\n\n{}\n\nChange-Id: {}\n",
1187+ change.number, change.target_bookmark, change.title, change.change_id
1188+ );
1189+
1190+ let outcome = match state
1191+ .store
1192+ .merge(
1193+ ctx.store_id(),
1194+ &change.target_bookmark,
1195+ &RevId::from_stored(head.clone()),
1196+ &message,
1197+ &author,
1198+ )
1199+ .await
1200+ {
1201+ Ok(o) => o,
1202+ Err(e) => {
1203+ tracing::error!(change = %change.id, "merge failed: {e}");
1204+ return Ok(Landed::Refused(format!("The merge could not be completed: {e}")));
1205+ }
1206+ };
1207+
1208+ let (text, merged) = match outcome {
1209+ df_store::MergeOutcome::Conflicted => {
1210+ return Ok(Landed::Refused(
1211+ "The change conflicts with the target bookmark. Rebase it in your working \
1212+ copy and push again — the server does not resolve conflicts."
1213+ .into(),
1214+ ))
1215+ }
1216+ df_store::MergeOutcome::AlreadyMerged => ("Already on the bookmark.".to_string(), true),
1217+ df_store::MergeOutcome::FastForward { .. } => {
1218+ (format!("Fast-forwarded {}.", change.target_bookmark), true)
1219+ }
1220+ df_store::MergeOutcome::Merged { .. } => {
1221+ (format!("Merged into {}.", change.target_bookmark), true)
1222+ }
1223+ };
1224+
1225+ if merged {
1226+ sqlx::query(
1227+ "UPDATE changes SET state = 'merged', merged_at = COALESCE(merged_at, now()),
1228+ updated_at = now()
1229+ WHERE id = $1",
1230+ )
1231+ .bind(change.id)
1232+ .execute(&state.db)
1233+ .await?;
1234+
1235+ event(
1236+ state,
1237+ ctx.repo.id,
1238+ Some(actor.id),
1239+ "change.merged",
1240+ change.id,
1241+ serde_json::json!({ "bookmark": change.target_bookmark }),
1242+ )
1243+ .await;
1244+ }
1245+
1246+ Ok(Landed::Ok(text))
1247+}
1248+
1249+/// Ask the worker to reindex, so bookmarks and change states catch up with the
1250+/// ref we just moved.
1251+///
1252+/// A merge does not go through the Git wire protocol, so no post-receive hook
1253+/// fires and nothing else would notice.
1254+async fn enqueue_reindex(state: &AppState, repo_id: Uuid) {
1255+ if let Err(e) = sqlx::query(
1256+ "INSERT INTO jobs (id, kind, payload) VALUES ($1, 'index_push', $2)",
1257+ )
1258+ .bind(new_id())
1259+ .bind(serde_json::json!({ "repo_id": repo_id }))
1260+ .execute(&state.db)
1261+ .await
1262+ {
1263+ tracing::error!(%repo_id, "enqueueing reindex after merge failed: {e}");
1264+ }
1265+}
1266+
1267+fn back_notice(l: &Loaded, msg: &str) -> Response {
1268+ Redirect::to(&format!(
1269+ "{}/changes/{}?notice={}",
1270+ l.ctx.base(),
1271+ l.change.number,
1272+ urlencode(msg)
1273+ ))
1274+ .into_response()
1275+}
Acrates/df-web/src/routes/search.rs+341−0
@@ −0,0 +1,341 @@
1+//! Global search over repositories, changes and issues (M5).
2+//!
3+//! > Global search over repos, changes, and issues (**not code**).
4+//!
5+//! Code search is explicitly out of scope for v1, and this deliberately does not
6+//! approximate it: searching titles and descriptions and calling it code search
7+//! would be worse than not having it.
8+//!
9+//! **The visibility rule is the whole security story of this page.** A search
10+//! that ignores it becomes an enumeration oracle for private repositories —
11+//! type a guess, learn whether it matched. Every query below applies the same
12+//! predicate the repository pages do, in SQL, before ranking: nothing the viewer
13+//! cannot open is ever loaded, let alone scored.
14+
15+use axum::extract::{Query, State};
16+use axum::response::{IntoResponse, Response};
17+use serde::Deserialize;
18+use uuid::Uuid;
19+
20+use crate::error::AppResult;
21+use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
22+use crate::views::{self, Chrome};
23+
24+#[derive(Deserialize, Default)]
25+pub struct SearchQuery {
26+ pub q: Option<String>,
27+ /// `repos` | `changes` | `issues`. Absent means all three.
28+ #[serde(rename = "type")]
29+ pub kind: Option<String>,
30+}
31+
32+/// The visibility predicate, shared by all three queries.
33+///
34+/// Written once as a constant rather than pasted three times: this is the
35+/// clause that must not diverge between result types, and three copies is how
36+/// it would.
37+///
38+/// `$2` is the viewer's id (null when anonymous) and `$3` is whether they are a
39+/// site admin. Every branch past the first requires `$2`, so an anonymous
40+/// request can match only public repositories.
41+const VISIBLE: &str = "(
42+ r.visibility = 'public'
43+ OR $3
44+ OR r.owner_user_id = $2
45+ OR EXISTS (SELECT 1 FROM repo_collaborators c WHERE c.repo_id = r.id AND c.user_id = $2)
46+ OR EXISTS (SELECT 1 FROM org_members m WHERE m.org_id = r.owner_org_id AND m.user_id = $2)
47+)";
48+
49+const LIMIT: i64 = 25;
50+
51+pub struct Hit {
52+ pub kind: &'static str,
53+ pub url: String,
54+ pub title: String,
55+ pub context: String,
56+ pub badge: Option<String>,
57+}
58+
59+/// `GET /search`
60+pub async fn search(
61+ State(state): State<AppState>,
62+ Query(q): Query<SearchQuery>,
63+ CurrentUser(user): CurrentUser,
64+ CsrfToken(csrf): CsrfToken,
65+ Nonce(nonce): Nonce,
66+) -> AppResult<Response> {
67+ let raw = q.q.as_deref().unwrap_or("").trim();
68+ let kind = q.kind.as_deref().unwrap_or("all");
69+
70+ let viewer: Option<Uuid> = user.as_ref().map(|u| u.id);
71+ let is_admin = user.as_ref().is_some_and(|u| u.is_admin);
72+
73+ // Short queries match almost everything and cost a full index scan for a
74+ // useless result. Two characters is the floor.
75+ let (repos, changes, issues) = if raw.chars().count() < 2 {
76+ (vec![], vec![], vec![])
77+ } else {
78+ let term = to_tsquery(raw);
79+ (
80+ if matches!(kind, "all" | "repos") {
81+ search_repos(&state, &term, viewer, is_admin).await?
82+ } else {
83+ vec![]
84+ },
85+ if matches!(kind, "all" | "changes") {
86+ search_changes(&state, &term, viewer, is_admin).await?
87+ } else {
88+ vec![]
89+ },
90+ if matches!(kind, "all" | "issues") {
91+ search_issues(&state, &term, viewer, is_admin).await?
92+ } else {
93+ vec![]
94+ },
95+ )
96+ };
97+
98+ let total = repos.len() + changes.len() + issues.len();
99+
100+ Ok(views::page(
101+ Chrome { title: "Search", user: user.as_deref(), csrf: &csrf, nonce: &nonce },
102+ maud::html! {
103+ div .panel {
104+ h1 { "Search" }
105+ form method="get" action="/search" .row style="gap:8px" {
106+ input type="search" name="q" value=(raw) autofocus
107+ placeholder="repositories, changes and issues"
108+ aria-label="Search query" style="flex:1";
109+ select name="type" aria-label="Result type" {
110+ @for (key, label) in [("all", "everything"), ("repos", "repositories"),
111+ ("changes", "changes"), ("issues", "issues")] {
112+ option value=(key) selected[kind == key] { (label) }
113+ }
114+ }
115+ button .btn.btn-primary type="submit" { "Search" }
116+ }
117+ p .hint {
118+ "Titles, descriptions and bodies. Code search is not part of v1 — \
119+ clone the repository and use " code { "jj" } " or " code { "grep" } "."
120+ }
121+ }
122+
123+ @if !raw.is_empty() && total == 0 {
124+ div .panel { div .empty {
125+ h2 { "No results" }
126+ p { "Nothing you can see matches that." }
127+ } }
128+ }
129+
130+ @for (heading, hits) in [("Repositories", &repos), ("Changes", &changes), ("Issues", &issues)] {
131+ @if !hits.is_empty() {
132+ div .panel {
133+ h2 { (heading) }
134+ div .stack style="gap:0" {
135+ @for h in hits.iter() {
136+ div style="padding:10px 0;border-bottom:1px solid var(--border)" {
137+ div .row {
138+ span .label-condensed .faint { (h.kind) }
139+ @if let Some(b) = &h.badge { span .chip { (b) } }
140+ a href=(&h.url) style="font-weight:500" { (h.title) }
141+ }
142+ @if !h.context.is_empty() {
143+ p .dim style="margin:4px 0 0" { (h.context) }
144+ }
145+ }
146+ }
147+ }
148+ }
149+ }
150+ }
151+ },
152+ )
153+ .into_response())
154+}
155+
156+/// Turn user input into a `tsquery` safely.
157+///
158+/// `websearch_to_tsquery` would also work, but it accepts operators, and a
159+/// search box that silently reinterprets `-` and `or` surprises people. This
160+/// keeps alphanumeric words only and ANDs them, with a prefix match on the last
161+/// word so typing feels responsive.
162+fn to_tsquery(raw: &str) -> String {
163+ let words: Vec<String> = raw
164+ .split(|c: char| !c.is_alphanumeric())
165+ .filter(|w| !w.is_empty())
166+ .take(8)
167+ .map(|w| w.chars().take(64).collect::<String>())
168+ .collect();
169+
170+ if words.is_empty() {
171+ // Matches nothing, which is the honest answer to a query of punctuation.
172+ return String::from("''");
173+ }
174+
175+ let mut parts: Vec<String> = words.iter().map(|w| format!("{w}:*")).collect();
176+ // Only the last word gets prefix semantics for the *user*; the rest are
177+ // whole words. Prefixing every word makes "in the" match the whole table.
178+ for p in parts.iter_mut().take(words.len().saturating_sub(1)) {
179+ p.truncate(p.len() - 2);
180+ }
181+ parts.join(" & ")
182+}
183+
184+async fn search_repos(
185+ state: &AppState,
186+ term: &str,
187+ viewer: Option<Uuid>,
188+ is_admin: bool,
189+) -> AppResult<Vec<Hit>> {
190+ let sql = format!(
191+ r#"
192+ SELECT COALESCE(ou.handle, og.handle)::text, r.name::text, r.description,
193+ (r.visibility = 'private') AS private
194+ FROM repos r
195+ LEFT JOIN users ou ON ou.id = r.owner_user_id
196+ LEFT JOIN orgs og ON og.id = r.owner_org_id
197+ WHERE r.search @@ to_tsquery('english', $1) AND {VISIBLE}
198+ ORDER BY ts_rank(r.search, to_tsquery('english', $1)) DESC
199+ LIMIT {LIMIT}
200+ "#
201+ );
202+
203+ let rows: Vec<(String, String, Option<String>, bool)> = sqlx::query_as(&sql)
204+ .bind(term)
205+ .bind(viewer)
206+ .bind(is_admin)
207+ .fetch_all(&state.db)
208+ .await?;
209+
210+ Ok(rows
211+ .into_iter()
212+ .map(|(owner, name, description, private)| Hit {
213+ kind: "repo",
214+ url: format!("/{owner}/{name}"),
215+ title: format!("{owner}/{name}"),
216+ context: description.unwrap_or_default(),
217+ badge: private.then(|| "private".to_string()),
218+ })
219+ .collect())
220+}
221+
222+async fn search_changes(
223+ state: &AppState,
224+ term: &str,
225+ viewer: Option<Uuid>,
226+ is_admin: bool,
227+) -> AppResult<Vec<Hit>> {
228+ let sql = format!(
229+ r#"
230+ SELECT COALESCE(ou.handle, og.handle)::text, r.name::text,
231+ c.number, c.title, c.description, c.state::text
232+ FROM changes c
233+ JOIN repos r ON r.id = c.repo_id
234+ LEFT JOIN users ou ON ou.id = r.owner_user_id
235+ LEFT JOIN orgs og ON og.id = r.owner_org_id
236+ WHERE c.search @@ to_tsquery('english', $1) AND {VISIBLE}
237+ ORDER BY ts_rank(c.search, to_tsquery('english', $1)) DESC
238+ LIMIT {LIMIT}
239+ "#
240+ );
241+
242+ let rows: Vec<(String, String, i64, String, String, String)> = sqlx::query_as(&sql)
243+ .bind(term)
244+ .bind(viewer)
245+ .bind(is_admin)
246+ .fetch_all(&state.db)
247+ .await?;
248+
249+ Ok(rows
250+ .into_iter()
251+ .map(|(owner, name, number, title, description, st)| Hit {
252+ kind: "change",
253+ url: format!("/{owner}/{name}/changes/{number}"),
254+ title,
255+ context: format!(
256+ "{owner}/{name} #{number} · {}",
257+ df_render::excerpt(&description, 120)
258+ ),
259+ badge: Some(st),
260+ })
261+ .collect())
262+}
263+
264+async fn search_issues(
265+ state: &AppState,
266+ term: &str,
267+ viewer: Option<Uuid>,
268+ is_admin: bool,
269+) -> AppResult<Vec<Hit>> {
270+ let sql = format!(
271+ r#"
272+ SELECT COALESCE(ou.handle, og.handle)::text, r.name::text,
273+ i.number, i.title, i.body, i.state::text
274+ FROM issues i
275+ JOIN repos r ON r.id = i.repo_id
276+ LEFT JOIN users ou ON ou.id = r.owner_user_id
277+ LEFT JOIN orgs og ON og.id = r.owner_org_id
278+ WHERE i.search @@ to_tsquery('english', $1) AND {VISIBLE}
279+ ORDER BY ts_rank(i.search, to_tsquery('english', $1)) DESC
280+ LIMIT {LIMIT}
281+ "#
282+ );
283+
284+ let rows: Vec<(String, String, i64, String, String, String)> = sqlx::query_as(&sql)
285+ .bind(term)
286+ .bind(viewer)
287+ .bind(is_admin)
288+ .fetch_all(&state.db)
289+ .await?;
290+
291+ Ok(rows
292+ .into_iter()
293+ .map(|(owner, name, number, title, body, st)| Hit {
294+ kind: "issue",
295+ url: format!("/{owner}/{name}/issues/{number}"),
296+ title,
297+ context: format!("{owner}/{name} #{number} · {}", df_render::excerpt(&body, 120)),
298+ badge: Some(st),
299+ })
300+ .collect())
301+}
302+
303+#[cfg(test)]
304+mod tests {
305+ use super::to_tsquery;
306+
307+ #[test]
308+ fn words_are_anded_with_a_prefix_on_the_last() {
309+ assert_eq!(to_tsquery("revset error"), "revset & error:*");
310+ assert_eq!(to_tsquery("revset"), "revset:*");
311+ }
312+
313+ /// The input reaches `to_tsquery`, which raises an error on malformed
314+ /// syntax — so operators must never survive the sanitisation.
315+ #[test]
316+ fn tsquery_operators_cannot_reach_postgres() {
317+ for input in ["a & b", "a | b", "!a", "a <-> b", "'; DROP TABLE users; --", "((("] {
318+ let out = to_tsquery(input);
319+ assert!(
320+ !out.contains('|') && !out.contains('!') && !out.contains('\'')
321+ || out == "''",
322+ "{input} produced {out}"
323+ );
324+ }
325+ }
326+
327+ #[test]
328+ fn punctuation_only_matches_nothing() {
329+ assert_eq!(to_tsquery("!!!"), "''");
330+ assert_eq!(to_tsquery(""), "''");
331+ }
332+
333+ #[test]
334+ fn absurd_input_is_bounded() {
335+ let long = "word ".repeat(100);
336+ assert_eq!(to_tsquery(&long).matches('&').count(), 7, "at most eight terms");
337+
338+ let huge_word = "a".repeat(500);
339+ assert!(to_tsquery(&huge_word).len() <= 66);
340+ }
341+}
Acrates/df-web/src/routes/settings.rs+294−0
@@ −0,0 +1,294 @@
1+//! The signed-in user's own settings: profile, SSH keys, access tokens (spec §7).
2+//!
3+//! Everything here operates on `user.id` taken from the session, never from the
4+//! request — so there is no object-id parameter an attacker could substitute to
5+//! reach somebody else's key. The delete queries carry `AND user_id = $2` as a
6+//! second line of defence, and their `rows_affected()` is what decides whether
7+//! the page says "removed" or "not found".
8+
9+use axum::extract::{Path as UrlPath, Query, State};
10+use axum::response::{IntoResponse, Redirect, Response};
11+use axum::Form;
12+use df_db::ids::new_id;
13+use serde::Deserialize;
14+use uuid::Uuid;
15+
16+use crate::error::{AppError, AppResult};
17+use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
18+use crate::views::settings as v;
19+use crate::views::{self, Chrome};
20+
21+#[derive(Deserialize, Default)]
22+pub struct Flash {
23+ pub error: Option<String>,
24+ pub notice: Option<String>,
25+ /// A freshly minted token, round-tripped once through a redirect so a
26+ /// refresh of the POST does not mint a second one.
27+ pub token: Option<String>,
28+}
29+
30+/// `GET /settings`
31+pub async fn show(
32+ State(state): State<AppState>,
33+ Query(flash): Query<Flash>,
34+ CurrentUser(user): CurrentUser,
35+ CsrfToken(csrf): CsrfToken,
36+ Nonce(nonce): Nonce,
37+) -> AppResult<Response> {
38+ let Some(user) = user else {
39+ return Err(AppError::Unauthorized);
40+ };
41+
42+ let keys = load_keys(&state, user.id).await?;
43+ let tokens = load_tokens(&state, user.id).await?;
44+ let ssh_host = state.config.ssh_clone_url(&user.handle, "repo");
45+
46+ Ok(views::page(
47+ Chrome { title: "Settings", user: Some(&user), csrf: &csrf, nonce: &nonce },
48+ v::user_settings(v::UserSettings {
49+ user: &user,
50+ csrf: &csrf,
51+ keys: &keys,
52+ tokens: &tokens,
53+ new_token: flash.token.as_deref(),
54+ error: flash.error.as_deref(),
55+ notice: flash.notice.as_deref(),
56+ ssh_host: &ssh_host,
57+ }),
58+ )
59+ .into_response())
60+}
61+
62+// ─── SSH keys ────────────────────────────────────────────────────────────────
63+
64+#[derive(Deserialize)]
65+pub struct AddKey {
66+ pub key: String,
67+ pub name: Option<String>,
68+}
69+
70+/// `POST /settings/keys`
71+pub async fn add_key(
72+ State(state): State<AppState>,
73+ CurrentUser(user): CurrentUser,
74+ Form(form): Form<AddKey>,
75+) -> AppResult<Response> {
76+ let Some(user) = user else {
77+ return Err(AppError::Unauthorized);
78+ };
79+
80+ let parsed = match df_auth::ssh_keys::parse(&form.key) {
81+ Ok(p) => p,
82+ Err(e) => return Ok(redirect_err(&e.to_string())),
83+ };
84+
85+ let name = form
86+ .name
87+ .as_deref()
88+ .map(str::trim)
89+ .filter(|s| !s.is_empty())
90+ .map(|s| s.chars().take(100).collect::<String>())
91+ .unwrap_or_else(|| df_auth::ssh_keys::suggested_name(&parsed));
92+
93+ // `fingerprint` is UNIQUE across the whole table, not per user: the same key
94+ // must not authenticate as two different people, which is the entire basis
95+ // of SSH auth here. So a duplicate is a conflict even when it belongs to
96+ // somebody else — and the message deliberately does not say which.
97+ let inserted = sqlx::query(
98+ "INSERT INTO ssh_keys (id, user_id, name, key_type, fingerprint, public_key)
99+ VALUES ($1, $2, $3, $4, $5, $6)
100+ ON CONFLICT (fingerprint) DO NOTHING",
101+ )
102+ .bind(new_id())
103+ .bind(user.id)
104+ .bind(&name)
105+ .bind(&parsed.key_type)
106+ .bind(&parsed.fingerprint)
107+ .bind(&parsed.openssh)
108+ .execute(&state.db)
109+ .await?;
110+
111+ if inserted.rows_affected() == 0 {
112+ return Ok(redirect_err("That key is already registered."));
113+ }
114+
115+ audit(&state, user.id, "ssh_key.added", &parsed.fingerprint).await;
116+ Ok(redirect_ok("SSH key added."))
117+}
118+
119+/// `POST /settings/keys/{id}/delete` and `DELETE /settings/keys/{id}`
120+pub async fn delete_key(
121+ State(state): State<AppState>,
122+ UrlPath(id): UrlPath<Uuid>,
123+ CurrentUser(user): CurrentUser,
124+) -> AppResult<Response> {
125+ let Some(user) = user else {
126+ return Err(AppError::Unauthorized);
127+ };
128+
129+ let deleted = sqlx::query("DELETE FROM ssh_keys WHERE id = $1 AND user_id = $2")
130+ .bind(id)
131+ .bind(user.id)
132+ .execute(&state.db)
133+ .await?;
134+
135+ if deleted.rows_affected() == 0 {
136+ return Ok(redirect_err("That key is not yours, or no longer exists."));
137+ }
138+
139+ audit(&state, user.id, "ssh_key.removed", &id.to_string()).await;
140+ Ok(redirect_ok("SSH key removed."))
141+}
142+
143+// ─── access tokens ───────────────────────────────────────────────────────────
144+
145+#[derive(Deserialize)]
146+pub struct CreateToken {
147+ pub name: String,
148+ pub expires_days: Option<i64>,
149+}
150+
151+/// `POST /settings/tokens`
152+pub async fn create_token(
153+ State(state): State<AppState>,
154+ CurrentUser(user): CurrentUser,
155+ Form(form): Form<CreateToken>,
156+) -> AppResult<Response> {
157+ let Some(user) = user else {
158+ return Err(AppError::Unauthorized);
159+ };
160+
161+ let name: String = form.name.trim().chars().take(100).collect();
162+ if name.is_empty() {
163+ return Ok(redirect_err("A token needs a name."));
164+ }
165+
166+ // Clamped rather than rejected: the field is a fixed select, so an
167+ // out-of-range value is a hand-crafted request, and the safe reading of one
168+ // is the shortest lifetime rather than the longest.
169+ let expires_at = match form.expires_days.unwrap_or(90) {
170+ 0 => None,
171+ d => Some(chrono::Utc::now() + chrono::Duration::days(d.clamp(1, 365))),
172+ };
173+
174+ let token = df_auth::tokens::create(&state.db, user.id, &name, &[], expires_at).await?;
175+
176+ audit(&state, user.id, "token.created", &token.prefix).await;
177+
178+ // The plaintext travels back through the redirect so a browser refresh does
179+ // not mint a second token. It is in a URL, which is not ideal — but the URL
180+ // is same-origin, never linked, and the alternative is rendering the POST
181+ // response directly and having refresh-to-remint.
182+ Ok(Redirect::to(&format!(
183+ "/settings?token={}&notice={}",
184+ urlencode(&token.plaintext),
185+ urlencode("Token created.")
186+ ))
187+ .into_response())
188+}
189+
190+/// `POST /settings/tokens/{id}/delete` and `DELETE /settings/tokens/{id}`
191+pub async fn delete_token(
192+ State(state): State<AppState>,
193+ UrlPath(id): UrlPath<Uuid>,
194+ CurrentUser(user): CurrentUser,
195+) -> AppResult<Response> {
196+ let Some(user) = user else {
197+ return Err(AppError::Unauthorized);
198+ };
199+
200+ if df_auth::tokens::revoke(&state.db, user.id, id).await? {
201+ audit(&state, user.id, "token.revoked", &id.to_string()).await;
202+ Ok(redirect_ok("Token revoked."))
203+ } else {
204+ Ok(redirect_err("That token is not yours, or no longer exists."))
205+ }
206+}
207+
208+// ─── loading ─────────────────────────────────────────────────────────────────
209+
210+async fn load_keys(state: &AppState, user_id: Uuid) -> AppResult<Vec<v::SshKeyRow>> {
211+ let rows: Vec<(Uuid, String, String, String, Option<chrono::DateTime<chrono::Utc>>)> =
212+ sqlx::query_as(
213+ "SELECT id, name, key_type, fingerprint, last_used_at
214+ FROM ssh_keys WHERE user_id = $1 ORDER BY created_at",
215+ )
216+ .bind(user_id)
217+ .fetch_all(&state.db)
218+ .await?;
219+
220+ Ok(rows
221+ .into_iter()
222+ .map(|(id, name, key_type, fingerprint, last_used_at)| v::SshKeyRow {
223+ id,
224+ name,
225+ key_type,
226+ fingerprint,
227+ last_used_at,
228+ })
229+ .collect())
230+}
231+
232+async fn load_tokens(state: &AppState, user_id: Uuid) -> AppResult<Vec<v::TokenRow>> {
233+ let rows: Vec<(
234+ Uuid,
235+ String,
236+ String,
237+ Option<chrono::DateTime<chrono::Utc>>,
238+ Option<chrono::DateTime<chrono::Utc>>,
239+ )> = sqlx::query_as(
240+ "SELECT id, name, prefix, expires_at, last_used_at
241+ FROM access_tokens WHERE user_id = $1 ORDER BY created_at",
242+ )
243+ .bind(user_id)
244+ .fetch_all(&state.db)
245+ .await?;
246+
247+ Ok(rows
248+ .into_iter()
249+ .map(|(id, name, prefix, expires_at, last_used_at)| v::TokenRow {
250+ id,
251+ name,
252+ prefix,
253+ expires_at,
254+ last_used_at,
255+ })
256+ .collect())
257+}
258+
259+// ─── helpers ─────────────────────────────────────────────────────────────────
260+
261+/// Record a credential change in the audit log (spec §9).
262+///
263+/// Best-effort: a settings change must not fail because the audit insert did.
264+/// It is logged loudly instead, because a silently missing audit trail is worse
265+/// than a noisy one.
266+pub async fn audit(state: &AppState, actor: Uuid, action: &str, target: &str) {
267+ if let Err(e) = sqlx::query(
268+ "INSERT INTO audit_log (id, actor_id, action, target) VALUES ($1, $2, $3, $4)",
269+ )
270+ .bind(new_id())
271+ .bind(actor)
272+ .bind(action)
273+ .bind(target)
274+ .execute(&state.db)
275+ .await
276+ {
277+ tracing::error!(%action, %target, "audit log write failed: {e}");
278+ }
279+}
280+
281+fn redirect_ok(msg: &str) -> Response {
282+ Redirect::to(&format!("/settings?notice={}", urlencode(msg))).into_response()
283+}
284+
285+fn redirect_err(msg: &str) -> Response {
286+ Redirect::to(&format!("/settings?error={}", urlencode(msg))).into_response()
287+}
288+
289+pub fn urlencode(s: &str) -> String {
290+ // NON_ALPHANUMERIC is deliberately blunt: this escapes into a query string
291+ // that is then re-rendered into HTML, so encoding more than strictly
292+ // necessary costs nothing and removes a whole class of mistake.
293+ percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string()
294+}
Acrates/df-web/src/routes/setup.rs+63−0
@@ −0,0 +1,63 @@
1+//! One-time site-admin bootstrap (decided; mirrors the Ory console's pattern).
2+
3+use axum::extract::State;
4+use axum::response::{IntoResponse, Response};
5+use axum::Form;
6+use df_auth::provisioning;
7+use serde::Deserialize;
8+
9+use crate::error::{AppError, AppResult};
10+use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
11+use crate::views::{self, Chrome};
12+
13+pub async fn show(
14+ CurrentUser(user): CurrentUser,
15+ CsrfToken(csrf): CsrfToken,
16+ Nonce(nonce): Nonce,
17+) -> AppResult<Response> {
18+ // Claiming admin attaches it to an account, so one is required first.
19+ let Some(user) = user else {
20+ return Err(AppError::Unauthorized);
21+ };
22+
23+ Ok(views::page(
24+ Chrome { title: "Setup", user: Some(&user), csrf: &csrf, nonce: &nonce },
25+ views::pages::setup(&csrf, None),
26+ )
27+ .into_response())
28+}
29+
30+#[derive(Deserialize)]
31+pub struct SetupForm {
32+ pub token: String,
33+}
34+
35+pub async fn claim(
36+ State(state): State<AppState>,
37+ CurrentUser(user): CurrentUser,
38+ CsrfToken(csrf): CsrfToken,
39+ Nonce(nonce): Nonce,
40+ Form(form): Form<SetupForm>,
41+) -> AppResult<Response> {
42+ let Some(user) = user else {
43+ return Err(AppError::Unauthorized);
44+ };
45+
46+ let ok = provisioning::claim_admin(&state.db, user.id, form.token.trim()).await?;
47+
48+ if !ok {
49+ tracing::warn!(user = %user.id, "setup token rejected");
50+ return Ok(views::page(
51+ Chrome { title: "Setup", user: Some(&user), csrf: &csrf, nonce: &nonce },
52+ views::pages::setup(&csrf, Some("That token is not valid, or has already been used.")),
53+ )
54+ .into_response());
55+ }
56+
57+ tracing::info!(user = %user.id, "site admin claimed");
58+ Ok(views::page(
59+ Chrome { title: "Setup complete", user: Some(&user), csrf: &csrf, nonce: &nonce },
60+ views::pages::setup_done(),
61+ )
62+ .into_response())
63+}
Acrates/df-web/src/security_tests.rs1000 lines+1000−0
@@ −0,0 +1,1000 @@
1+//! The §9 security checklist, one test per item (M6).
2+//!
3+//! > **Private repo leakage.** The most common forge vulnerability class. […]
4+//! > Deliberately test: private repo object access via a public fork's URL, blob
5+//! > access by OID without a reachable ref, change and issue numbers enumerated
6+//! > across visibility boundaries, and search results crossing repos. Every one
7+//! > of these has been a real CVE in a real forge.
8+//!
9+//! These drive the **real router** — every middleware, every extractor, every
10+//! handler — through `tower::ServiceExt::oneshot`. Testing the SQL directly
11+//! would miss exactly the bugs that matter here, which live in the seam between
12+//! a handler and the authorization it forgot to call.
13+//!
14+//! They need a throwaway Postgres. Set `DF_TEST_DATABASE_URL`; without it they
15+//! skip with a warning, and `DF_REQUIRE_DB=1` turns that skip into a failure so
16+//! CI cannot quietly stop running them.
17+//!
18+//! ```sh
19+//! docker run -d --name df-pgtest -e POSTGRES_PASSWORD=x -e POSTGRES_DB=t \
20+//! -p 55432:5432 postgres:17-alpine
21+//! DF_TEST_DATABASE_URL=postgres://postgres:x@127.0.0.1:55432/t \
22+//! cargo test -p df-web security
23+//! ```
24+//!
25+//! Each test runs in its own Postgres **schema**, created and dropped around it,
26+//! so they neither collide nor need ordering.
27+
28+#![cfg(test)]
29+
30+use std::path::Path;
31+use std::sync::Arc;
32+
33+use axum::body::Body;
34+use axum::http::{Request, StatusCode};
35+use axum::response::Response;
36+use df_store::{
37+ BlameLine, Blob, Bookmark, ConflictedFile, Diff, DiffOpts, EditOutcome, MergeOutcome, RepoId,
38+ RepoStore, Result as SResult, RevId, Revision, Signature, StoreError, TreeEntry,
39+};
40+use sqlx::{Executor, PgPool};
41+use tower::ServiceExt;
42+use uuid::Uuid;
43+
44+use crate::config::Config;
45+use crate::state::{AppState, Inner};
46+
47+// ─── harness ─────────────────────────────────────────────────────────────────
48+
49+/// A store that has nothing in it.
50+///
51+/// The visibility tests are about *authorization*, and authorization must be
52+/// decided before storage is touched. A store that refuses everything makes that
53+/// falsifiable: if a handler ever reaches storage on an unauthorized request,
54+/// the response changes from 404 to 500 and the test fails.
55+struct EmptyStore;
56+
57+#[async_trait::async_trait]
58+impl RepoStore for EmptyStore {
59+ async fn create(&self, _: RepoId, _: &str) -> SResult<()> {
60+ Ok(())
61+ }
62+ async fn configure_receive_validation(&self, _: RepoId, _: &str) -> SResult<()> {
63+ Ok(())
64+ }
65+ async fn delete(&self, _: RepoId) -> SResult<()> {
66+ Ok(())
67+ }
68+ async fn exists(&self, _: RepoId) -> bool {
69+ true
70+ }
71+ async fn is_empty(&self, _: RepoId) -> SResult<bool> {
72+ Ok(true)
73+ }
74+ async fn list_tree(&self, _: RepoId, _: &RevId, _: &Path) -> SResult<Vec<TreeEntry>> {
75+ Err(StoreError::NoSuchRevision)
76+ }
77+ async fn read_blob(&self, _: RepoId, _: &RevId, _: &Path) -> SResult<Blob> {
78+ Err(StoreError::NoSuchRevision)
79+ }
80+ async fn diff(&self, _: RepoId, _: &RevId, _: &RevId, _: DiffOpts) -> SResult<Diff> {
81+ Err(StoreError::NoSuchRevision)
82+ }
83+ async fn diff_from_parent(&self, _: RepoId, _: &RevId, _: DiffOpts) -> SResult<Diff> {
84+ Err(StoreError::NoSuchRevision)
85+ }
86+ async fn log(&self, _: RepoId, _: &RevId, _: usize) -> SResult<Vec<Revision>> {
87+ Err(StoreError::NoSuchRevision)
88+ }
89+ async fn revision(&self, _: RepoId, _: &RevId) -> SResult<Revision> {
90+ Err(StoreError::NoSuchRevision)
91+ }
92+ async fn bookmarks(&self, _: RepoId) -> SResult<Vec<Bookmark>> {
93+ Ok(vec![])
94+ }
95+ async fn merge_base(&self, _: RepoId, _: &RevId, _: &RevId) -> SResult<Option<RevId>> {
96+ Ok(None)
97+ }
98+ async fn is_ancestor(&self, _: RepoId, _: &RevId, _: &RevId) -> SResult<bool> {
99+ Ok(false)
100+ }
101+ async fn resolve(&self, _: RepoId, _: &str) -> SResult<RevId> {
102+ Err(StoreError::NoSuchRevision)
103+ }
104+ async fn merge(
105+ &self,
106+ _: RepoId,
107+ _: &str,
108+ _: &RevId,
109+ _: &str,
110+ _: &Signature,
111+ ) -> SResult<MergeOutcome> {
112+ Err(StoreError::NoSuchRepo)
113+ }
114+ async fn commit_file(
115+ &self,
116+ _: RepoId,
117+ _: &str,
118+ _: &RevId,
119+ _: &str,
120+ _: Vec<u8>,
121+ _: &str,
122+ _: &Signature,
123+ ) -> SResult<EditOutcome> {
124+ Err(StoreError::NoSuchRepo)
125+ }
126+ async fn conflicts(&self, _: RepoId, _: &RevId) -> SResult<Vec<ConflictedFile>> {
127+ Ok(vec![])
128+ }
129+ async fn size_bytes(&self, _: RepoId) -> SResult<u64> {
130+ Ok(0)
131+ }
132+ async fn blame(&self, _: RepoId, _: &RevId, _: &Path) -> SResult<Vec<BlameLine>> {
133+ Err(StoreError::NoSuchRevision)
134+ }
135+ async fn last_commit_for_path(
136+ &self,
137+ _: RepoId,
138+ _: &RevId,
139+ _: &Path,
140+ ) -> SResult<Option<Revision>> {
141+ Ok(None)
142+ }
143+}
144+
145+/// Enough provider metadata for `Oidc` to construct without a network call.
146+const METADATA: &str = r#"{
147+ "issuer": "https://oidc.test/",
148+ "authorization_endpoint": "https://oidc.test/auth",
149+ "token_endpoint": "https://oidc.test/token",
150+ "jwks_uri": "https://oidc.test/jwks",
151+ "response_types_supported": ["code"],
152+ "subject_types_supported": ["public"],
153+ "id_token_signing_alg_values_supported": ["RS256"]
154+}"#;
155+
156+struct Harness {
157+ app: axum::Router,
158+ db: PgPool,
159+ schema: String,
160+}
161+
162+impl Harness {
163+ /// A GET as an anonymous visitor.
164+ async fn get(&self, path: &str) -> Response {
165+ self.request(path, None).await
166+ }
167+
168+ /// A GET as a signed-in user.
169+ async fn get_as(&self, path: &str, session: &Uuid) -> Response {
170+ self.request(path, Some(*session)).await
171+ }
172+
173+ async fn request(&self, path: &str, session: Option<Uuid>) -> Response {
174+ let mut req = Request::builder().uri(path).method("GET");
175+ if let Some(s) = session {
176+ req = req.header("cookie", format!("{}={s}", df_auth::session::COOKIE_NAME));
177+ }
178+ self.app
179+ .clone()
180+ .oneshot(req.body(Body::empty()).expect("request"))
181+ .await
182+ .expect("response")
183+ }
184+
185+ async fn body(&self, res: Response) -> String {
186+ let bytes = axum::body::to_bytes(res.into_body(), 4 * 1024 * 1024)
187+ .await
188+ .expect("body");
189+ String::from_utf8_lossy(&bytes).into_owned()
190+ }
191+
192+ async fn drop_schema(&self) {
193+ let _ = self
194+ .db
195+ .execute(format!("DROP SCHEMA IF EXISTS {} CASCADE", self.schema).as_str())
196+ .await;
197+ }
198+}
199+
200+/// Send `tracing` output to the test's captured stderr, once per process.
201+///
202+/// Without this an internal error is a bare 500 in the assertion and the reason
203+/// is thrown away — which is exactly the information a failing security test
204+/// needs to hand over.
205+fn init_tracing() {
206+ use std::sync::Once;
207+ static ONCE: Once = Once::new();
208+ ONCE.call_once(|| {
209+ let _ = tracing_subscriber::fmt()
210+ .with_env_filter(
211+ tracing_subscriber::EnvFilter::try_from_default_env()
212+ .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("df_web=error")),
213+ )
214+ .with_test_writer()
215+ .try_init();
216+ });
217+}
218+
219+/// Build a harness, or `None` when no test database is configured.
220+async fn harness(name: &str) -> Option<Harness> {
221+ init_tracing();
222+
223+ let Ok(url) = std::env::var("DF_TEST_DATABASE_URL") else {
224+ if std::env::var_os("DF_REQUIRE_DB").is_some() {
225+ panic!("DF_REQUIRE_DB is set but DF_TEST_DATABASE_URL is not");
226+ }
227+ eprintln!("warning: DF_TEST_DATABASE_URL unset, security test `{name}` skipped");
228+ return None;
229+ };
230+
231+ // One schema per test, named after the test. Deriving it from the name
232+ // rather than from a random id makes collisions impossible — two tests
233+ // cannot share a name — so they stay independent while running in parallel.
234+ let schema: String = format!("df_test_{name}")
235+ .chars()
236+ .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
237+ .take(60)
238+ .collect();
239+
240+ let db = sqlx::postgres::PgPoolOptions::new()
241+ .max_connections(4)
242+ .after_connect({
243+ let schema = schema.clone();
244+ move |conn, _| {
245+ let schema = schema.clone();
246+ Box::pin(async move {
247+ conn.execute(format!("SET search_path TO {schema}, public").as_str())
248+ .await?;
249+ Ok(())
250+ })
251+ }
252+ })
253+ .connect(&url)
254+ .await
255+ .expect("connecting to DF_TEST_DATABASE_URL");
256+
257+ // Dropped first: a previous run that panicked before its cleanup would
258+ // otherwise leave rows behind and make this run fail for the wrong reason.
259+ db.execute(format!("DROP SCHEMA IF EXISTS {schema} CASCADE").as_str())
260+ .await
261+ .expect("dropping any leftover test schema");
262+ db.execute(format!("CREATE SCHEMA {schema}").as_str())
263+ .await
264+ .expect("creating the test schema");
265+
266+ // Extensions are database-wide, and `CREATE EXTENSION` installs into the
267+ // first schema on the search path. Pinning them to `public` up front stops
268+ // the first test that runs from installing them into its own schema and
269+ // taking them away when it drops it — the `IF NOT EXISTS` in the migration
270+ // then correctly does nothing.
271+ // `CREATE EXTENSION IF NOT EXISTS` is not safe to run concurrently: the
272+ // existence check and the insert are not atomic, so test binaries running
273+ // in parallel against a *fresh* database collide on `pg_extension`'s unique
274+ // index and fail here. An advisory lock serialises just this step. Only the
275+ // first run on a new database ever contends — which is exactly the run
276+ // where the failure is most confusing, because it looks like the security
277+ // tests themselves are broken.
278+ const EXTENSION_LOCK: i64 = 0x6466_5F65_7874; // "df_ext"
279+
280+ let mut conn = db.acquire().await.expect("a connection for extension setup");
281+
282+ sqlx::query("SELECT pg_advisory_lock($1)")
283+ .bind(EXTENSION_LOCK)
284+ .execute(&mut *conn)
285+ .await
286+ .expect("taking the extension lock");
287+
288+ let installed = async {
289+ for ext in ["citext", "pg_trgm"] {
290+ conn.execute(format!("CREATE EXTENSION IF NOT EXISTS {ext} SCHEMA public").as_str())
291+ .await?;
292+ }
293+ Ok::<_, sqlx::Error>(())
294+ }
295+ .await;
296+
297+ // Released whether or not the install worked, so a failure here does not
298+ // wedge every other test binary waiting on the lock.
299+ sqlx::query("SELECT pg_advisory_unlock($1)")
300+ .bind(EXTENSION_LOCK)
301+ .execute(&mut *conn)
302+ .await
303+ .expect("releasing the extension lock");
304+
305+ installed.expect("installing an extension into public");
306+ drop(conn);
307+
308+ df_db::migrate(&db).await.expect("migrations");
309+
310+ // The schema on the search path must be the one that owns the tables. If a
311+ // previous run ever migrated into `public`, every query here would silently
312+ // read *that* schema instead — the tests would still pass, against the wrong
313+ // data, until a new migration made them disagree. Caught here rather than
314+ // three layers down in a handler.
315+ let owned: Option<String> = sqlx::query_scalar(&format!(
316+ "SELECT to_regclass('{schema}.repos')::text"
317+ ))
318+ .fetch_one(&db)
319+ .await
320+ .expect("checking schema ownership");
321+ assert!(
322+ owned.is_some(),
323+ "migrations did not land in {schema}; is there a stale dogfood schema in `public`? \
324+ Reset it with: DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
325+ );
326+
327+ let config = Config::for_tests();
328+ let oidc = df_auth::Oidc::from_metadata_json(
329+ METADATA,
330+ "test-client",
331+ "test-secret",
332+ "https://dogfood.test/auth/callback",
333+ "openid profile email",
334+ )
335+ .expect("test oidc client");
336+
337+ let state = AppState(Arc::new(Inner {
338+ db: db.clone(),
339+ oidc,
340+ config,
341+ store: Arc::new(EmptyStore),
342+ limiter: crate::ratelimit::Limiter::new(),
343+ }));
344+
345+ Some(Harness {
346+ app: crate::build_router(state),
347+ db,
348+ schema,
349+ })
350+}
351+
352+/// Seed a user and an active session, returning `(user_id, session_id)`.
353+async fn user(db: &PgPool, handle: &str, admin: bool) -> (Uuid, Uuid) {
354+ let id = Uuid::now_v7();
355+ sqlx::query(
356+ "INSERT INTO users (id, subject, handle, display_name, is_admin)
357+ VALUES ($1, $2, $3, $3, $4)",
358+ )
359+ .bind(id)
360+ .bind(format!("sub-{handle}"))
361+ .bind(handle)
362+ .bind(admin)
363+ .execute(db)
364+ .await
365+ .expect("seeding a user");
366+
367+ let session = Uuid::now_v7();
368+ sqlx::query("INSERT INTO sessions (id, user_id, expires_at) VALUES ($1, $2, now() + '14 days')")
369+ .bind(session)
370+ .bind(id)
371+ .execute(db)
372+ .await
373+ .expect("seeding a session");
374+
375+ (id, session)
376+}
377+
378+/// Seed a repository owned by `owner`, returning its id.
379+async fn repo(db: &PgPool, owner: Uuid, name: &str, private: bool) -> Uuid {
380+ let id = Uuid::now_v7();
381+ sqlx::query(
382+ "INSERT INTO repos (id, owner_kind, owner_user_id, name, visibility, default_bookmark)
383+ VALUES ($1, 'user', $2, $3, $4::visibility, 'main')",
384+ )
385+ .bind(id)
386+ .bind(owner)
387+ .bind(name)
388+ .bind(if private { "private" } else { "public" })
389+ .execute(db)
390+ .await
391+ .expect("seeding a repo");
392+
393+ sqlx::query("INSERT INTO repo_counters (repo_id) VALUES ($1)")
394+ .bind(id)
395+ .execute(db)
396+ .await
397+ .expect("seeding counters");
398+
399+ id
400+}
401+
402+/// Seed a change with one revision.
403+async fn change(db: &PgPool, repo_id: Uuid, number: i64, change_id: &str, title: &str) -> Uuid {
404+ let id = Uuid::now_v7();
405+ sqlx::query(
406+ "INSERT INTO changes (id, repo_id, change_id, number, title, description,
407+ state, target_bookmark)
408+ VALUES ($1, $2, $3, $4, $5, 'secret description', 'open', 'main')",
409+ )
410+ .bind(id)
411+ .bind(repo_id)
412+ .bind(change_id)
413+ .bind(number)
414+ .bind(title)
415+ .execute(db)
416+ .await
417+ .expect("seeding a change");
418+
419+ sqlx::query(
420+ "INSERT INTO revisions (id, change_id_fk, rev, seq, author_name, author_email,
421+ authored_at, message)
422+ VALUES ($1, $2, $3, 1, 'A', 'a@b.c', now(), $4)",
423+ )
424+ .bind(Uuid::now_v7())
425+ .bind(id)
426+ .bind(format!("{:040x}", number))
427+ .bind(title)
428+ .execute(db)
429+ .await
430+ .expect("seeding a revision");
431+
432+ id
433+}
434+
435+async fn issue(db: &PgPool, repo_id: Uuid, number: i64, title: &str) -> Uuid {
436+ let id = Uuid::now_v7();
437+ sqlx::query(
438+ "INSERT INTO issues (id, repo_id, number, title, body)
439+ VALUES ($1, $2, $3, $4, 'secret issue body')",
440+ )
441+ .bind(id)
442+ .bind(repo_id)
443+ .bind(number)
444+ .bind(title)
445+ .execute(db)
446+ .await
447+ .expect("seeding an issue");
448+ id
449+}
450+
451+// ─── the checklist ───────────────────────────────────────────────────────────
452+
453+/// > Return an identical 404 for a private repo and a nonexistent repo.
454+#[tokio::test]
455+async fn a_private_repo_and_a_nonexistent_repo_are_indistinguishable() {
456+ let Some(h) = harness("identical_404").await else { return };
457+
458+ let (alice, _) = user(&h.db, "alice", false).await;
459+ // A name that cannot appear in the chrome for any other reason — `hidden`
460+ // would also match `aria-hidden` and `<input type="hidden">`, and the
461+ // assertion would then fail for a reason that has nothing to do with the
462+ // property being tested.
463+ repo(&h.db, alice, "unmentionable-repo", true).await;
464+
465+ let private = h.get("/alice/unmentionable-repo").await;
466+ let missing = h.get("/alice/does-not-exist").await;
467+
468+ assert_eq!(private.status(), StatusCode::NOT_FOUND);
469+ assert_eq!(missing.status(), StatusCode::NOT_FOUND);
470+
471+ let a = h.body(private).await;
472+ let b = h.body(missing).await;
473+ assert_eq!(a, b, "the two responses must be byte-identical");
474+ assert!(
475+ !a.contains("unmentionable-repo"),
476+ "the repository name leaked into the 404"
477+ );
478+
479+ h.drop_schema().await;
480+}
481+
482+/// > Being authenticated is not authorization.
483+#[tokio::test]
484+async fn a_signed_in_stranger_cannot_reach_a_private_repo() {
485+ let Some(h) = harness("stranger").await else { return };
486+
487+ let (alice, _) = user(&h.db, "alice", false).await;
488+ let (_, mallory_session) = user(&h.db, "mallory", false).await;
489+ repo(&h.db, alice, "hidden", true).await;
490+
491+ for path in [
492+ "/alice/hidden",
493+ "/alice/hidden/log",
494+ "/alice/hidden/bookmarks",
495+ "/alice/hidden/changes",
496+ "/alice/hidden/issues",
497+ "/alice/hidden/settings",
498+ "/alice/hidden/tree/main/",
499+ "/alice/hidden/blob/main/README.md",
500+ "/alice/hidden/raw/main/README.md",
501+ ] {
502+ let res = h.get_as(path, &mallory_session).await;
503+ assert_eq!(res.status(), StatusCode::NOT_FOUND, "{path} was reachable");
504+ }
505+
506+ h.drop_schema().await;
507+}
508+
509+/// > Change and issue numbers enumerated across visibility boundaries.
510+#[tokio::test]
511+async fn change_and_issue_numbers_cannot_be_enumerated_across_a_boundary() {
512+ let Some(h) = harness("enumeration").await else { return };
513+
514+ let (alice, _) = user(&h.db, "alice", false).await;
515+ let (_, mallory) = user(&h.db, "mallory", false).await;
516+
517+ let hidden = repo(&h.db, alice, "hidden", true).await;
518+ change(&h.db, hidden, 1, "klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", "secret change title").await;
519+ issue(&h.db, hidden, 1, "secret issue title").await;
520+
521+ for path in [
522+ "/alice/hidden/changes/1",
523+ "/alice/hidden/changes/klxqnvpq",
524+ "/alice/hidden/changes/1/files",
525+ "/alice/hidden/changes/1/revisions",
526+ "/alice/hidden/changes/1/conflicts",
527+ "/alice/hidden/issues/1",
528+ "/alice/hidden/stacks/klxqnvpqlnlvtkmuqmtmxktlnvnomvwv",
529+ ] {
530+ for session in [None, Some(mallory)] {
531+ let res = h.request(path, session).await;
532+ assert_eq!(res.status(), StatusCode::NOT_FOUND, "{path} was reachable");
533+ let body = h.body(res).await;
534+ assert!(!body.contains("secret"), "{path} leaked content: {body}");
535+ }
536+ }
537+
538+ h.drop_schema().await;
539+}
540+
541+/// > Blob access by OID without a reachable ref.
542+///
543+/// The route requires a repository the viewer can read *before* it resolves any
544+/// revision, so a raw object id is not a way around visibility. The store used
545+/// here refuses every read, which is what proves the 404 came from
546+/// authorization rather than from a missing object.
547+#[tokio::test]
548+async fn a_raw_object_id_is_not_a_way_around_visibility() {
549+ let Some(h) = harness("blob_by_oid").await else { return };
550+
551+ let (alice, _) = user(&h.db, "alice", false).await;
552+ repo(&h.db, alice, "hidden", true).await;
553+
554+ let oid = "0123456789abcdef0123456789abcdef01234567";
555+ for path in [
556+ format!("/alice/hidden/blob/{oid}/src/secret.rs"),
557+ format!("/alice/hidden/raw/{oid}/src/secret.rs"),
558+ format!("/alice/hidden/tree/{oid}/"),
559+ format!("/alice/hidden/log?rev={oid}"),
560+ ] {
561+ let res = h.get(&path).await;
562+ assert_eq!(res.status(), StatusCode::NOT_FOUND, "{path}");
563+ }
564+
565+ h.drop_schema().await;
566+}
567+
568+/// > Private repo object access via a public fork's URL.
569+///
570+/// Decided §13.5: forks get separate storage, no Git alternates. So a fork's URL
571+/// resolves to the fork's own repository row and its own storage — the parent's
572+/// visibility is irrelevant because the parent's objects are not there at all.
573+/// This pins that the fork relationship grants nothing.
574+#[tokio::test]
575+async fn a_public_fork_does_not_expose_its_private_parent() {
576+ let Some(h) = harness("fork").await else { return };
577+
578+ let (alice, _) = user(&h.db, "alice", false).await;
579+ let (bob, _) = user(&h.db, "bob", false).await;
580+ let (_, mallory) = user(&h.db, "mallory", false).await;
581+
582+ let parent = repo(&h.db, alice, "private-parent", true).await;
583+ let fork = repo(&h.db, bob, "public-fork", false).await;
584+ sqlx::query("UPDATE repos SET fork_of_repo_id = $2 WHERE id = $1")
585+ .bind(fork)
586+ .bind(parent)
587+ .execute(&h.db)
588+ .await
589+ .unwrap();
590+
591+ // Content that exists only in the parent.
592+ change(&h.db, parent, 1, "klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", "secret parent change").await;
593+ issue(&h.db, parent, 1, "secret parent issue").await;
594+
595+ // The fork is public and readable…
596+ assert_eq!(h.get("/bob/public-fork").await.status(), StatusCode::OK);
597+
598+ // …but the parent's content is not reachable through it, by number or by
599+ // change id, and the parent itself stays a 404.
600+ for path in [
601+ "/bob/public-fork/changes/1",
602+ "/bob/public-fork/changes/klxqnvpq",
603+ "/bob/public-fork/issues/1",
604+ "/alice/private-parent",
605+ ] {
606+ for session in [None, Some(mallory)] {
607+ let res = h.request(path, session).await;
608+ assert_eq!(res.status(), StatusCode::NOT_FOUND, "{path}");
609+ let body = h.body(res).await;
610+ assert!(!body.contains("secret parent"), "{path} leaked: {body}");
611+ }
612+ }
613+
614+ h.drop_schema().await;
615+}
616+
617+/// > Search results crossing repos.
618+#[tokio::test]
619+async fn search_never_returns_anything_the_viewer_cannot_open() {
620+ let Some(h) = harness("search").await else { return };
621+
622+ let (alice, alice_session) = user(&h.db, "alice", false).await;
623+ let (_, mallory) = user(&h.db, "mallory", false).await;
624+
625+ // The last term of a query is a *prefix* match, so both names must start
626+ // with the term for the search to be a fair test of visibility rather than
627+ // of tokenisation.
628+ let hidden = repo(&h.db, alice, "widgetsecret", true).await;
629+ let public = repo(&h.db, alice, "widgetopen", false).await;
630+
631+ change(&h.db, hidden, 1, "klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", "widgetconfidential change").await;
632+ issue(&h.db, hidden, 1, "widgetconfidential issue").await;
633+ change(&h.db, public, 1, "mmmmnvpqlnlvtkmuqmtmxktlnvnomvwv", "widgetpublic change").await;
634+
635+ for session in [None, Some(mallory)] {
636+ let res = h.request("/search?q=widget", session).await;
637+ assert_eq!(res.status(), StatusCode::OK);
638+ let body = h.body(res).await;
639+
640+ assert!(!body.contains("widgetconfidential"), "private content in search: {body}");
641+ assert!(!body.contains("widgetsecret"), "private repo name in search: {body}");
642+ assert!(body.contains("widgetopen"), "the public repo should be findable");
643+ }
644+
645+ // The owner does see their own private content, or the feature is useless.
646+ let res = h.get_as("/search?q=widget", &alice_session).await;
647+ let body = h.body(res).await;
648+ assert!(body.contains("widgetconfidential"), "the owner must see their own: {body}");
649+
650+ h.drop_schema().await;
651+}
652+
653+/// A profile page must not enumerate repositories the viewer cannot open.
654+#[tokio::test]
655+async fn a_profile_does_not_list_private_repositories_to_strangers() {
656+ let Some(h) = harness("profile").await else { return };
657+
658+ let (alice, alice_session) = user(&h.db, "alice", false).await;
659+ let (_, mallory) = user(&h.db, "mallory", false).await;
660+ // Distinctive names: `hidden` would also match the markup of every
661+ // `<input type="hidden">` on the page and the assertion would pass or fail
662+ // for the wrong reason.
663+ repo(&h.db, alice, "unlistable-repo", true).await;
664+ repo(&h.db, alice, "listable-repo", false).await;
665+
666+ for session in [None, Some(mallory)] {
667+ let res = h.request("/alice", session).await;
668+ assert_eq!(res.status(), StatusCode::OK);
669+ let body = h.body(res).await;
670+ assert!(
671+ !body.contains("unlistable-repo"),
672+ "private repo listed on a profile: {body}"
673+ );
674+ assert!(body.contains("listable-repo"));
675+ }
676+
677+ let body = h.body(h.get_as("/alice", &alice_session).await).await;
678+ assert!(
679+ body.contains("unlistable-repo"),
680+ "the owner must see their own repositories"
681+ );
682+
683+ h.drop_schema().await;
684+}
685+
686+/// A collaborator grant is what opens a private repository, and nothing else.
687+#[tokio::test]
688+async fn a_collaborator_grant_is_what_opens_a_private_repo() {
689+ let Some(h) = harness("collaborator").await else { return };
690+
691+ let (alice, _) = user(&h.db, "alice", false).await;
692+ let (bob, bob_session) = user(&h.db, "bob", false).await;
693+ let hidden = repo(&h.db, alice, "hidden", true).await;
694+
695+ assert_eq!(
696+ h.get_as("/alice/hidden", &bob_session).await.status(),
697+ StatusCode::NOT_FOUND
698+ );
699+
700+ sqlx::query("INSERT INTO repo_collaborators (repo_id, user_id, role) VALUES ($1, $2, 'read')")
701+ .bind(hidden)
702+ .bind(bob)
703+ .execute(&h.db)
704+ .await
705+ .unwrap();
706+
707+ assert_eq!(
708+ h.get_as("/alice/hidden", &bob_session).await.status(),
709+ StatusCode::OK,
710+ "a read collaborator must be able to open it"
711+ );
712+
713+ // …but read is not settings.
714+ assert_eq!(
715+ h.get_as("/alice/hidden/settings", &bob_session).await.status(),
716+ StatusCode::FORBIDDEN,
717+ "read access must not reach settings"
718+ );
719+
720+ h.drop_schema().await;
721+}
722+
723+/// > Enforce authorization in middleware before the handler runs.
724+///
725+/// A `read` collaborator sees a 403 on settings, not a 404 — they can already
726+/// see the repository, so nothing is disclosed — while a stranger sees a 404.
727+/// The distinction is the whole design of `AppError`.
728+#[tokio::test]
729+async fn a_403_is_only_ever_shown_to_somebody_who_can_already_see_the_repo() {
730+ let Some(h) = harness("403_vs_404").await else { return };
731+
732+ let (alice, _) = user(&h.db, "alice", false).await;
733+ let (bob, bob_session) = user(&h.db, "bob", false).await;
734+ let (_, mallory) = user(&h.db, "mallory", false).await;
735+ let hidden = repo(&h.db, alice, "hidden", true).await;
736+
737+ sqlx::query("INSERT INTO repo_collaborators (repo_id, user_id, role) VALUES ($1, $2, 'read')")
738+ .bind(hidden)
739+ .bind(bob)
740+ .execute(&h.db)
741+ .await
742+ .unwrap();
743+
744+ assert_eq!(
745+ h.get_as("/alice/hidden/settings", &bob_session).await.status(),
746+ StatusCode::FORBIDDEN
747+ );
748+ assert_eq!(
749+ h.get_as("/alice/hidden/settings", &mallory).await.status(),
750+ StatusCode::NOT_FOUND,
751+ "a stranger must not learn the repository exists"
752+ );
753+
754+ h.drop_schema().await;
755+}
756+
757+/// > Strict CSP with no `unsafe-inline`, `nosniff`, and framing denied — on
758+/// > every response, including errors.
759+#[tokio::test]
760+async fn security_headers_are_present_on_every_response() {
761+ let Some(h) = harness("headers").await else { return };
762+
763+ for path in ["/", "/does-not-exist", "/login"] {
764+ let res = h.get(path).await;
765+ let headers = res.headers();
766+
767+ let csp = headers
768+ .get("content-security-policy")
769+ .and_then(|v| v.to_str().ok())
770+ .unwrap_or_default()
771+ .to_owned();
772+
773+ assert!(!csp.is_empty(), "{path} has no CSP");
774+ assert!(!csp.contains("unsafe-inline"), "{path} CSP allows unsafe-inline: {csp}");
775+ assert!(!csp.contains("unsafe-eval"), "{path} CSP allows unsafe-eval: {csp}");
776+ assert!(csp.contains("frame-ancestors 'none'"), "{path}: {csp}");
777+ assert!(csp.contains("object-src 'none'"), "{path}: {csp}");
778+
779+ assert_eq!(
780+ headers.get("x-content-type-options").and_then(|v| v.to_str().ok()),
781+ Some("nosniff"),
782+ "{path}"
783+ );
784+ assert_eq!(
785+ headers.get("x-frame-options").and_then(|v| v.to_str().ok()),
786+ Some("DENY"),
787+ "{path}"
788+ );
789+ }
790+
791+ h.drop_schema().await;
792+}
793+
794+/// > `/metrics` — bind to loopback only.
795+///
796+/// Requests through this harness have no `ConnectInfo`, which is the same
797+/// position a request arriving without a resolvable peer is in. The endpoint
798+/// must refuse rather than default to serving.
799+#[tokio::test]
800+async fn metrics_are_not_served_without_a_loopback_peer() {
801+ let Some(h) = harness("metrics").await else { return };
802+
803+ let res = h.get("/metrics").await;
804+ assert_ne!(
805+ res.status(),
806+ StatusCode::OK,
807+ "metrics must not be served to a request that cannot prove it is local"
808+ );
809+
810+ h.drop_schema().await;
811+}
812+
813+/// > CSRF: double-submit cookie, validated on every non-GET.
814+#[tokio::test]
815+async fn state_changing_requests_without_a_csrf_token_are_refused() {
816+ let Some(h) = harness("csrf").await else { return };
817+
818+ let (alice, session) = user(&h.db, "alice", false).await;
819+ repo(&h.db, alice, "r", false).await;
820+
821+ for (method, path) in [
822+ ("POST", "/repos"),
823+ ("POST", "/logout"),
824+ ("POST", "/settings/keys"),
825+ ("POST", "/alice/r/settings/general"),
826+ ("POST", "/alice/r/issues"),
827+ ] {
828+ let req = Request::builder()
829+ .uri(path)
830+ .method(method)
831+ .header("cookie", format!("{}={session}", df_auth::session::COOKIE_NAME))
832+ .header("content-type", "application/x-www-form-urlencoded")
833+ .body(Body::from("name=x"))
834+ .unwrap();
835+
836+ let res = h.app.clone().oneshot(req).await.unwrap();
837+ assert_eq!(
838+ res.status(),
839+ StatusCode::FORBIDDEN,
840+ "{method} {path} was accepted without a CSRF token"
841+ );
842+ }
843+
844+ h.drop_schema().await;
845+}
846+
847+/// > Blob content served with `Content-Disposition: attachment` and
848+/// > `X-Content-Type-Options: nosniff` — never serve user-controlled HTML on
849+/// > the app origin.
850+///
851+/// The store here cannot produce a blob, so this asserts the reachable part:
852+/// that the raw route never answers with a content type a browser would render.
853+/// The header construction itself is pinned by a unit test next to the handler.
854+#[tokio::test]
855+async fn the_raw_route_never_serves_a_renderable_content_type() {
856+ let Some(h) = harness("raw").await else { return };
857+
858+ let (alice, _) = user(&h.db, "alice", false).await;
859+ repo(&h.db, alice, "r", false).await;
860+
861+ let res = h.get("/alice/r/raw/main/evil.html").await;
862+ let ct = res
863+ .headers()
864+ .get("content-type")
865+ .and_then(|v| v.to_str().ok())
866+ .unwrap_or_default()
867+ .to_owned();
868+ assert!(
869+ !ct.contains("text/html") || res.status() != StatusCode::OK,
870+ "raw served renderable HTML: {ct}"
871+ );
872+
873+ h.drop_schema().await;
874+}
875+
876+/// Path traversal must be refused at the parser, before any handler.
877+#[tokio::test]
878+async fn traversal_and_control_characters_are_refused() {
879+ let Some(h) = harness("traversal").await else { return };
880+
881+ let (alice, _) = user(&h.db, "alice", false).await;
882+ repo(&h.db, alice, "r", false).await;
883+
884+ for path in [
885+ "/alice/r/blob/main/../../../../etc/passwd",
886+ "/alice/r/blob/main/%2e%2e%2f%2e%2e%2fetc%2fpasswd",
887+ "/alice/r/raw/main/%00etc/passwd",
888+ "/alice/r/tree/main/../..",
889+ ] {
890+ let res = h.get(path).await;
891+ assert!(
892+ res.status().is_client_error(),
893+ "{path} returned {} instead of a client error",
894+ res.status()
895+ );
896+ }
897+
898+ h.drop_schema().await;
899+}
900+
901+/// A signed-out visitor must not be able to act, only to look.
902+#[tokio::test]
903+async fn anonymous_visitors_cannot_reach_authenticated_pages() {
904+ let Some(h) = harness("anon").await else { return };
905+
906+ for path in ["/settings", "/new", "/orgs/new"] {
907+ let res = h.get(path).await;
908+ assert_eq!(
909+ res.status(),
910+ StatusCode::SEE_OTHER,
911+ "{path} should redirect an anonymous visitor to sign in"
912+ );
913+ assert_eq!(
914+ res.headers().get("location").and_then(|v| v.to_str().ok()),
915+ Some("/login"),
916+ "{path}"
917+ );
918+ }
919+
920+ h.drop_schema().await;
921+}
922+
923+/// An archived repository is read-only. The settings page promises that in so
924+/// many words, so it has to be true on the wire — enforcing it only in the UI
925+/// would leave the promise false for every Git client.
926+#[tokio::test]
927+async fn an_archived_repository_refuses_pushes() {
928+ let Some(h) = harness("archived").await else { return };
929+
930+ let (alice, _) = user(&h.db, "alice", false).await;
931+ let repo_id = repo(&h.db, alice, "frozen", false).await;
932+ sqlx::query("UPDATE repos SET archived = true WHERE id = $1")
933+ .bind(repo_id)
934+ .execute(&h.db)
935+ .await
936+ .unwrap();
937+
938+ let token = df_auth::tokens::create(&h.db, alice, "push", &[], None)
939+ .await
940+ .expect("minting a token");
941+ let basic = base64::Engine::encode(
942+ &base64::engine::general_purpose::STANDARD,
943+ format!("alice:{}", token.plaintext),
944+ );
945+
946+ let req = Request::builder()
947+ .uri("/alice/frozen/git-receive-pack")
948+ .method("POST")
949+ .header("authorization", format!("Basic {basic}"))
950+ .header("content-type", "application/x-git-receive-pack-request")
951+ .body(Body::from("0000"))
952+ .unwrap();
953+
954+ let res = h.app.clone().oneshot(req).await.unwrap();
955+ assert_eq!(
956+ res.status(),
957+ StatusCode::BAD_REQUEST,
958+ "an archived repository accepted a push"
959+ );
960+
961+ // …and reads still work, or "archived" would just mean "deleted".
962+ assert_eq!(h.get("/alice/frozen").await.status(), StatusCode::OK);
963+
964+ h.drop_schema().await;
965+}
966+
967+/// A push to a repository the token's owner cannot write must be refused even
968+/// though the credentials are valid.
969+#[tokio::test]
970+async fn a_valid_token_does_not_grant_push_to_a_repo_you_cannot_write() {
971+ let Some(h) = harness("push_authz").await else { return };
972+
973+ let (alice, _) = user(&h.db, "alice", false).await;
974+ let (mallory, _) = user(&h.db, "mallory", false).await;
975+ repo(&h.db, alice, "theirs", true).await;
976+
977+ let token = df_auth::tokens::create(&h.db, mallory, "push", &[], None)
978+ .await
979+ .expect("minting a token");
980+ let basic = base64::Engine::encode(
981+ &base64::engine::general_purpose::STANDARD,
982+ format!("mallory:{}", token.plaintext),
983+ );
984+
985+ let req = Request::builder()
986+ .uri("/alice/theirs/git-receive-pack")
987+ .method("POST")
988+ .header("authorization", format!("Basic {basic}"))
989+ .body(Body::from("0000"))
990+ .unwrap();
991+
992+ let res = h.app.clone().oneshot(req).await.unwrap();
993+ assert_eq!(
994+ res.status(),
995+ StatusCode::NOT_FOUND,
996+ "a private repo must stay a 404 to an authenticated stranger, even on the push path"
997+ );
998+
999+ h.drop_schema().await;
1000+}
Acrates/df-web/src/state.rs+117−0
@@ −0,0 +1,117 @@
1+//! Shared application state and request-scoped context.
2+
3+use std::sync::Arc;
4+
5+use axum::extract::FromRequestParts;
6+use axum::http::request::Parts;
7+use df_auth::Oidc;
8+use df_db::models::User;
9+use df_db::PgPool;
10+
11+use crate::config::Config;
12+
13+pub struct Inner {
14+ pub db: PgPool,
15+ pub oidc: Oidc,
16+ pub config: Config,
17+ /// Repository storage, behind the trait so nothing here knows it is Git.
18+ pub store: Arc<dyn df_store::RepoStore>,
19+ /// Per-identity rate and concurrency limits (spec §9).
20+ pub limiter: crate::ratelimit::Limiter,
21+}
22+
23+#[derive(Clone)]
24+pub struct AppState(pub Arc<Inner>);
25+
26+impl std::ops::Deref for AppState {
27+ type Target = Inner;
28+ fn deref(&self) -> &Inner {
29+ &self.0
30+ }
31+}
32+
33+/// The signed-in user, resolved once per request by the session middleware and
34+/// stashed in request extensions.
35+///
36+/// Handlers take `CurrentUser` (optional) or `RequireUser` (enforced) rather
37+/// than reading cookies themselves.
38+#[derive(Clone)]
39+pub struct CurrentUser(pub Option<Arc<User>>);
40+
41+impl<S: Send + Sync> FromRequestParts<S> for CurrentUser {
42+ type Rejection = std::convert::Infallible;
43+
44+ async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
45+ Ok(parts
46+ .extensions
47+ .get::<CurrentUser>()
48+ .cloned()
49+ .unwrap_or(CurrentUser(None)))
50+ }
51+}
52+
53+/// The per-request CSRF token, minted or echoed by middleware.
54+#[derive(Clone)]
55+pub struct CsrfToken(pub String);
56+
57+impl<S: Send + Sync> FromRequestParts<S> for CsrfToken {
58+ type Rejection = std::convert::Infallible;
59+
60+ async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
61+ Ok(parts
62+ .extensions
63+ .get::<CsrfToken>()
64+ .cloned()
65+ .unwrap_or_else(|| CsrfToken(String::new())))
66+ }
67+}
68+
69+/// The connecting peer's address, when one is known.
70+///
71+/// `ConnectInfo<SocketAddr>` itself is not an optional extractor, so a handler
72+/// that took it directly would 500 on any request that arrived without it —
73+/// which is every request in a test, and any request at all if the server is
74+/// ever served without `into_make_service_with_connect_info`. This wrapper makes
75+/// absence a value the handler decides about rather than an error.
76+#[derive(Clone, Copy, Debug)]
77+pub struct PeerAddr(pub Option<std::net::SocketAddr>);
78+
79+impl PeerAddr {
80+ /// Whether the peer is provably local. Absence is not local.
81+ pub fn is_loopback(&self) -> bool {
82+ self.0.is_some_and(|a| a.ip().is_loopback())
83+ }
84+
85+ pub fn ip(&self) -> Option<std::net::IpAddr> {
86+ self.0.map(|a| a.ip())
87+ }
88+}
89+
90+impl<S: Send + Sync> FromRequestParts<S> for PeerAddr {
91+ type Rejection = std::convert::Infallible;
92+
93+ async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
94+ Ok(PeerAddr(
95+ parts
96+ .extensions
97+ .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
98+ .map(|c| c.0),
99+ ))
100+ }
101+}
102+
103+/// Per-request CSP nonce.
104+#[derive(Clone)]
105+pub struct Nonce(pub String);
106+
107+impl<S: Send + Sync> FromRequestParts<S> for Nonce {
108+ type Rejection = std::convert::Infallible;
109+
110+ async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
111+ Ok(parts
112+ .extensions
113+ .get::<Nonce>()
114+ .cloned()
115+ .unwrap_or_else(|| Nonce(String::new())))
116+ }
117+}
Acrates/df-web/src/views/change.rs+283−0
@@ −0,0 +1,283 @@
1+//! Change list and change detail (M3).
2+//!
3+//! The change list is the hottest page in the product (spec §4), so it reads
4+//! precomputed `change_edges` rather than walking the commit graph.
5+
6+use chrono::{DateTime, Utc};
7+use maud::{html, Markup};
8+
9+
10+use crate::repo_ctx::RepoContext;
11+
12+pub struct ChangeRow {
13+ pub number: i64,
14+ pub change_id: String,
15+ pub synthetic: bool,
16+ pub title: String,
17+ pub state: String,
18+ pub conflicted: bool,
19+ pub updated_at: DateTime<Utc>,
20+ pub author: Option<String>,
21+ pub revision_count: i64,
22+ /// Changes stacked directly on top of this one.
23+ pub children: Vec<String>,
24+}
25+
26+/// The change chip — the product's central concept made visible.
27+///
28+/// A real jj change id gets the identity accent and a monospace prefix. A
29+/// synthetic id gets no chip (spec §4: "the UI shows them without a change chip
30+/// and with reduced revision-history guarantees"), because presenting a
31+/// synthesised id as a change id would be a lie the user cannot detect.
32+pub fn change_chip(change_id: &str, synthetic: bool) -> Markup {
33+ html! {
34+ @if synthetic {
35+ span .chip title="Authored with plain git — identity derived from the patch" {
36+ "git"
37+ }
38+ } @else {
39+ span .chip.chip-change title=(format!("jj change id: {change_id}")) {
40+ (&change_id[..12.min(change_id.len())])
41+ }
42+ }
43+ }
44+}
45+
46+pub fn state_badge(state: &str, conflicted: bool) -> Markup {
47+ html! {
48+ @if conflicted {
49+ span .badge.badge-conflict { "conflict" }
50+ }
51+ @match state {
52+ "merged" => span .badge.badge-merged { "merged" },
53+ "abandoned" => span .badge.badge-abandoned { "abandoned" },
54+ "draft" => span .chip { "draft" },
55+ _ => span .badge.badge-open { "open" },
56+ }
57+ }
58+}
59+
60+pub struct ListFilters<'a> {
61+ pub state: &'a str,
62+ pub revset: &'a str,
63+ pub revset_error: Option<&'a str>,
64+}
65+
66+pub fn list(ctx: &RepoContext, rows: &[ChangeRow], f: ListFilters<'_>) -> Markup {
67+ let base = ctx.base();
68+
69+ html! {
70+ div .panel {
71+ form method="get" action=(format!("{base}/changes")) style="margin-bottom:16px" {
72+ div .row {
73+ @for (key, label) in [("open","Open"),("merged","Merged"),("abandoned","Abandoned"),("all","All")] {
74+ a href=(format!("{base}/changes?state={key}"))
75+ style=(if f.state == key { "color:var(--text);font-weight:500" } else { "" }) {
76+ (label)
77+ }
78+ }
79+ span style="margin-left:auto;flex:1;max-width:420px" {
80+ input type="text" name="revset" value=(f.revset)
81+ placeholder="revset — author(x) & ~conflicted()"
82+ aria-label="Filter by revset";
83+ }
84+ input type="hidden" name="state" value=(f.state);
85+ button .btn type="submit" { "Filter" }
86+ }
87+ @if let Some(e) = f.revset_error {
88+ div .banner.banner-error style="margin-top:12px" {
89+ strong { "Unsupported expression. " }
90+ (e)
91+ }
92+ }
93+ }
94+
95+ @if rows.is_empty() {
96+ div .empty {
97+ h2 { "No changes" }
98+ p { "Push with " code { "jj git push" } " and changes appear here." }
99+ }
100+ } @else {
101+ div .stack style="gap:0" {
102+ @for r in rows {
103+ div style="padding:12px 0;border-bottom:1px solid var(--border)" {
104+ div .row {
105+ (state_badge(&r.state, r.conflicted))
106+ a href=(format!("{base}/changes/{}", r.number)) style="font-weight:500" {
107+ (r.title)
108+ }
109+ }
110+ div .row style="margin-top:6px;gap:10px" {
111+ (change_chip(&r.change_id, r.synthetic))
112+ span .faint { "#" (r.number) }
113+ @if let Some(a) = &r.author { span .faint { (a) } }
114+ // Revision count is the visible payoff of stable
115+ // identity: one review, many rewrites.
116+ @if r.revision_count > 1 {
117+ span .faint title="revisions of this change" {
118+ (r.revision_count) " revisions"
119+ }
120+ }
121+ span .faint { (r.updated_at.format("%Y-%m-%d").to_string()) }
122+ }
123+ @if !r.children.is_empty() {
124+ div .row style="margin-top:6px;gap:6px" {
125+ span .label-condensed { "stacked under" }
126+ @for c in &r.children {
127+ span .chip { (&c[..12.min(c.len())]) }
128+ }
129+ }
130+ }
131+ }
132+ }
133+ }
134+ }
135+ }
136+ }
137+}
138+
139+/// Disambiguation page for an ambiguous change-id prefix (spec §7).
140+pub fn ambiguous(ctx: &RepoContext, prefix: &str, candidates: &[(i64, String, String)]) -> Markup {
141+ let base = ctx.base();
142+ html! {
143+ div .panel {
144+ h1 { "Ambiguous change id" }
145+ p .lede {
146+ "More than one change starts with " code { (prefix) } ". Pick one:"
147+ }
148+ div .stack style="gap:0" {
149+ @for (number, change_id, title) in candidates {
150+ div style="padding:10px 0;border-bottom:1px solid var(--border)" {
151+ a href=(format!("{base}/changes/{number}")) { (title) }
152+ div .row style="margin-top:4px;gap:8px" {
153+ span .chip.chip-change { (&change_id[..16.min(change_id.len())]) }
154+ span .faint { "#" (number) }
155+ }
156+ }
157+ }
158+ }
159+ }
160+ }
161+}
162+
163+#[cfg(test)]
164+mod tests {
165+ use super::*;
166+
167+ #[test]
168+ fn synthetic_changes_render_without_a_change_chip() {
169+ // Spec §4: "Do not pretend a synthetic identity is a real change ID."
170+ let synthetic = change_chip("ppwkwxvrwvxxyttp0000000000000000", true).into_string();
171+ assert!(synthetic.contains("git"));
172+ assert!(
173+ !synthetic.contains("chip-change"),
174+ "a synthetic id must not use the change-identity chip: {synthetic}"
175+ );
176+
177+ let real = change_chip("klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", false).into_string();
178+ assert!(real.contains("chip-change"));
179+ assert!(real.contains("klxqnvpqlnlv"));
180+ }
181+
182+ #[test]
183+ fn a_conflicted_change_shows_the_conflict_badge_regardless_of_state() {
184+ let m = state_badge("open", true).into_string();
185+ assert!(m.contains("badge-conflict"));
186+ let m = state_badge("merged", true).into_string();
187+ assert!(m.contains("badge-conflict"), "conflict must show on merged too");
188+ }
189+
190+ #[test]
191+ fn unknown_states_fall_back_to_open() {
192+ let m = state_badge("something-new", false).into_string();
193+ assert!(m.contains("badge-open"));
194+ }
195+}
196+
197+// ─── opening a change (M3) ───────────────────────────────────────────────────
198+
199+/// A change that could be proposed for review.
200+pub struct Proposable {
201+ pub change_id: String,
202+ pub number: i64,
203+ pub title: String,
204+ pub synthetic: bool,
205+ pub revisions: i64,
206+ pub state: String,
207+}
208+
209+/// The "open a change" form.
210+///
211+/// The wording is deliberate: the work already exists in the repository, and
212+/// this form proposes it. A jj forge that claimed to *create* a change here
213+/// would be describing something that already happened at push time.
214+pub fn new_change_form(
215+ ctx: &RepoContext,
216+ csrf: &str,
217+ candidates: &[Proposable],
218+ bookmarks: &[String],
219+ error: Option<&str>,
220+) -> Markup {
221+ let base = ctx.base();
222+ html! {
223+ div .panel {
224+ h1 { "Open a change" }
225+ p .lede {
226+ "Pushed work becomes a change the moment Dogfood sees its change id. \
227+ Opening one gives it a target, a title, and a description so it can be \
228+ reviewed."
229+ }
230+
231+ @if let Some(e) = error { div .banner.banner-error role="alert" { (e) } }
232+
233+ @if candidates.is_empty() {
234+ div .empty {
235+ h2 { "Nothing to open" }
236+ p { "Push with " code { "jj git push" } " and the work appears here." }
237+ }
238+ } @else {
239+ form method="post" action=(format!("{base}/changes")) .stack {
240+ input type="hidden" name="_csrf" value=(csrf);
241+
242+ div .field {
243+ label for="change" { "Change" }
244+ select id="change" name="change" required {
245+ @for c in candidates {
246+ option value=(c.change_id) {
247+ "#" (c.number) " · " (c.title)
248+ @if c.state == "draft" { " (draft)" }
249+ @if c.revisions > 1 { " · " (c.revisions) " revisions" }
250+ @if c.synthetic { " · git" }
251+ }
252+ }
253+ }
254+ }
255+
256+ div .field {
257+ label for="target_bookmark" { "Target bookmark" }
258+ select id="target_bookmark" name="target_bookmark" required {
259+ @for b in bookmarks {
260+ option value=(b) selected[*b == ctx.repo.default_bookmark] { (b) }
261+ }
262+ }
263+ p .hint { "Where this change is measured against, and where it lands." }
264+ }
265+
266+ div .field {
267+ label for="title" { "Title" }
268+ input type="text" id="title" name="title" required maxlength="300"
269+ placeholder="Defaults to the description of the top commit.";
270+ }
271+
272+ div .field {
273+ label for="description" { "Description" }
274+ textarea id="description" name="description" rows="6"
275+ placeholder="What this change does and why. Reviewers read this first." {}
276+ }
277+
278+ button .btn.btn-primary type="submit" { "Open change" }
279+ }
280+ }
281+ }
282+ }
283+}
Acrates/df-web/src/views/design.rs+278−0
@@ −0,0 +1,278 @@
1+//! The design system reference: a component sheet and a rationale page.
2+//!
3+//! These are internal documentation that renders itself. A swatch sheet built
4+//! from the same stylesheet as the product cannot drift from it — which is the
5+//! whole reason to have one, and the reason it is a route rather than a
6+//! screenshot in a wiki.
7+
8+use maud::{html, Markup};
9+
10+use crate::views::change::{change_chip, state_badge};
11+
12+/// Every colour token, as a swatch grid.
13+const NEUTRALS: &[(&str, &str)] = &[
14+ ("--bg", "page ground"),
15+ ("--surface", "panels, cards, bars"),
16+ ("--surface-raised", "controls on a surface"),
17+ ("--border", "hairlines"),
18+ ("--border-strong", "control edges"),
19+ ("--text", "body"),
20+ ("--text-dim", "secondary"),
21+ ("--text-faint", "tertiary, metadata"),
22+];
23+
24+const ACCENTS: &[(&str, &str)] = &[
25+ ("--brand", "the one primary move on a page"),
26+ ("--identity", "change ids, stack rails"),
27+ ("--action", "links"),
28+];
29+
30+const STATES: &[(&str, &str)] = &[
31+ ("--open", "open"),
32+ ("--merged", "merged"),
33+ ("--abandoned", "abandoned"),
34+ ("--conflict", "conflicted"),
35+ ("--danger", "destructive or broken"),
36+];
37+
38+fn swatches(title: &str, tokens: &[(&str, &str)]) -> Markup {
39+ html! {
40+ h3 .swatch-group-title { (title) }
41+ div .swatches {
42+ @for (token, use_for) in tokens {
43+ div .swatch {
44+ span .swatch-chip style=(format!("background:var({token})")) {}
45+ code .swatch-name { (token) }
46+ span .faint { (use_for) }
47+ }
48+ }
49+ }
50+ }
51+}
52+
53+fn section(id: &str, title: &str, body: Markup) -> Markup {
54+ html! {
55+ section .design-section id=(id) aria-labelledby=(format!("{id}-h")) {
56+ h2 #(format!("{id}-h")) .design-section-title { (title) }
57+ (body)
58+ }
59+ }
60+}
61+
62+/// `/design` — the component sheet.
63+pub fn sheet() -> Markup {
64+ html! {
65+ div .design-head {
66+ h1 .display.design-title { "Component sheet" }
67+ p .dim {
68+ "Every token and control, rendered from the same stylesheet the product uses. "
69+ a href="/design/rationale" { "Read the rationale" } "."
70+ }
71+ }
72+
73+ (section("tokens", "Colour tokens", html! {
74+ (swatches("Neutrals", NEUTRALS))
75+ (swatches("Accents", ACCENTS))
76+ (swatches("State", STATES))
77+ p .hint {
78+ "Every token has a light-theme counterpart. Switching the theme re-renders this
79+ page from the same variables, so a swatch that looks wrong here is wrong in the
80+ product."
81+ }
82+ }))
83+
84+ (section("type", "Typography", html! {
85+ div .type-specimens {
86+ p .display style="font-size:var(--text-2xl)" { "Display 40 / condensed 700" }
87+ p .display style="font-size:var(--text-xl)" { "Display 28 / condensed 700" }
88+ p style="font-size:var(--text-lg)" { "Large 20 / sans" }
89+ p style="font-size:var(--text-md)" { "Medium 16 / sans" }
90+ p { "Base 14 / sans — the default for body copy and controls." }
91+ p .mono { "Mono 12.5 / JetBrains — identifiers, paths, diffs, timestamps." }
92+ p .label-condensed { "Label 11 / condensed uppercase" }
93+ }
94+ }))
95+
96+ (section("buttons", "Buttons", html! {
97+ div .design-row {
98+ a .btn.btn-primary href="#buttons" { "Primary" }
99+ a .btn href="#buttons" { "Default" }
100+ a .btn.btn-danger href="#buttons" { "Danger" }
101+ span .btn.btn-quiet-danger { "Quiet danger" }
102+ span .btn.is-disabled aria-disabled="true" { "Disabled" }
103+ }
104+ div .design-row {
105+ a .btn.btn-primary.btn-lg href="#buttons" { "Large primary" }
106+ a .btn.btn-lg href="#buttons" { "Large default" }
107+ }
108+ p .hint {
109+ "Primary is brand vermilion and there is at most one on a screen. A second
110+ primary button means the page has not decided what it is for."
111+ }
112+ }))
113+
114+ (section("segmented", "Segmented control", html! {
115+ div .design-row {
116+ div .segmented role="group" aria-label="Example" {
117+ a .segmented-item.is-on href="#segmented" { "rendered" }
118+ a .segmented-item href="#segmented" { "source" }
119+ }
120+ }
121+ p .hint {
122+ "Two links, not a scripted toggle — each half is a real URL, so the control
123+ works without JavaScript and either view can be linked to."
124+ }
125+ }))
126+
127+ (section("chips", "Chips and badges", html! {
128+ div .design-row {
129+ (change_chip("kmnwolvtqrsz", false))
130+ (change_chip("anything", true))
131+ span .chip { "private" }
132+ span .chip { "draft" }
133+ }
134+ div .design-row {
135+ (state_badge("open", false))
136+ (state_badge("merged", false))
137+ (state_badge("abandoned", false))
138+ (state_badge("open", true))
139+ }
140+ p .hint {
141+ "A state is never colour alone — every badge carries its own name as text, so
142+ it survives a monochrome display and a colour-blind reader."
143+ }
144+ }))
145+
146+ (section("forms", "Form controls", html! {
147+ div .field {
148+ label for="d-text" { "Text input" }
149+ input type="text" id="d-text" value="dogfood";
150+ p .hint { "A hint sits under the control it explains." }
151+ }
152+ div .field {
153+ label for="d-area" { "Textarea" }
154+ textarea id="d-area" rows="3" { "Monospace, because it usually holds code." }
155+ }
156+ div .field {
157+ label for="d-select" { "Select" }
158+ select id="d-select" {
159+ option { "main" }
160+ option { "release" }
161+ }
162+ }
163+ }))
164+
165+ (section("banners", "Banners", html! {
166+ div .banner role="status" { "A neutral banner." }
167+ div .banner.banner-ok role="status" { "Something worked." }
168+ div .banner.banner-error role="alert" { "Something failed, and here is why." }
169+ }))
170+
171+ (section("empty", "Empty state", html! {
172+ div .empty {
173+ h2 { "No changes yet" }
174+ p { "Push one with " code { "jj git push" } "." }
175+ p { a .btn.btn-primary href="#empty" { "New repository" } }
176+ }
177+ }))
178+
179+ (section("compare", "Comparison list", html! {
180+ ul .compare {
181+ li .compare-item {
182+ span .compare-them {
183+ span .compare-sign aria-hidden="true" { "−" }
184+ "the old way"
185+ }
186+ span .compare-us {
187+ span .compare-sign aria-hidden="true" { "+" }
188+ "the new way"
189+ }
190+ }
191+ }
192+ }))
193+ }
194+}
195+
196+/// `/design/rationale` — why the system is the way it is.
197+///
198+/// Written against the current tokens. The prototype's own rationale page still
199+/// describes the pre-brand palette, so this is not a port of that text.
200+pub fn rationale() -> Markup {
201+ html! {
202+ article .rationale {
203+ header {
204+ h1 .display.design-title { "Design rationale" }
205+ p .dim {
206+ "One page, for the cases the component sheet does not cover. "
207+ a href="/design" { "Back to the component sheet" } "."
208+ }
209+ }
210+
211+ section {
212+ h2 { "Three accents, three jobs" }
213+ p .dim {
214+ "Vermilion is " em { "brand" } " — the wordmark, the one primary action on a
215+ page, the rule under the masthead, the slash before a section heading. Ochre
216+ is " em { "identity" } " — change ids and stack rails, and nothing else. Teal
217+ is " em { "action" } " — links. The test when adding an element: does it "
218+ em { "name" } " a change, does it " em { "do" } " something, or is it the
219+ product asserting itself? It is never two of those at once. If brand starts
220+ appearing on ordinary links it stops meaning anything, which is the failure
221+ mode to watch for."
222+ }
223+ }
224+
225+ section {
226+ h2 { "Conflict is magenta, not red" }
227+ p .dim {
228+ "In jj a conflict is a committed, reviewable state — not an error. Colouring
229+ it red would teach the wrong model on the first encounter and take a long
230+ time to unteach. Red is reserved for destructive actions and for things that
231+ are actually broken."
232+ }
233+ }
234+
235+ section {
236+ h2 { "The type pairing" }
237+ p .dim {
238+ "Mono carries the content: identifiers, paths, diffs, timestamps, numbers.
239+ IBM Plex Sans carries the interface around it. Plex Sans Condensed does two
240+ jobs — uppercase at 11px for labels and column headers, where it buys
241+ horizontal room in dense tables and subordinates the label to its value; and
242+ large and bold for display headings, where it gives the marketing surfaces a
243+ voice the product surfaces do not have. Base is 14px on 21px. If a new
244+ element holds data, set it in mono; if it explains data, sans; if it names a
245+ column of data, condense it."
246+ }
247+ p .dim {
248+ "All three faces are self-hosted Latin subsets. A forge should not tell a
249+ third party which repository you are reading, which is what a font CDN
250+ learns on every page load."
251+ }
252+ }
253+
254+ section {
255+ h2 { "Density over decoration" }
256+ p .dim {
257+ "Radius is 3px, borders are one hairline, and shadows appear only on things
258+ that genuinely float. The pages that matter — change lists, diffs, file
259+ trees — are read for hours, and every pixel of chrome is one not spent on
260+ code. The grid field and the brand glow are the only decorative elements in
261+ the system, they sit behind content rather than around it, and both are
262+ near-invisible by design."
263+ }
264+ }
265+
266+ section {
267+ h2 { "Nothing depends on JavaScript" }
268+ p .dim {
269+ "Progressive enhancement is a hard requirement, so every control here has a
270+ no-script form: toggles are links to real URLs, filters are GET forms, and
271+ the line numbers in a blob are anchors. htmx is loaded for enhancement only.
272+ A control that cannot be built this way is a control this system does not
273+ have."
274+ }
275+ }
276+ }
277+ }
278+}
Acrates/df-web/src/views/edit.rs+88−0
@@ −0,0 +1,88 @@
1+//! The in-browser file editor.
2+//!
3+//! A form with a `<textarea>` in it. That is the whole page, and it works with
4+//! scripting disabled — CodeMirror replaces the textarea when `/assets/editor.js`
5+//! loads and copies its content back on submit (spec §7: "Progressive
6+//! enhancement is a hard requirement").
7+//!
8+//! Nothing here is conditional on JavaScript being available. There is no
9+//! "loading" state and no hidden fallback: the fallback *is* the page.
10+
11+use maud::{html, Markup};
12+
13+use crate::repo_ctx::RepoContext;
14+use crate::views::repo::breadcrumbs;
15+
16+pub struct Editor<'a> {
17+ pub bookmark: &'a str,
18+ /// The revision the edit is composed against, submitted back so a
19+ /// concurrent edit is detected rather than overwritten.
20+ pub tip: &'a str,
21+ pub path: &'a str,
22+ pub content: &'a str,
23+ /// The grammar name CodeMirror should use, when we have one.
24+ pub language: Option<&'a str>,
25+ pub author: &'a str,
26+ pub csrf: &'a str,
27+ pub error: Option<&'a str>,
28+}
29+
30+pub fn editor(ctx: &RepoContext, e: Editor<'_>) -> Markup {
31+ let base = ctx.base();
32+ let action = format!("{base}/edit/{}/{}", e.bookmark, e.path);
33+ let cancel = format!("{base}/blob/{}/{}", e.bookmark, e.path);
34+
35+ html! {
36+ div .panel {
37+ div .row style="margin-bottom:12px" {
38+ span .chip { (e.bookmark) }
39+ (breadcrumbs(&base, e.bookmark, e.path))
40+ a .btn href=(cancel) style="margin-left:auto" { "Cancel" }
41+ }
42+
43+ @if let Some(msg) = e.error {
44+ div .banner.banner-error role="alert" { (msg) }
45+ }
46+
47+ form method="post" action=(action) data-editor-form="1" .stack {
48+ input type="hidden" name="_csrf" value=(e.csrf);
49+ // What the edit was composed against. The server refuses the
50+ // save if the bookmark has moved since.
51+ input type="hidden" name="tip" value=(e.tip);
52+
53+ div .field {
54+ label for="content" { "Contents of " (e.path) }
55+ div .editor-host {
56+ textarea id="content" name="content" rows="24"
57+ data-editor="1"
58+ data-language=[e.language]
59+ spellcheck="false"
60+ autocapitalize="off" autocomplete="off"
61+ wrap="off" { (e.content) }
62+ }
63+ }
64+
65+ div .field {
66+ label for="message" { "Commit message" }
67+ input type="text" id="message" name="message" maxlength="500"
68+ placeholder=(format!("Update {}", e.path));
69+ p .hint {
70+ "Committed to " code { (e.bookmark) } " as " (e.author)
71+ ". This is an ordinary commit — it appears in the log and \
72+ is indexed like any push."
73+ }
74+ }
75+
76+ div .row {
77+ button .btn.btn-primary type="submit" { "Commit changes" }
78+ a .btn href=(cancel) { "Cancel" }
79+ }
80+ }
81+
82+ p .hint {
83+ "Editing here writes a commit on top of " code { (e.bookmark) }
84+ ". It never rewrites history, so a protected bookmark accepts it."
85+ }
86+ }
87+ }
88+}
Acrates/df-web/src/views/issue.rs+359−0
@@ −0,0 +1,359 @@
1+//! Issues, labels and assignees (M5).
2+
3+use chrono::{DateTime, Utc};
4+use maud::{html, Markup, PreEscaped};
5+
6+use crate::repo_ctx::RepoContext;
7+use crate::views::review::CommentRow;
8+
9+pub struct Label {
10+ pub name: String,
11+ pub color: String,
12+}
13+
14+/// A label chip.
15+///
16+/// The colour is repository-controlled, so it reaches a `style` attribute — the
17+/// one place in the product where that is true. It is validated as a hex triple
18+/// before it gets here; `label_chip_rejects_a_non_colour` pins that anything
19+/// else falls back to the theme rather than being emitted.
20+pub fn label_chip(l: &Label) -> Markup {
21+ let safe = valid_hex(&l.color);
22+ html! {
23+ span .chip.label-chip
24+ style=[safe.then(|| format!("border-color:{0};color:{0}", l.color))] {
25+ (l.name)
26+ }
27+ }
28+}
29+
30+/// Whether a string is a `#rrggbb` colour and nothing else.
31+pub fn valid_hex(s: &str) -> bool {
32+ s.len() == 7
33+ && s.starts_with('#')
34+ && s[1..].bytes().all(|b| b.is_ascii_hexdigit())
35+}
36+
37+pub struct IssueRow {
38+ pub number: i64,
39+ pub title: String,
40+ pub state: String,
41+ pub author: Option<String>,
42+ pub updated_at: DateTime<Utc>,
43+ pub comment_count: i64,
44+ pub labels: Vec<Label>,
45+ pub assignees: Vec<String>,
46+}
47+
48+pub struct ListFilters<'a> {
49+ pub state: &'a str,
50+ pub label: Option<&'a str>,
51+ pub assignee: Option<&'a str>,
52+ pub all_labels: &'a [Label],
53+}
54+
55+pub fn list(ctx: &RepoContext, rows: &[IssueRow], f: ListFilters<'_>) -> Markup {
56+ let base = ctx.base();
57+ html! {
58+ div .panel {
59+ div .row style="margin-bottom:14px" {
60+ @for (key, label) in [("open", "Open"), ("closed", "Closed"), ("all", "All")] {
61+ a href=(format!("{base}/issues?state={key}"))
62+ style=(if f.state == key { "color:var(--text);font-weight:500" } else { "" }) {
63+ (label)
64+ }
65+ }
66+
67+ form method="get" action=(format!("{base}/issues")) .row style="margin-left:auto;gap:8px" {
68+ input type="hidden" name="state" value=(f.state);
69+ select name="label" aria-label="Filter by label" {
70+ option value="" { "any label" }
71+ @for l in f.all_labels {
72+ option value=(l.name) selected[f.label == Some(l.name.as_str())] { (l.name) }
73+ }
74+ }
75+ input type="text" name="assignee" value=[f.assignee]
76+ placeholder="assignee" aria-label="Filter by assignee";
77+ button .btn type="submit" { "Filter" }
78+ }
79+
80+ a .btn.btn-primary href=(format!("{base}/issues/new")) { "New issue" }
81+ }
82+
83+ @if rows.is_empty() {
84+ div .empty {
85+ h2 { "No issues" }
86+ p { "Nothing matches this filter." }
87+ }
88+ } @else {
89+ div .stack style="gap:0" {
90+ @for r in rows {
91+ div style="padding:12px 0;border-bottom:1px solid var(--border)" {
92+ div .row {
93+ @if r.state == "closed" {
94+ span .badge.badge-merged { "closed" }
95+ } @else {
96+ span .badge.badge-open { "open" }
97+ }
98+ a href=(format!("{base}/issues/{}", r.number)) style="font-weight:500" {
99+ (r.title)
100+ }
101+ @for l in &r.labels { (label_chip(l)) }
102+ }
103+ div .row style="margin-top:6px;gap:10px" {
104+ span .faint { "#" (r.number) }
105+ @if let Some(a) = &r.author { span .faint { "by " (a) } }
106+ @if !r.assignees.is_empty() {
107+ span .faint { "assigned to " (r.assignees.join(", ")) }
108+ }
109+ @if r.comment_count > 0 {
110+ span .faint { (r.comment_count) " comment(s)" }
111+ }
112+ span .faint { (r.updated_at.format("%Y-%m-%d").to_string()) }
113+ }
114+ }
115+ }
116+ }
117+ }
118+ }
119+ }
120+}
121+
122+pub struct NewIssue<'a> {
123+ pub csrf: &'a str,
124+ pub labels: &'a [Label],
125+ pub error: Option<&'a str>,
126+}
127+
128+pub fn new_form(ctx: &RepoContext, n: NewIssue<'_>) -> Markup {
129+ let base = ctx.base();
130+ html! {
131+ div .panel {
132+ h1 { "New issue" }
133+ @if let Some(e) = n.error { div .banner.banner-error role="alert" { (e) } }
134+
135+ form method="post" action=(format!("{base}/issues")) .stack {
136+ input type="hidden" name="_csrf" value=(n.csrf);
137+ div .field {
138+ label for="title" { "Title" }
139+ input type="text" id="title" name="title" required maxlength="300" autofocus;
140+ }
141+ div .field {
142+ label for="body" { "Description" }
143+ textarea id="body" name="body" rows="8"
144+ placeholder="Markdown is supported. #123 links an issue, @handle a person." {}
145+ }
146+ @if !n.labels.is_empty() {
147+ fieldset style="border:none;padding:0;margin:0" {
148+ legend .label-condensed { "Labels" }
149+ div .row style="flex-wrap:wrap;gap:12px" {
150+ @for l in n.labels {
151+ label {
152+ input type="checkbox" name="labels" value=(l.name);
153+ " " (l.name)
154+ }
155+ }
156+ }
157+ }
158+ }
159+ button .btn.btn-primary type="submit" { "Open issue" }
160+ }
161+ }
162+ }
163+}
164+
165+pub struct Detail<'a> {
166+ pub number: i64,
167+ pub title: &'a str,
168+ pub body_html: &'a str,
169+ pub state: &'a str,
170+ pub author: Option<&'a str>,
171+ pub created_at: DateTime<Utc>,
172+ pub labels: &'a [Label],
173+ pub all_labels: &'a [Label],
174+ pub assignees: &'a [String],
175+ pub comments: &'a [CommentRow],
176+ /// Changes and issues that reference this one.
177+ pub referenced_by: &'a [(String, i64, String)],
178+ pub can_comment: bool,
179+ pub can_manage: bool,
180+ pub csrf: &'a str,
181+}
182+
183+pub fn detail(ctx: &RepoContext, d: Detail<'_>) -> Markup {
184+ let base = format!("{}/issues/{}", ctx.base(), d.number);
185+
186+ html! {
187+ div .panel {
188+ div .row {
189+ @if d.state == "closed" {
190+ span .badge.badge-merged { "closed" }
191+ } @else {
192+ span .badge.badge-open { "open" }
193+ }
194+ h1 style="margin:0" { (d.title) }
195+ }
196+ div .row style="margin-top:8px;gap:10px" {
197+ span .faint { "#" (d.number) }
198+ @if let Some(a) = d.author { span .faint { "opened by " (a) } }
199+ span .faint { (d.created_at.format("%Y-%m-%d").to_string()) }
200+ @for l in d.labels { (label_chip(l)) }
201+ }
202+
203+ @if !d.assignees.is_empty() {
204+ p .dim style="margin-top:8px" { "Assigned to " (d.assignees.join(", ")) }
205+ }
206+
207+ @if !d.body_html.is_empty() {
208+ div .markdown-body style="margin-top:16px" { (PreEscaped(d.body_html)) }
209+ }
210+ }
211+
212+ @if !d.referenced_by.is_empty() {
213+ div .panel {
214+ h2 { "Referenced by" }
215+ div .stack style="gap:6px" {
216+ @for (kind, number, title) in d.referenced_by {
217+ div .row {
218+ span .faint { (kind) }
219+ a href=(format!("{}/{}s/{number}", ctx.base(), kind)) {
220+ "#" (number) " · " (title)
221+ }
222+ }
223+ }
224+ }
225+ }
226+ }
227+
228+ @if d.can_manage {
229+ div .panel {
230+ h2 { "Manage" }
231+
232+ form method="post" action=(format!("{base}/labels")) .stack {
233+ input type="hidden" name="_csrf" value=(d.csrf);
234+ fieldset style="border:none;padding:0;margin:0" {
235+ legend .label-condensed { "Labels" }
236+ div .row style="flex-wrap:wrap;gap:12px" {
237+ @for l in d.all_labels {
238+ label {
239+ input type="checkbox" name="labels" value=(l.name)
240+ checked[d.labels.iter().any(|x| x.name == l.name)];
241+ " " (l.name)
242+ }
243+ }
244+ }
245+ }
246+ button .btn type="submit" { "Save labels" }
247+ }
248+
249+ form method="post" action=(format!("{base}/assignees")) .stack style="margin-top:16px" {
250+ input type="hidden" name="_csrf" value=(d.csrf);
251+ div .field {
252+ label for="assignees" { "Assignees" }
253+ input type="text" id="assignees" name="assignees"
254+ value=(d.assignees.join(", "))
255+ placeholder="handles, comma separated";
256+ }
257+ button .btn type="submit" { "Save assignees" }
258+ }
259+ }
260+ }
261+
262+ div .panel {
263+ h2 { "Discussion" }
264+ @if d.comments.is_empty() {
265+ p .hint { "No comments yet." }
266+ }
267+ div .stack {
268+ @for c in d.comments { (issue_comment(c)) }
269+ }
270+
271+ @if d.can_comment {
272+ form method="post" action=(format!("{base}/comments")) .stack style="margin-top:20px" {
273+ input type="hidden" name="_csrf" value=(d.csrf);
274+ div .field {
275+ label for="body" { "Comment" }
276+ textarea id="body" name="body" rows="4" required {}
277+ }
278+ div .row {
279+ button .btn.btn-primary type="submit" { "Comment" }
280+ @if d.can_manage {
281+ button .btn type="submit" name="state"
282+ value=(if d.state == "closed" { "open" } else { "closed" }) {
283+ @if d.state == "closed" { "Comment and reopen" } @else { "Comment and close" }
284+ }
285+ }
286+ }
287+ }
288+ } @else {
289+ p .hint { "Sign in to comment." }
290+ }
291+
292+ @if d.can_manage {
293+ form method="post" action=(format!("{base}/state")) style="margin-top:12px" {
294+ input type="hidden" name="_csrf" value=(d.csrf);
295+ input type="hidden" name="state"
296+ value=(if d.state == "closed" { "open" } else { "closed" });
297+ button .btn type="submit" {
298+ @if d.state == "closed" { "Reopen issue" } @else { "Close issue" }
299+ }
300+ }
301+ }
302+ }
303+ }
304+}
305+
306+fn issue_comment(c: &CommentRow) -> Markup {
307+ html! {
308+ div .comment {
309+ div .row {
310+ strong { (c.author) }
311+ span .faint { (c.created_at.format("%Y-%m-%d %H:%M").to_string()) }
312+ @if c.edited { span .faint { "edited" } }
313+ }
314+ div .comment-body.markdown-body { (PreEscaped(&c.body_html)) }
315+ }
316+ }
317+}
318+
319+#[cfg(test)]
320+mod tests {
321+ use super::*;
322+
323+ fn label(color: &str) -> Label {
324+ Label { name: "bug".into(), color: color.into() }
325+ }
326+
327+ #[test]
328+ fn a_valid_colour_reaches_the_style_attribute() {
329+ let m = label_chip(&label("#d06b6b")).into_string();
330+ assert!(m.contains("border-color:#d06b6b"), "{m}");
331+ }
332+
333+ /// A label colour is the one repository-controlled value that reaches a
334+ /// `style` attribute. Anything that is not a hex triple must be dropped,
335+ /// not escaped and emitted.
336+ #[test]
337+ fn label_chip_rejects_a_non_colour() {
338+ for bad in [
339+ "red; background:url(javascript:alert(1))",
340+ "#zzzzzz",
341+ "#fff",
342+ "",
343+ "expression(alert(1))",
344+ ] {
345+ let m = label_chip(&label(bad)).into_string();
346+ assert!(!m.contains("style="), "{bad} reached a style attribute: {m}");
347+ assert!(m.contains("bug"), "the label name must still render");
348+ }
349+ }
350+
351+ #[test]
352+ fn hex_validation_is_exact() {
353+ assert!(valid_hex("#000000"));
354+ assert!(valid_hex("#AbCdEf"));
355+ assert!(!valid_hex("#abc"));
356+ assert!(!valid_hex("000000"));
357+ assert!(!valid_hex("#0000000"));
358+ }
359+}
Acrates/df-web/src/views/layout.rs+190−0
@@ −0,0 +1,190 @@
1+//! Page chrome.
2+//!
3+//! Every page renders standalone, with no JavaScript required (spec §7:
4+//! "Progressive enhancement is a hard requirement"). htmx is loaded for
5+//! enhancement only — nothing here depends on it running.
6+
7+use maud::{html, Markup, DOCTYPE};
8+
9+use df_db::models::User;
10+
11+/// Per-request context the chrome needs.
12+pub struct Chrome<'a> {
13+ pub title: &'a str,
14+ pub user: Option<&'a User>,
15+ /// CSRF token, attached to the body so htmx sends it on every non-GET.
16+ pub csrf: &'a str,
17+ /// Nonce for the one inline script. The CSP forbids `unsafe-inline`
18+ /// (spec §9), so the nonce is what allows it.
19+ pub nonce: &'a str,
20+}
21+
22+/// The wordmark: a vermilion triangle with the page ground knocked out of it,
23+/// beside the name.
24+///
25+/// The glyph is decorative — "Dogfood" beside it is the accessible name — so it
26+/// is hidden from assistive technology rather than announced as a shape. The
27+/// inner cutout is filled with `var(--bg)` rather than a fixed colour so the
28+/// mark stays correct in both themes.
29+fn brand(href: &str) -> Markup {
30+ html! {
31+ a .brand href=(href) {
32+ svg .brand-mark width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" {
33+ path d="M8 1 15 14H1L8 1Z" fill="currentColor" {}
34+ path d="M8 6 11.5 12h-7L8 6Z" fill="var(--bg)" {}
35+ }
36+ span { "Dogfood" }
37+ }
38+ }
39+}
40+
41+/// Wrap page content in the site chrome.
42+pub fn page(chrome: Chrome<'_>, content: Markup) -> Markup {
43+ html! {
44+ (DOCTYPE)
45+ html lang="en" {
46+ head {
47+ meta charset="utf-8";
48+ meta name="viewport" content="width=device-width, initial-scale=1";
49+ title { (chrome.title) " · Dogfood" }
50+ link rel="stylesheet" href="/assets/app.css";
51+ meta name="color-scheme" content="dark light";
52+ meta name="theme-color" media="(prefers-color-scheme: light)" content="#f4f1ea";
53+ meta name="theme-color" media="(prefers-color-scheme: dark)" content="#12100e";
54+ // Blocking (no `defer`) and first, so a stored "light"
55+ // preference lands before the default dark theme paints.
56+ script src="/assets/theme-init.js" nonce=(chrome.nonce) {}
57+ }
58+ body .grid-field
59+ hx-headers=(format!(r#"{{"x-csrf-token": "{}"}}"#, chrome.csrf))
60+ {
61+ // The first focusable thing on the page, visible only when
62+ // focused. Without it a keyboard user tabs through the whole
63+ // masthead on every page before reaching the content.
64+ a .skip-link href="#main" { "Skip to content" }
65+
66+ header .masthead {
67+ // The brand rule. Decorative, and the only full-bleed use
68+ // of the accent in the chrome.
69+ span .masthead-rule aria-hidden="true" {}
70+ div .masthead-inner {
71+ (brand("/"))
72+ // A plain GET form, so search works with scripting
73+ // disabled like everything else (spec §7).
74+ form .masthead-search method="get" action="/search" role="search" {
75+ input type="search" name="q" placeholder="Search"
76+ aria-label="Search repositories, changes and issues";
77+ }
78+ // JS-only: there is no server-side notion of theme to
79+ // toggle without it, so it starts hidden and theme.js
80+ // reveals it. The site's default dark theme is what
81+ // every visitor without scripting sees.
82+ button .btn.theme-toggle type="button" data-theme-toggle
83+ hidden aria-label="Switch between dark and light theme" {
84+ svg .icon-sun width="16" height="16" viewBox="0 0 16 16"
85+ fill="none" stroke="currentColor" stroke-width="1.5"
86+ aria-hidden="true" {
87+ circle cx="8" cy="8" r="3.25" {}
88+ path d="M8 1.2v1.8M8 13v1.8M2.6 8H1M15 8h-1.6M3.6 3.6l1.3 1.3M11.1 11.1l1.3 1.3M12.4 3.6l-1.3 1.3M4.9 11.1l-1.3 1.3" stroke-linecap="round" {}
89+ }
90+ svg .icon-moon width="16" height="16" viewBox="0 0 16 16"
91+ fill="currentColor" aria-hidden="true" {
92+ path d="M13.8 10.2A6 6 0 0 1 5.8 2.2a6.3 6.3 0 1 0 8 8Z" {}
93+ }
94+ }
95+ nav aria-label="Account" {
96+ @if let Some(u) = chrome.user {
97+ a href="/new" { "New repository" }
98+ a href="/orgs/new" { "New organization" }
99+ a href="/settings" { (u.handle) }
100+ form method="post" action="/logout" style="display:inline" {
101+ input type="hidden" name="_csrf" value=(chrome.csrf);
102+ button .btn.btn-quiet-danger type="submit" { "Sign out" }
103+ }
104+ } @else {
105+ a .btn href="/login" { "Sign in" }
106+ a .btn.btn-primary href="/login" { "Start free" }
107+ }
108+ }
109+ }
110+ }
111+
112+ // `tabindex="-1"` so the skip link can move focus here, not
113+ // just scroll to it — without it the next Tab press would go
114+ // back to the top of the page.
115+ main id="main" tabindex="-1" {
116+ div .wrap {
117+ (content)
118+ }
119+ }
120+
121+ footer {
122+ div .wrap {
123+ span .footer-mark { "Dogfood" }
124+ span { "code hosting built on Jujutsu" }
125+ a href="https://github.com/jj-vcs/jj" { "jj" }
126+ // A component sheet nobody can find does not get kept
127+ // up to date, so it gets a real link.
128+ a href="/design" { "Design" }
129+ span .footer-end .mono { "© 2026" }
130+ }
131+ }
132+
133+ // htmx is progressive enhancement only. If it fails to load,
134+ // every link and form still works as plain HTML.
135+ script src="/assets/htmx.min.js" nonce=(chrome.nonce) defer {}
136+ script src="/assets/theme.js" nonce=(chrome.nonce) defer {}
137+ }
138+ }
139+ }
140+}
141+
142+/// A minimal error page rendered without request context.
143+///
144+/// Used by the error type, which runs after extractors and so cannot rely on
145+/// the session or CSRF token being available.
146+pub fn bare_error(status: axum::http::StatusCode, message: &str) -> Markup {
147+ html! {
148+ (DOCTYPE)
149+ html lang="en" {
150+ head {
151+ meta charset="utf-8";
152+ meta name="viewport" content="width=device-width, initial-scale=1";
153+ title { (status.as_u16()) " · Dogfood" }
154+ link rel="stylesheet" href="/assets/app.css";
155+ }
156+ body .grid-field {
157+ a .skip-link href="#main" { "Skip to content" }
158+ header .masthead {
159+ span .masthead-rule aria-hidden="true" {}
160+ div .masthead-inner {
161+ (brand("/"))
162+ }
163+ }
164+ main id="main" tabindex="-1" {
165+ div .wrap {
166+ div .panel {
167+ h1 { (status.as_u16()) " " (status.canonical_reason().unwrap_or("Error")) }
168+ p .dim { (message) }
169+ p { a href="/" { "Back to the dashboard" } }
170+ }
171+ }
172+ }
173+ }
174+ }
175+ }
176+}
177+
178+/// A standalone error page.
179+pub fn error_page(chrome: Chrome<'_>, heading: &str, detail: &str) -> Markup {
180+ page(
181+ chrome,
182+ html! {
183+ div .panel {
184+ h1 { (heading) }
185+ p .dim { (detail) }
186+ p { a href="/" { "Back to the dashboard" } }
187+ }
188+ },
189+ )
190+}
Acrates/df-web/src/views/mod.rs+80−0
@@ −0,0 +1,80 @@
1+//! Maud templates.
2+//!
3+//! Every fragment that htmx can swap is also a function that renders
4+//! standalone, so each URL works without JavaScript (spec §7).
5+
6+pub mod change;
7+pub mod design;
8+pub mod edit;
9+pub mod issue;
10+pub mod layout;
11+pub mod pages;
12+pub mod repo;
13+pub mod review;
14+pub mod settings;
15+
16+pub use layout::{error_page, page, Chrome};
17+
18+use chrono::{DateTime, Utc};
19+
20+/// A short, coarse "how long ago" label.
21+///
22+/// Feeds and file listings in the design are scanned, not read — a reader wants
23+/// to know whether something is fresh, not the minute it landed. Coarse buckets
24+/// say that faster than a timestamp does, so the exact time goes in a `title`
25+/// attribute at the call site for anyone who needs it.
26+///
27+/// Deliberately has no "just now" case below a minute: a feed row that says
28+/// "0m" reads as broken, and one that says "just now" goes stale the moment the
29+/// page is cached.
30+pub fn relative_time(then: DateTime<Utc>, now: DateTime<Utc>) -> String {
31+ let secs = (now - then).num_seconds();
32+
33+ // A clock skew between the app and the database should not render as a
34+ // date in the future.
35+ if secs <= 0 {
36+ return "now".into();
37+ }
38+
39+ const MINUTE: i64 = 60;
40+ const HOUR: i64 = 60 * MINUTE;
41+ const DAY: i64 = 24 * HOUR;
42+ const MONTH: i64 = 30 * DAY;
43+ const YEAR: i64 = 365 * DAY;
44+
45+ match secs {
46+ s if s < MINUTE => "now".into(),
47+ s if s < HOUR => format!("{}m", s / MINUTE),
48+ s if s < DAY => format!("{}h", s / HOUR),
49+ s if s < MONTH => format!("{}d", s / DAY),
50+ s if s < YEAR => format!("{}mo", s / MONTH),
51+ s => format!("{}y", s / YEAR),
52+ }
53+}
54+
55+#[cfg(test)]
56+mod relative_time_tests {
57+ use super::relative_time;
58+ use chrono::{Duration, Utc};
59+
60+ #[test]
61+ fn buckets_read_as_labels() {
62+ let now = Utc::now();
63+ let ago = |d: Duration| relative_time(now - d, now);
64+
65+ assert_eq!(ago(Duration::seconds(5)), "now");
66+ assert_eq!(ago(Duration::minutes(3)), "3m");
67+ assert_eq!(ago(Duration::hours(5)), "5h");
68+ assert_eq!(ago(Duration::days(9)), "9d");
69+ assert_eq!(ago(Duration::days(70)), "2mo");
70+ assert_eq!(ago(Duration::days(800)), "2y");
71+ }
72+
73+ /// A row timestamped slightly in the future is a clock skew, not a
74+ /// scheduled post — it must not render as one.
75+ #[test]
76+ fn a_future_timestamp_does_not_render_as_negative() {
77+ let now = Utc::now();
78+ assert_eq!(relative_time(now + Duration::hours(3), now), "now");
79+ }
80+}
Acrates/df-web/src/views/pages.rs529 lines+529−0
@@ −0,0 +1,529 @@
1+//! Page bodies for the M0 surface.
2+
3+use chrono::{DateTime, Utc};
4+use maud::{html, Markup};
5+
6+use df_db::models::User;
7+
8+use crate::views::change::change_chip;
9+use crate::views::relative_time;
10+use crate::views::repo::avatar;
11+
12+/// One row of the public "shipping right now" feed.
13+pub struct FeedItem {
14+ pub owner: String,
15+ pub repo: String,
16+ pub number: i64,
17+ pub change_id: String,
18+ pub synthetic: bool,
19+ pub title: String,
20+ /// `None` for a change pushed by somebody with no Dogfood account — the
21+ /// indexer records those, and dropping the row would misrepresent activity.
22+ pub author: Option<String>,
23+ pub when: DateTime<Utc>,
24+}
25+
26+/// The pitch, stated as a diff. Deliberately not configurable: it is copy.
27+const COMPARISONS: &[(&str, &str)] = &[
28+ ("detached HEAD, lost work", "every state is recoverable"),
29+ ("rebase hell across a stack", "stacks rebase themselves"),
30+ ("merge conflicts block you", "conflicts are just a state"),
31+ ("force-push rewrites history", "stable ids survive rewrites"),
32+];
33+
34+const TICKER: &[&str] = &[
35+ "changes, not commits",
36+ "conflicts are first-class",
37+ "stacks that actually stack",
38+ "stable change ids",
39+ "undo anything",
40+ "no staging area",
41+];
42+
43+/// Landing page for signed-out visitors.
44+pub fn landing(clone_hint: &str, feed: &[FeedItem], repos: &[RepoSummary]) -> Markup {
45+ let now = Utc::now();
46+
47+ html! {
48+ section .hero.brand-glow aria-labelledby="hero-h" {
49+ div .hero-body {
50+ div .hero-eyebrow {
51+ span .pill-brand { "beta" }
52+ span .label-condensed { "version control, evolved" }
53+ }
54+ h1 #hero-h .display.hero-title {
55+ "Ship code in " span .mark { "changes" } ", not commits."
56+ }
57+ p .hero-lede {
58+ "Dogfood is code hosting built on "
59+ a href="https://github.com/jj-vcs/jj" { "Jujutsu" }
60+ ". Every change keeps a stable id through every rebase, conflicts \
61+ are recorded instead of blocking you, and stacks stay coherent \
62+ from first push to merge."
63+ }
64+ div .hero-actions {
65+ a .btn.btn-primary.btn-lg href="/login" { "Start for free" }
66+ @if let Some(r) = repos.first() {
67+ a .btn.btn-lg href=(format!("/{}/{}", r.owner, r.name)) {
68+ "Explore a live repo"
69+ }
70+ }
71+ }
72+ div .clone-box {
73+ code { (clone_hint) }
74+ }
75+ }
76+ // Decorative marquee of taglines. Static text, not a marquee
77+ // element — it wraps rather than scrolling, so nothing moves for a
78+ // reader who did not ask for motion.
79+ div .ticker-rule.ticker {
80+ @for t in TICKER {
81+ span .ticker-item {
82+ span .ticker-dot aria-hidden="true" { "◆" }
83+ (t)
84+ }
85+ }
86+ }
87+ }
88+
89+ div .landing-columns {
90+ section .landing-main aria-labelledby="feed-h" {
91+ div .section-head {
92+ h2 #feed-h .label-condensed.slash { "shipping right now" }
93+ span .faint.mono.live-indicator {
94+ span .live-dot aria-hidden="true" {}
95+ "live"
96+ }
97+ }
98+ p .dim.section-note {
99+ "Public changes people are pushing across Dogfood repositories."
100+ }
101+ @if feed.is_empty() {
102+ div .empty {
103+ h2 { "Nothing public yet" }
104+ p { "Changes pushed to public repositories will show up here." }
105+ }
106+ } @else {
107+ ul .feed {
108+ @for item in feed {
109+ (feed_row(item, now))
110+ }
111+ }
112+ }
113+ }
114+
115+ aside .landing-aside {
116+ section aria-labelledby="why-h" {
117+ h2 #why-h .label-condensed.slash { "why switch" }
118+ ul .compare {
119+ @for (them, us) in COMPARISONS {
120+ li .compare-item {
121+ span .compare-them {
122+ span .compare-sign aria-hidden="true" { "−" }
123+ (them)
124+ }
125+ span .compare-us {
126+ span .compare-sign aria-hidden="true" { "+" }
127+ (us)
128+ }
129+ }
130+ }
131+ }
132+ }
133+
134+ @if !repos.is_empty() {
135+ section aria-labelledby="repos-h" {
136+ h2 #repos-h .label-condensed.slash { "explore public repos" }
137+ div .repo-cards {
138+ @for r in repos {
139+ (repo_card(r))
140+ }
141+ }
142+ }
143+ }
144+ }
145+ }
146+
147+ section .cta-band.brand-glow aria-labelledby="cta-h" {
148+ h2 #cta-h .display.cta-title { "Stop fighting your version control." }
149+ p .cta-lede {
150+ "Bring your team to a forge that finally matches how you actually work."
151+ }
152+ div .hero-actions.cta-actions {
153+ a .btn.btn-primary.btn-lg href="/login" { "Start for free" }
154+ @if let Some(r) = repos.first() {
155+ a .btn.btn-lg href=(format!("/{}/{}", r.owner, r.name)) {
156+ "Browse a repo first"
157+ }
158+ }
159+ }
160+ }
161+ }
162+}
163+
164+/// One feed row.
165+///
166+/// The exact timestamp goes in `title` because the visible label is coarse on
167+/// purpose — see `relative_time`.
168+fn feed_row(item: &FeedItem, now: DateTime<Utc>) -> Markup {
169+ let href = format!("/{}/{}/changes/{}", item.owner, item.repo, item.number);
170+
171+ html! {
172+ li .feed-row {
173+ (avatar(item.author.as_deref().unwrap_or("?")))
174+ div .feed-main {
175+ a .feed-title href=(href) { (item.title) }
176+ div .feed-meta {
177+ @if let Some(a) = &item.author {
178+ span .feed-actor { (a) }
179+ } @else {
180+ span .feed-actor.faint { "someone" }
181+ }
182+ span .faint { "in" }
183+ a .mono href=(format!("/{}/{}", item.owner, item.repo)) {
184+ (item.owner) "/" (item.repo)
185+ }
186+ span .faint aria-hidden="true" { "·" }
187+ span .faint.tnum title=(item.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
188+ (relative_time(item.when, now))
189+ }
190+ }
191+ }
192+ div .feed-side {
193+ (change_chip(&item.change_id, item.synthetic))
194+ }
195+ }
196+ }
197+}
198+
199+fn repo_card(r: &RepoSummary) -> Markup {
200+ html! {
201+ a .repo-card href=(format!("/{}/{}", r.owner, r.name)) {
202+ span .repo-card-rail aria-hidden="true" {}
203+ span .repo-card-name {
204+ span .faint { (r.owner) "/" }
205+ span .repo-card-repo { (r.name) }
206+ @if r.private {
207+ span .chip { "private" }
208+ }
209+ }
210+ @if let Some(d) = &r.description {
211+ span .repo-card-desc { (d) }
212+ }
213+ }
214+ }
215+}
216+
217+/// A change as it appears in a dashboard list.
218+pub struct DashChange {
219+ pub owner: String,
220+ pub repo: String,
221+ pub number: i64,
222+ pub change_id: String,
223+ pub synthetic: bool,
224+ pub title: String,
225+ pub state: String,
226+ pub conflicted: bool,
227+ pub author: Option<String>,
228+ pub updated_at: DateTime<Utc>,
229+}
230+
231+/// One row of the watched-activity feed.
232+pub struct ActivityItem {
233+ pub owner: String,
234+ pub repo: String,
235+ pub actor: Option<String>,
236+ pub kind: String,
237+ /// The change this event was about, when it was about one.
238+ pub change_number: Option<i64>,
239+ pub change_title: Option<String>,
240+ pub when: DateTime<Utc>,
241+}
242+
243+/// Everything the dashboard renders.
244+pub struct Dashboard<'a> {
245+ pub user: &'a User,
246+ /// Open changes the viewer did not write and has not yet reviewed.
247+ pub awaiting: &'a [DashChange],
248+ pub mine: &'a [DashChange],
249+ pub activity: &'a [ActivityItem],
250+ pub repos: &'a [RepoSummary],
251+}
252+
253+/// Signed-in dashboard.
254+pub fn dashboard(d: Dashboard<'_>) -> Markup {
255+ let now = Utc::now();
256+
257+ html! {
258+ div .dash-head {
259+ div {
260+ h1 .display.dash-title {
261+ "Welcome back, " span .dash-name { (d.user.label()) }
262+ }
263+ p .dim { "Here's what needs you across your repositories." }
264+ }
265+ a .btn.btn-primary href="/new" { "New repository" }
266+ }
267+
268+ @if d.repos.is_empty() {
269+ div .empty {
270+ h2 { "No repositories yet" }
271+ p { "Create one, then push to it with " code { "jj git push" } " or " code { "git push" } "." }
272+ p { a .btn.btn-primary href="/new" { "New repository" } }
273+ }
274+ } @else {
275+ div .landing-columns {
276+ div .landing-main {
277+ section aria-labelledby="awaiting-h" {
278+ div .section-head {
279+ h2 #awaiting-h .label-condensed.slash { "awaiting your review" }
280+ span .faint.mono.tnum { (d.awaiting.len()) }
281+ }
282+ @if d.awaiting.is_empty() {
283+ p .dim.section-note { "Nothing is waiting on you." }
284+ } @else {
285+ ul .feed {
286+ @for c in d.awaiting { (dash_change_row(c, now)) }
287+ }
288+ }
289+ }
290+
291+ section aria-labelledby="mine-h" {
292+ div .section-head {
293+ h2 #mine-h .label-condensed.slash { "your open changes" }
294+ }
295+ @if d.mine.is_empty() {
296+ p .dim.section-note { "You have no open changes." }
297+ } @else {
298+ ul .feed {
299+ @for c in d.mine { (dash_change_row(c, now)) }
300+ }
301+ }
302+ }
303+
304+ section aria-labelledby="activity-h" {
305+ div .section-head {
306+ h2 #activity-h .label-condensed.slash { "watched activity" }
307+ }
308+ @if d.activity.is_empty() {
309+ p .dim.section-note { "No recent activity in your repositories." }
310+ } @else {
311+ ul .activity {
312+ @for a in d.activity { (activity_row(a, now)) }
313+ }
314+ }
315+ }
316+ }
317+
318+ aside .landing-aside aria-labelledby="dash-repos-h" {
319+ h2 #dash-repos-h .label-condensed.slash { "your repositories" }
320+ div .repo-cards {
321+ @for r in d.repos { (repo_card(r)) }
322+ a .repo-card-new href="/new" { "new repository" }
323+ }
324+ }
325+ }
326+ }
327+ }
328+}
329+
330+fn dash_change_row(c: &DashChange, now: DateTime<Utc>) -> Markup {
331+ let href = format!("/{}/{}/changes/{}", c.owner, c.repo, c.number);
332+
333+ html! {
334+ li .feed-row {
335+ div .feed-main {
336+ a .feed-title href=(href) { (c.title) }
337+ div .feed-meta {
338+ @if let Some(a) = &c.author {
339+ span .feed-actor { (a) }
340+ }
341+ span .faint { "in" }
342+ a .mono href=(format!("/{}/{}", c.owner, c.repo)) {
343+ (c.owner) "/" (c.repo)
344+ }
345+ span .faint aria-hidden="true" { "·" }
346+ span .faint.tnum title=(c.updated_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
347+ (relative_time(c.updated_at, now))
348+ }
349+ }
350+ }
351+ div .feed-side {
352+ (crate::views::change::state_badge(&c.state, c.conflicted))
353+ (change_chip(&c.change_id, c.synthetic))
354+ }
355+ }
356+ }
357+}
358+
359+fn activity_row(a: &ActivityItem, now: DateTime<Utc>) -> Markup {
360+ let repo_href = format!("/{}/{}", a.owner, a.repo);
361+
362+ html! {
363+ li .activity-row {
364+ span .activity-actor { (a.actor.as_deref().unwrap_or("someone")) }
365+ span .dim { " " (activity_verb(&a.kind)) " " }
366+ @if let (Some(n), Some(t)) = (a.change_number, &a.change_title) {
367+ a href=(format!("{repo_href}/changes/{n}")) { (t) }
368+ span .dim { " in " }
369+ }
370+ a .mono.faint href=(repo_href) { (a.owner) "/" (a.repo) }
371+ span .faint.tnum.activity-when
372+ title=(a.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
373+ (relative_time(a.when, now))
374+ }
375+ }
376+ }
377+}
378+
379+/// The verb phrase for an event kind.
380+///
381+/// Unknown kinds fall through to the raw kind rather than being dropped or
382+/// silently relabelled — the same rule the change timeline follows, and for the
383+/// same reason: an event the UI does not recognise still happened.
384+fn activity_verb(kind: &str) -> &str {
385+ match kind {
386+ "change.opened" => "opened",
387+ "change.pushed" => "pushed to",
388+ "change.rebased" => "rewrote",
389+ "change.conflicted" => "hit a conflict on",
390+ "change.resolved" => "resolved conflicts on",
391+ "change.merged" => "merged",
392+ "change.abandoned" => "abandoned",
393+ "change.reopened" => "reopened",
394+ "change.drafted" => "drafted",
395+ "change.ready" => "marked ready",
396+ "change.reviewed" => "reviewed",
397+ "comments.rebased" => "re-anchored comments on",
398+ other => other,
399+ }
400+}
401+
402+pub struct RepoSummary {
403+ pub owner: String,
404+ pub name: String,
405+ pub description: Option<String>,
406+ pub private: bool,
407+}
408+
409+/// The sign-in page.
410+///
411+/// SSO is the only method that exists. Passkeys and self-service sign-up are
412+/// rendered as explicitly disabled rather than omitted: the design shows them,
413+/// and a disabled control that says why is more honest than a live-looking
414+/// button that does nothing. They carry `aria-disabled` and no `href`, so they
415+/// are not in the tab order as if they were usable.
416+pub fn signin(sso_href: &str) -> Markup {
417+ html! {
418+ div .signin {
419+ div .signin-intro {
420+ span .label-condensed.signin-eyebrow { "welcome back" }
421+ h1 .display.signin-title { "Sign in to your changes." }
422+ p .dim {
423+ "Single sign-on through your organization. No passwords to leak, \
424+ rotate, or forget."
425+ }
426+ }
427+
428+ a .btn.btn-primary.btn-block href=(sso_href) { "Continue with SSO" }
429+
430+ div .signin-or {
431+ span .signin-rule aria-hidden="true" {}
432+ span .faint.mono { "or" }
433+ span .signin-rule aria-hidden="true" {}
434+ }
435+
436+ span .btn.btn-block.is-disabled aria-disabled="true"
437+ title="Passkey sign-in is not available on this instance yet" {
438+ "Continue with a passkey"
439+ }
440+
441+ p .hint.signin-foot {
442+ "New here? This instance is invitation-only — ask an administrator \
443+ for an invitation."
444+ }
445+ }
446+ }
447+}
448+
449+/// Shown when a valid login belongs to somebody who is not admitted.
450+///
451+/// Deliberately says nothing about why, beyond that access is by invitation —
452+/// enumerating the allowlist would be a disclosure.
453+pub fn not_invited() -> Markup {
454+ html! {
455+ div .panel {
456+ h1 { "This instance is invitation-only" }
457+ p .lede {
458+ "You signed in successfully, but this account has not been invited to \
459+ Dogfood. Ask an administrator for an invitation."
460+ }
461+ form method="post" action="/logout" {
462+ button .btn type="submit" { "Sign out" }
463+ }
464+ }
465+ }
466+}
467+
468+/// Handle chooser, shown when one cannot be derived from the OIDC claims.
469+pub fn choose_handle(suggested: Option<&str>, csrf: &str, error: Option<&str>) -> Markup {
470+ html! {
471+ div .panel {
472+ h1 { "Choose a handle" }
473+ p .lede {
474+ "Your handle appears in URLs, like "
475+ code { "dogfood.sh/your-handle/your-repo" } "."
476+ }
477+ @if let Some(e) = error {
478+ div .banner.banner-error role="alert" { (e) }
479+ }
480+ form method="post" action="/auth/handle" {
481+ input type="hidden" name="_csrf" value=(csrf);
482+ div .field {
483+ label for="handle" { "Handle" }
484+ input type="text" id="handle" name="handle"
485+ value=(suggested.unwrap_or(""))
486+ required
487+ minlength="1" maxlength="39"
488+ pattern="[a-z0-9][a-z0-9-]*"
489+ autocomplete="off" autofocus;
490+ p .hint { "Lowercase letters, digits and hyphens. Up to 39 characters." }
491+ }
492+ button .btn.btn-primary type="submit" { "Continue" }
493+ }
494+ }
495+ }
496+}
497+
498+/// One-time site-admin claim.
499+pub fn setup(csrf: &str, error: Option<&str>) -> Markup {
500+ html! {
501+ div .panel {
502+ h1 { "Claim site administrator" }
503+ p .lede {
504+ "Paste the setup token this instance printed to its logs on first boot. \
505+ It can be used once."
506+ }
507+ @if let Some(e) = error {
508+ div .banner.banner-error role="alert" { (e) }
509+ }
510+ form method="post" action="/setup" {
511+ input type="hidden" name="_csrf" value=(csrf);
512+ div .field {
513+ label for="token" { "Setup token" }
514+ input type="password" id="token" name="token" required autocomplete="off" autofocus;
515+ }
516+ button .btn.btn-primary type="submit" { "Claim" }
517+ }
518+ }
519+ }
520+}
521+
522+pub fn setup_done() -> Markup {
523+ html! {
524+ div .panel {
525+ div .banner.banner-ok role="status" { "You are now a site administrator." }
526+ p { a href="/" { "Go to the dashboard" } }
527+ }
528+ }
529+}
Acrates/df-web/src/views/repo.rs666 lines+666−0
@@ −0,0 +1,666 @@
1+//! Repository browsing views (M2).
2+
3+use maud::{html, Markup, PreEscaped};
4+
5+use df_store::{Bookmark, EntryKind, Revision, TreeEntry};
6+
7+use crate::repo_ctx::RepoContext;
8+
9+/// Repository header: name, visibility, and the browse tabs.
10+pub fn header(ctx: &RepoContext, active: &str) -> Markup {
11+ let base = ctx.base();
12+ let tabs = [
13+ ("code", "Code", base.clone()),
14+ ("changes", "Changes", format!("{base}/changes")),
15+ ("issues", "Issues", format!("{base}/issues")),
16+ ("bookmarks", "Bookmarks", format!("{base}/bookmarks")),
17+ ];
18+
19+ html! {
20+ div .repo-head {
21+ div .row {
22+ h1 .repo-title {
23+ a .repo-owner href=(format!("/{}", ctx.owner)) { (ctx.owner) }
24+ span .repo-slash aria-hidden="true" { "/" }
25+ a .repo-name href=(base) { (ctx.repo.name) }
26+ }
27+ @if !ctx.repo.is_public() {
28+ span .chip { "private" }
29+ }
30+ }
31+ @if let Some(d) = &ctx.repo.description {
32+ p .dim .repo-desc { (d) }
33+ }
34+ nav .subtabs .repo-tabs aria-label="Repository" {
35+ @for (key, label, href) in &tabs {
36+ a href=(href) .active[*key == active]
37+ aria-current=[(*key == active).then_some("page")] {
38+ (label)
39+ }
40+ }
41+ @if ctx.access.can_change_settings() {
42+ a .subtabs-end href=(format!("{base}/settings")) { "Settings" }
43+ }
44+ }
45+ }
46+ }
47+}
48+
49+/// Clone instructions, shown on an empty repository.
50+pub fn empty_repo(https: &str, ssh: &str) -> Markup {
51+ html! {
52+ div .panel {
53+ h2 { "Push your first change" }
54+ p .dim {
55+ "This repository is empty. Push to it with " code { "jj" } " or " code { "git" } "."
56+ }
57+
58+ p .label-condensed style="margin-top:18px" { "Clone over HTTPS" }
59+ div .clone-box { code { "jj git clone " (https) } }
60+
61+ p .label-condensed style="margin-top:14px" { "Clone over SSH" }
62+ div .clone-box { code { "jj git clone " (ssh) } }
63+
64+ p .label-condensed style="margin-top:14px" { "Push an existing repository" }
65+ div .clone-box {
66+ code { "jj git remote add origin " (https) " && jj git push -b main" }
67+ }
68+ p .hint {
69+ "Over HTTPS, authenticate with your handle and a personal access token from "
70+ a href="/settings" { "settings" } "."
71+ }
72+ }
73+ }
74+}
75+
76+/// File listing for a tree.
77+/// The most recent revision on the branch being browsed.
78+///
79+/// This is the *directory's* tip, not a per-file blame — see the note on
80+/// `tree_listing`.
81+pub struct TipCommit {
82+ pub author: String,
83+ pub summary: String,
84+ pub when: chrono::DateTime<chrono::Utc>,
85+ pub change_id: Option<String>,
86+}
87+
88+/// A monogram stand-in for a user picture.
89+///
90+/// Dogfood stores no avatars, and fetching one from a third-party service
91+/// would leak the viewer's reading habits to that service on every page. Two
92+/// letters in a box identify the author well enough for a listing.
93+pub fn avatar(name: &str) -> Markup {
94+ let initials: String = name.chars().take(2).collect();
95+ html! {
96+ span .avatar title=(name) aria-hidden="true" { (initials) }
97+ }
98+}
99+
100+fn dir_icon() -> Markup {
101+ html! {
102+ svg .icon-dir width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" {
103+ path d="M1.5 4h4l1.5 2h7.5v6.5h-13V4Z" fill="currentColor" opacity="0.18" {}
104+ path d="M1.5 4h4l1.5 2h7.5v6.5h-13V4Z" stroke="currentColor" stroke-width="1.2" {}
105+ }
106+ }
107+}
108+
109+fn file_icon() -> Markup {
110+ html! {
111+ svg .icon-file width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" {
112+ path d="M4 1.5h5l3 3v10h-8v-13Z" stroke="currentColor" stroke-width="1.2" {}
113+ path d="M9 1.5v3h3" stroke="currentColor" stroke-width="1.2" {}
114+ }
115+ }
116+}
117+
118+/// The directory listing.
119+///
120+/// The design puts a per-file "last change that touched this path" column here.
121+/// Dogfood cannot fill it: the index records changes and revisions but never
122+/// the paths a change touched, so there is nothing to join against. The column
123+/// is therefore left out rather than filled with a plausible-looking value —
124+/// showing the directory tip's message on every row would read as per-file
125+/// history and be wrong on all but one of them.
126+pub fn tree_listing(
127+ ctx: &RepoContext,
128+ rev_label: &str,
129+ path: &str,
130+ entries: &[TreeEntry],
131+ tip: Option<&TipCommit>,
132+ readme: Option<&(String, Markup)>,
133+) -> Markup {
134+ let base = ctx.base();
135+
136+ html! {
137+ div .row.tree-crumbs {
138+ span .label-condensed { "Browsing" }
139+ span .chip { (rev_label) }
140+ (breadcrumbs(&base, rev_label, path))
141+ }
142+
143+ @if let Some(t) = tip {
144+ div .commit-bar {
145+ (avatar(&t.author))
146+ span .commit-bar-author { (t.author) }
147+ span .commit-bar-msg { (t.summary) }
148+ @if let Some(c) = &t.change_id {
149+ span .chip.chip-change.commit-bar-chip title=(format!("jj change id: {c}")) {
150+ (&c[..12.min(c.len())])
151+ }
152+ }
153+ span .faint.tnum.commit-bar-when
154+ title=(t.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
155+ (crate::views::relative_time(t.when, chrono::Utc::now()))
156+ }
157+ }
158+ }
159+
160+ @if entries.is_empty() {
161+ div .filelist.filelist-standalone {
162+ p .dim style="padding:16px" { "This directory is empty." }
163+ }
164+ } @else {
165+ div .filelist .filelist-standalone[tip.is_none()] {
166+ table {
167+ caption .sr-only { "Files in this directory" }
168+ tbody {
169+ @if !path.is_empty() {
170+ tr {
171+ td .filelist-icon {
172+ a href=(parent_link(&base, rev_label, path)) aria-label="Parent directory" {
173+ (dir_icon())
174+ }
175+ }
176+ td .filelist-name colspan="2" {
177+ a .mono href=(parent_link(&base, rev_label, path)) { ".." }
178+ }
179+ }
180+ }
181+ @for e in entries {
182+ tr {
183+ td .filelist-icon {
184+ a href=(entry_link(&base, rev_label, e)) tabindex="-1" aria-hidden="true" {
185+ @if e.kind == EntryKind::Directory { (dir_icon()) } @else { (file_icon()) }
186+ }
187+ }
188+ td .filelist-name {
189+ a .mono href=(entry_link(&base, rev_label, e))
190+ .is-dir[e.kind == EntryKind::Directory] {
191+ (e.name)
192+ }
193+ @if e.kind == EntryKind::Symlink {
194+ span .faint .filelist-note { "symlink" }
195+ }
196+ }
197+ td .filelist-size.faint.tnum {
198+ @if let Some(s) = e.size { (human_size(s)) }
199+ }
200+ }
201+ }
202+ }
203+ }
204+ }
205+ }
206+
207+ @if let Some((name, body)) = readme {
208+ article .readme {
209+ div .readme-head {
210+ (file_icon())
211+ span .mono.dim { (name) }
212+ }
213+ div .readme-body.markdown-body { (body) }
214+ }
215+ }
216+ }
217+}
218+
219+/// How a markdown file is being shown.
220+///
221+/// `None` means the file is not markdown, so no toggle appears at all.
222+pub enum MarkdownView<'a> {
223+ /// Showing the rendered document.
224+ Rendered(&'a Markup),
225+ /// Showing the highlighted source.
226+ Source,
227+}
228+
229+/// Extra data passed to the blob view for the enhanced code view layout.
230+pub struct BlobExtras<'a> {
231+ /// File tree entries for the sidebar (the directory containing this file).
232+ pub sidebar_entries: &'a [TreeEntry],
233+ /// The directory shown in the sidebar.
234+ pub sidebar_dir: &'a str,
235+ /// Symbols extracted from the file for the outline panel.
236+ pub symbols: &'a [df_render::symbols::Symbol],
237+ /// The last commit that modified this file.
238+ pub last_commit: Option<&'a df_store::Revision>,
239+ /// Per-line blame data (when the user toggled blame on).
240+ pub blame: Option<&'a [df_store::BlameLine]>,
241+ /// Whether blame was requested.
242+ pub wants_blame: bool,
243+}
244+
245+impl<'a> Default for BlobExtras<'a> {
246+ fn default() -> Self {
247+ BlobExtras {
248+ sidebar_entries: &[],
249+ sidebar_dir: "",
250+ symbols: &[],
251+ last_commit: None,
252+ blame: None,
253+ wants_blame: false,
254+ }
255+ }
256+}
257+
258+/// A single file.
259+///
260+/// The rendered/source toggle is two real URLs rather than a scripted control,
261+/// so it works with JavaScript disabled like the rest of the product (spec §7)
262+/// and each view can be linked to directly.
263+///
264+/// The toggle says "source" rather than the design's "raw" because this page
265+/// already has a Raw button that downloads the file. Two different controls
266+/// both labelled "raw" on one page would be a worse outcome than the wording
267+/// drifting from the mock.
268+pub fn blob_view(
269+ ctx: &RepoContext,
270+ rev_label: &str,
271+ path: &str,
272+ body: BlobBody<'_>,
273+ markdown: Option<MarkdownView<'_>>,
274+ extras: &BlobExtras<'_>,
275+) -> Markup {
276+ let base = ctx.base();
277+ let raw = format!("{base}/raw/{rev_label}/{path}");
278+ let here = format!("{base}/blob/{rev_label}/{path}");
279+ // Binary and oversized files have no editable representation.
280+ let editable = matches!(body, BlobBody::Text { .. });
281+ let showing_source = matches!(markdown, Some(MarkdownView::Source));
282+
283+ html! {
284+ // Last commit bar for this file.
285+ @if let Some(lc) = extras.last_commit {
286+ div .file-commit-bar {
287+ (avatar(&lc.author.name))
288+ span .commit-bar-author { (lc.author.name) }
289+ span .commit-bar-msg { (lc.summary()) }
290+ @if let Some(c) = &lc.change_id {
291+ span .chip.chip-change title="change id" {
292+ (&c[..12.min(c.len())])
293+ }
294+ }
295+ span .faint.tnum
296+ title=(lc.author.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
297+ (crate::views::relative_time(lc.author.when, chrono::Utc::now()))
298+ }
299+ }
300+ }
301+
302+ div .code-view-layout {
303+ // ─── Left sidebar: file tree ─────────────────────────────────
304+ aside .code-sidebar-left aria-label="File tree" {
305+ div .sidebar-header {
306+ (dir_icon())
307+ span .mono.dim {
308+ @if extras.sidebar_dir.is_empty() {
309+ "/"
310+ } @else {
311+ (extras.sidebar_dir)
312+ }
313+ }
314+ }
315+ nav .file-tree {
316+ @if !extras.sidebar_dir.is_empty() {
317+ a .file-tree-item.file-tree-parent href=(
318+ parent_link(&base, rev_label, extras.sidebar_dir)
319+ ) {
320+ (dir_icon()) ".."
321+ }
322+ }
323+ @for e in extras.sidebar_entries {
324+ @let is_current = e.path == path;
325+ a .file-tree-item
326+ .is-dir[e.is_dir()]
327+ .is-current[is_current]
328+ href=(entry_link(&base, rev_label, e))
329+ aria-current=[is_current.then_some("page")] {
330+ @if e.is_dir() { (dir_icon()) } @else { (file_icon()) }
331+ (e.name)
332+ }
333+ }
334+ }
335+ }
336+
337+ // ─── Center: code panel ──────────────────────────────────────
338+ div .code-panel {
339+ div .panel {
340+ div .row style="margin-bottom:12px" {
341+ span .chip { (rev_label) }
342+ (breadcrumbs(&base, rev_label, path))
343+ span style="margin-left:auto" {}
344+
345+ // Blame toggle
346+ @if editable {
347+ @if extras.wants_blame {
348+ a .btn.btn-sm href=(here.clone()) { "Hide Blame" }
349+ } @else {
350+ a .btn.btn-sm href=(format!("{here}?blame=1")) { "Blame" }
351+ }
352+ }
353+
354+ @if markdown.is_some() {
355+ div .segmented role="group" aria-label="Markdown view" {
356+ a .segmented-item .is-on[!showing_source] href=(here.clone()) {
357+ "rendered"
358+ }
359+ a .segmented-item .is-on[showing_source]
360+ href=(format!("{here}?view=source")) {
361+ "source"
362+ }
363+ }
364+ }
365+
366+ // Editing needs push access and a text file. Offered only where
367+ // it would actually work, rather than shown and then refused.
368+ @if ctx.access.can_push() && !ctx.repo.archived && editable {
369+ a .btn href=(format!("{base}/edit/{rev_label}/{path}")) { "Edit" }
370+ }
371+ a .btn href=(raw) { "Raw" }
372+ }
373+
374+ @if let Some(MarkdownView::Rendered(doc)) = &markdown {
375+ div .readme-body.markdown-body { (doc) }
376+ } @else {
377+ @match body {
378+ BlobBody::Text { content, lines, highlighted, language, plain_reason } => {
379+ div .codeblock {
380+ table .mono .codetable {
381+ tbody {
382+ @for (n, line) in content.lines().enumerate() {
383+ tr id=(format!("L{}", n + 1)) {
384+ // Blame gutter (when active)
385+ @if let Some(blame) = extras.blame {
386+ @if let Some(bl) = blame.get(n) {
387+ td .blame-cell title=(format!("{} — {}", bl.author, bl.summary)) {
388+ span .blame-author {
389+ (bl.author.chars().take(12).collect::<String>())
390+ }
391+ }
392+ } @else {
393+ td .blame-cell {}
394+ }
395+ }
396+ td .faint .lineno {
397+ a href=(format!("#L{}", n + 1)) { (n + 1) }
398+ }
399+ td .codeline {
400+ @match highlighted.get(n) {
401+ Some(h) => (maud::PreEscaped(h.as_str())),
402+ None => (line),
403+ }
404+ }
405+ }
406+ }
407+ }
408+ }
409+ }
410+ p .hint {
411+ (lines) " lines"
412+ @if let Some(l) = language { " · " (l) }
413+ @if let Some(why) = plain_reason { " · " (why) }
414+ }
415+ }
416+ BlobBody::Binary { size } => {
417+ div .empty {
418+ h2 { "Binary file" }
419+ p { (human_size(size)) " — not shown." }
420+ p { a .btn href=(raw) { "Download" } }
421+ }
422+ }
423+ BlobBody::TooLarge { size, limit } => {
424+ div .empty {
425+ h2 { "File is too large to display" }
426+ p { (human_size(size)) ", over the " (human_size(limit)) " render limit." }
427+ p { a .btn href=(raw) { "Download" } }
428+ }
429+ }
430+ }
431+ }
432+ }
433+ }
434+
435+ // ─── Right sidebar: symbols outline ──────────────────────────
436+ @if !extras.symbols.is_empty() {
437+ aside .code-sidebar-right aria-label="Symbol outline" {
438+ div .sidebar-header {
439+ span .label-condensed { "Symbols" }
440+ }
441+ nav .symbol-list {
442+ @for sym in extras.symbols {
443+ a .symbol-item href=(format!("#L{}", sym.line)) {
444+ span .symbol-kind class=(format!("sk-{}", sym.kind.css_class())) {
445+ (sym.kind.label())
446+ }
447+ span .symbol-name { (sym.name) }
448+ }
449+ }
450+ }
451+ }
452+ }
453+ }
454+ }
455+}
456+
457+pub enum BlobBody<'a> {
458+ Text {
459+ content: &'a str,
460+ lines: usize,
461+ /// Per-line highlighted HTML. Empty when the file rendered plain, and
462+ /// indexed by line so a short list degrades line-by-line rather than
463+ /// misaligning the whole file.
464+ highlighted: &'a [String],
465+ language: Option<&'a str>,
466+ /// Why highlighting was skipped, when it was.
467+ plain_reason: Option<&'a str>,
468+ },
469+ Binary { size: u64 },
470+ TooLarge { size: u64, limit: u64 },
471+}
472+
473+/// Commit log.
474+pub fn log_view(ctx: &RepoContext, rev_label: &str, revisions: &[Revision]) -> Markup {
475+ let base = ctx.base();
476+ html! {
477+ div .panel {
478+ div .row style="margin-bottom:12px" {
479+ span .label-condensed { "History" }
480+ span .chip { (rev_label) }
481+ }
482+ @if revisions.is_empty() {
483+ p .dim { "No history." }
484+ } @else {
485+ div .stack style="gap:0" {
486+ @for r in revisions {
487+ div style="padding:10px 0;border-bottom:1px solid var(--border)" {
488+ div .row {
489+ a href=(format!("{base}/tree/{}/", r.rev)) { (r.summary()) }
490+ @if r.conflicted {
491+ span .badge.badge-conflict { "conflict" }
492+ }
493+ }
494+ div .row style="margin-top:4px;gap:8px" {
495+ // The change id is the identity; the revision is
496+ // a point in its history (spec §4).
497+ @if let Some(c) = &r.change_id {
498+ span .chip.chip-change title="jj change id" {
499+ (&c[..12.min(c.len())])
500+ }
501+ } @else {
502+ span .chip title="authored with plain git" { "git" }
503+ }
504+ span .faint { (r.author.name) }
505+ span .faint { (r.author.when.format("%Y-%m-%d %H:%M").to_string()) }
506+ }
507+ }
508+ }
509+ }
510+ }
511+ }
512+ }
513+}
514+
515+/// Bookmark list.
516+pub fn bookmarks_view(ctx: &RepoContext, marks: &[Bookmark]) -> Markup {
517+ let base = ctx.base();
518+ html! {
519+ div .panel {
520+ h2 { "Bookmarks" }
521+ p .dim {
522+ "Bookmarks are movable pointers, not identities. Reviews attach to changes."
523+ }
524+ @if marks.is_empty() {
525+ p .dim { "No bookmarks yet." }
526+ } @else {
527+ table style="width:100%;border-collapse:collapse" {
528+ tbody {
529+ @for m in marks {
530+ tr {
531+ td style="padding:8px 4px;border-bottom:1px solid var(--border)" {
532+ a href=(format!("{base}/tree/{}/", m.name)) { (m.name) }
533+ @if m.name == ctx.repo.default_bookmark {
534+ span .chip style="margin-left:8px" { "default" }
535+ }
536+ }
537+ }
538+ }
539+ }
540+ }
541+ }
542+ }
543+ }
544+}
545+
546+/// New-repository form.
547+pub fn new_repo_form(csrf: &str, error: Option<&str>, owners: &[String]) -> Markup {
548+ html! {
549+ div .panel {
550+ h1 { "New repository" }
551+ @if let Some(e) = error {
552+ div .banner.banner-error role="alert" { (e) }
553+ }
554+ form method="post" action="/repos" {
555+ input type="hidden" name="_csrf" value=(csrf);
556+
557+ div .field {
558+ label for="owner" { "Owner" }
559+ select id="owner" name="owner"
560+ style="padding:7px 10px;background:var(--bg);border:1px solid var(--border-strong);border-radius:var(--radius);color:var(--text);font:inherit" {
561+ @for o in owners {
562+ option value=(o) { (o) }
563+ }
564+ }
565+ }
566+
567+ div .field {
568+ label for="name" { "Repository name" }
569+ input type="text" id="name" name="name" required
570+ maxlength="100" pattern="[A-Za-z0-9][A-Za-z0-9._\\-]*"
571+ autocomplete="off" autofocus;
572+ p .hint { "Letters, digits, dots, hyphens and underscores." }
573+ }
574+
575+ div .field {
576+ label for="description" { "Description (optional)" }
577+ input type="text" id="description" name="description" maxlength="500";
578+ }
579+
580+ div .field {
581+ label for="default_bookmark" { "Default bookmark" }
582+ input type="text" id="default_bookmark" name="default_bookmark"
583+ value="main" maxlength="100" required;
584+ }
585+
586+ div .field {
587+ label {
588+ input type="checkbox" name="private" value="1" checked
589+ style="width:auto;margin-right:8px";
590+ "Private"
591+ }
592+ }
593+
594+ button .btn.btn-primary type="submit" { "Create repository" }
595+ }
596+ }
597+ }
598+}
599+
600+// ─── helpers ─────────────────────────────────────────────────────────────────
601+
602+fn entry_link(base: &str, rev: &str, e: &TreeEntry) -> String {
603+ let kind = if e.is_dir() { "tree" } else { "blob" };
604+ format!("{base}/{kind}/{rev}/{}", e.path)
605+}
606+
607+fn parent_link(base: &str, rev: &str, path: &str) -> String {
608+ let parent = match path.rsplit_once('/') {
609+ Some((p, _)) => p,
610+ None => "",
611+ };
612+ format!("{base}/tree/{rev}/{parent}")
613+}
614+
615+pub fn breadcrumbs(base: &str, rev: &str, path: &str) -> Markup {
616+ html! {
617+ span {
618+ a href=(format!("{base}/tree/{rev}/")) { "root" }
619+ @for (label, acc) in df_store::path::breadcrumbs(path) {
620+ span .faint { " / " }
621+ a href=(format!("{base}/tree/{rev}/{acc}")) { (label) }
622+ }
623+ }
624+ }
625+}
626+
627+/// Human-readable byte count.
628+pub fn human_size(n: u64) -> String {
629+ const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
630+ let mut v = n as f64;
631+ let mut i = 0;
632+ while v >= 1024.0 && i < UNITS.len() - 1 {
633+ v /= 1024.0;
634+ i += 1;
635+ }
636+ if i == 0 {
637+ format!("{n} B")
638+ } else {
639+ format!("{v:.1} {}", UNITS[i])
640+ }
641+}
642+
643+/// Markdown that has already been sanitised by `df-render`.
644+pub fn rendered_markdown(html_str: &str) -> Markup {
645+ PreEscaped(html_str.to_string())
646+}
647+
648+#[cfg(test)]
649+mod tests {
650+ use super::*;
651+
652+ #[test]
653+ fn human_size_is_readable() {
654+ assert_eq!(human_size(0), "0 B");
655+ assert_eq!(human_size(512), "512 B");
656+ assert_eq!(human_size(1024), "1.0 KB");
657+ assert_eq!(human_size(1536), "1.5 KB");
658+ assert_eq!(human_size(1024 * 1024), "1.0 MB");
659+ }
660+
661+ #[test]
662+ fn parent_link_walks_up_one_level() {
663+ assert_eq!(parent_link("/o/r", "main", "a/b/c"), "/o/r/tree/main/a/b");
664+ assert_eq!(parent_link("/o/r", "main", "a"), "/o/r/tree/main/");
665+ }
666+}
Acrates/df-web/src/views/review.rs888 lines+888−0
@@ −0,0 +1,888 @@
1+//! Change detail: overview, files, revisions, conflicts, and the stack graph
2+//! (M3, M4).
3+//!
4+//! Everything here renders standalone. The comment forms are ordinary POSTs to
5+//! ordinary URLs, so a reviewer with JavaScript disabled can read a diff and
6+//! leave an inline comment — which spec §7 makes a hard requirement, and which
7+//! is the single easiest thing to lose when a diff view grows interactive.
8+//!
9+//! htmx enhances two things and nothing else: posting a comment swaps just its
10+//! thread, and expanding collapsed context fetches only the extra lines.
11+
12+use chrono::{DateTime, Utc};
13+use maud::{html, Markup, PreEscaped};
14+use uuid::Uuid;
15+
16+use df_store::{ConflictedFile, Diff, DiffLineKind, DiffSpan};
17+
18+use crate::repo_ctx::RepoContext;
19+use crate::views::change::{change_chip, state_badge};
20+
21+// ─── shared shape ────────────────────────────────────────────────────────────
22+
23+/// Everything the change pages need about the change itself.
24+pub struct ChangeHead<'a> {
25+ pub number: i64,
26+ pub change_id: &'a str,
27+ pub synthetic: bool,
28+ pub title: &'a str,
29+ pub state: &'a str,
30+ pub conflicted: bool,
31+ pub target_bookmark: &'a str,
32+ pub author: Option<&'a str>,
33+ pub revision_count: usize,
34+ /// Whether the viewer may edit the change (author or maintainer).
35+ pub can_manage: bool,
36+ pub can_comment: bool,
37+ pub csrf: &'a str,
38+}
39+
40+/// Header and tab bar, shared by every change page.
41+pub fn header(ctx: &RepoContext, c: &ChangeHead<'_>, tab: &str) -> Markup {
42+ let base = format!("{}/changes/{}", ctx.base(), c.number);
43+
44+ html! {
45+ div .panel {
46+ div .row {
47+ (state_badge(c.state, c.conflicted))
48+ h1 style="margin:0" { (c.title) }
49+ }
50+ div .row style="margin-top:8px;gap:10px" {
51+ (change_chip(c.change_id, c.synthetic))
52+ span .faint { "#" (c.number) }
53+ @if let Some(a) = c.author { span .faint { "by " (a) } }
54+ span .faint { "into" }
55+ span .chip { (c.target_bookmark) }
56+ @if c.revision_count > 1 {
57+ span .faint title="revisions of this change" {
58+ (c.revision_count) " revisions"
59+ }
60+ }
61+ }
62+
63+ nav .subtabs style="margin-top:14px;margin-bottom:0" aria-label="Change sections" {
64+ a href=(base.clone()) .active[tab == "overview"]
65+ aria-current=[(tab == "overview").then_some("page")] { "Overview" }
66+ a href=(format!("{base}/files")) .active[tab == "files"]
67+ aria-current=[(tab == "files").then_some("page")] { "Files" }
68+ a href=(format!("{base}/revisions")) .active[tab == "revisions"]
69+ aria-current=[(tab == "revisions").then_some("page")] { "Revisions" }
70+ @if c.conflicted {
71+ a href=(format!("{base}/conflicts")) .active[tab == "conflicts"]
72+ aria-current=[(tab == "conflicts").then_some("page")] { "Conflicts" }
73+ }
74+ a href=(format!("{}/stacks/{}", ctx.base(), c.change_id)) { "Stack" }
75+ }
76+ }
77+ }
78+}
79+
80+// ─── overview ────────────────────────────────────────────────────────────────
81+
82+pub struct CommentRow {
83+ pub id: Uuid,
84+ pub author: String,
85+ pub body_html: String,
86+ pub created_at: DateTime<Utc>,
87+ pub edited: bool,
88+ pub anchor_path: Option<String>,
89+ pub anchor_line: Option<i32>,
90+ /// `old` or `new`. Stored and loaded because a comment on a deleted line is
91+ /// a different thing from one on an added line, and losing that on the way
92+ /// in would be unrecoverable — the diff view currently anchors on the new
93+ /// side only, so nothing reads it yet.
94+ #[allow(dead_code)]
95+ pub anchor_side: Option<String>,
96+ pub anchor_state: String,
97+ pub anchor_context: Option<String>,
98+ pub resolved: bool,
99+}
100+
101+pub struct ReviewRow {
102+ pub reviewer: String,
103+ pub verdict: String,
104+ pub body_html: String,
105+ pub created_at: DateTime<Utc>,
106+ /// The revision the review was left against, abbreviated.
107+ pub rev: String,
108+ /// Whether that revision is still the head — a stale approval is one of the
109+ /// things stable change identity makes visible, so it is worth saying.
110+ pub is_head: bool,
111+}
112+
113+pub struct EventRow {
114+ pub kind: String,
115+ pub actor: Option<String>,
116+ pub created_at: DateTime<Utc>,
117+ pub payload: serde_json::Value,
118+}
119+
120+pub struct Overview<'a> {
121+ pub description_html: &'a str,
122+ pub description_raw: &'a str,
123+ pub comments: &'a [CommentRow],
124+ /// Inline comments whose anchor no longer exists. Spec §5 step 5: surface
125+ /// them in the overview timeline rather than losing them in the diff.
126+ pub orphaned: &'a [CommentRow],
127+ pub reviews: &'a [ReviewRow],
128+ pub events: &'a [EventRow],
129+ /// Whether the viewer already reviewed the head revision.
130+ pub viewer_reviewed: bool,
131+}
132+
133+pub fn overview(ctx: &RepoContext, c: &ChangeHead<'_>, o: Overview<'_>) -> Markup {
134+ let base = format!("{}/changes/{}", ctx.base(), c.number);
135+
136+ html! {
137+ @if c.synthetic {
138+ div .panel {
139+ div .banner {
140+ "This change was authored with plain " code { "git" }
141+ ". Its identity is derived from the patch, so it survives a rebase — \
142+ but it is not a jj change id, and revision history is best-effort."
143+ }
144+ }
145+ }
146+
147+ div .panel {
148+ h2 { "Description" }
149+ @if o.description_raw.is_empty() {
150+ p .hint { "No description." }
151+ } @else {
152+ div .markdown-body { (PreEscaped(o.description_html)) }
153+ }
154+
155+ @if c.can_manage {
156+ details style="margin-top:14px" {
157+ summary { "Edit" }
158+ form method="post" action=(format!("{base}/edit")) .stack style="margin-top:12px" {
159+ input type="hidden" name="_csrf" value=(c.csrf);
160+ div .field {
161+ label for="title" { "Title" }
162+ input type="text" id="title" name="title" value=(c.title) required maxlength="300";
163+ }
164+ div .field {
165+ label for="description" { "Description" }
166+ textarea id="description" name="description" rows="6" { (o.description_raw) }
167+ }
168+ button .btn.btn-primary type="submit" { "Save" }
169+ }
170+ }
171+ }
172+ }
173+
174+ @if !o.reviews.is_empty() {
175+ div .panel {
176+ h2 { "Reviews" }
177+ div .stack {
178+ @for r in o.reviews {
179+ div .row {
180+ (verdict_badge(&r.verdict))
181+ strong { (r.reviewer) }
182+ span .faint { (r.created_at.format("%Y-%m-%d").to_string()) }
183+ span .faint .mono { (r.rev) }
184+ @if !r.is_head {
185+ span .chip title="left against an earlier revision of this change" {
186+ "stale"
187+ }
188+ }
189+ }
190+ @if !r.body_html.is_empty() {
191+ div .markdown-body style="margin:4px 0 12px 0" { (PreEscaped(&r.body_html)) }
192+ }
193+ }
194+ }
195+ }
196+ }
197+
198+ @if !o.orphaned.is_empty() {
199+ div .panel {
200+ h2 { "Comments without a home" }
201+ p .dim {
202+ "These inline comments were anchored to code that no longer exists in \
203+ the change. They are kept here rather than discarded."
204+ }
205+ div .stack {
206+ @for cm in o.orphaned { (comment(cm, c, &base, true)) }
207+ }
208+ }
209+ }
210+
211+ div .panel {
212+ h2 { "Discussion" }
213+ @if o.comments.is_empty() && o.events.is_empty() {
214+ p .hint { "Nothing yet." }
215+ }
216+
217+ div .stack { (timeline(o.events, o.comments, c, &base)) }
218+
219+ @if c.can_comment {
220+ form method="post" action=(format!("{base}/comments")) .stack style="margin-top:20px" {
221+ input type="hidden" name="_csrf" value=(c.csrf);
222+ div .field {
223+ label for="body" { "Comment" }
224+ textarea id="body" name="body" rows="4" required
225+ placeholder="Markdown is supported." {}
226+ }
227+ button .btn.btn-primary type="submit" { "Comment" }
228+ }
229+ } @else {
230+ p .hint { "Sign in to join the discussion." }
231+ }
232+ }
233+
234+ @if c.can_comment {
235+ div .panel {
236+ h2 { "Review" }
237+ @if o.viewer_reviewed {
238+ p .hint {
239+ "You have already reviewed the current revision. Submitting again \
240+ records a new review against it."
241+ }
242+ }
243+ form method="post" action=(format!("{base}/reviews")) .stack {
244+ input type="hidden" name="_csrf" value=(c.csrf);
245+ fieldset style="border:none;padding:0;margin:0" {
246+ legend .label-condensed { "Verdict" }
247+ @for (value, label) in [
248+ ("comment", "Comment — no verdict"),
249+ ("approve", "Approve"),
250+ ("request_changes", "Request changes"),
251+ ] {
252+ div .field style="margin:2px 0" {
253+ label {
254+ input type="radio" name="verdict" value=(value)
255+ checked[value == "comment"];
256+ " " (label)
257+ }
258+ }
259+ }
260+ }
261+ div .field {
262+ label for="review_body" { "Summary" }
263+ textarea id="review_body" name="body" rows="3" {}
264+ }
265+ button .btn.btn-primary type="submit" { "Submit review" }
266+ }
267+ }
268+ }
269+
270+ @if c.can_manage {
271+ (actions(c, &base))
272+ }
273+ }
274+}
275+
276+/// Draft toggle, abandon/reopen, and merge.
277+fn actions(c: &ChangeHead<'_>, base: &str) -> Markup {
278+ html! {
279+ div .panel {
280+ h2 { "Actions" }
281+ div .row style="gap:10px;flex-wrap:wrap" {
282+ // Draft is the author's own flag and is never inferred from
283+ // pushed metadata (decided; the indexer must not overwrite it).
284+ form method="post" action=(format!("{base}/state")) {
285+ input type="hidden" name="_csrf" value=(c.csrf);
286+ input type="hidden" name="state"
287+ value=(if c.state == "draft" { "open" } else { "draft" });
288+ button .btn type="submit" {
289+ @if c.state == "draft" { "Mark ready for review" } @else { "Convert to draft" }
290+ }
291+ }
292+
293+ @if c.state == "abandoned" {
294+ form method="post" action=(format!("{base}/state")) {
295+ input type="hidden" name="_csrf" value=(c.csrf);
296+ input type="hidden" name="state" value="open";
297+ button .btn type="submit" { "Reopen" }
298+ }
299+ } @else if c.state != "merged" {
300+ form method="post" action=(format!("{base}/state")) {
301+ input type="hidden" name="_csrf" value=(c.csrf);
302+ input type="hidden" name="state" value="abandoned";
303+ button .btn type="submit" { "Abandon" }
304+ }
305+ }
306+
307+ @if c.state == "open" && !c.conflicted {
308+ form method="post" action=(format!("{base}/merge")) {
309+ input type="hidden" name="_csrf" value=(c.csrf);
310+ button .btn.btn-primary type="submit" {
311+ "Merge into " (c.target_bookmark)
312+ }
313+ }
314+ }
315+ }
316+ @if c.conflicted {
317+ p .hint {
318+ "A conflicted change cannot be merged. Resolve the conflict in your \
319+ working copy and push again."
320+ }
321+ }
322+ }
323+ }
324+}
325+
326+/// Interleave events and top-level comments in one chronological list.
327+fn timeline(
328+ events: &[EventRow],
329+ comments: &[CommentRow],
330+ c: &ChangeHead<'_>,
331+ base: &str,
332+) -> Markup {
333+ enum Item<'a> {
334+ Event(&'a EventRow),
335+ Comment(&'a CommentRow),
336+ }
337+
338+ let mut items: Vec<(DateTime<Utc>, Item<'_>)> = Vec::new();
339+ items.extend(events.iter().map(|e| (e.created_at, Item::Event(e))));
340+ items.extend(comments.iter().map(|cm| (cm.created_at, Item::Comment(cm))));
341+ items.sort_by_key(|(t, _)| *t);
342+
343+ html! {
344+ @for (_, item) in &items {
345+ @match item {
346+ Item::Comment(cm) => (comment(cm, c, base, false)),
347+ Item::Event(e) => {
348+ div .row .timeline-event {
349+ span .timeline-dot.(event_dot_class(&e.kind)) aria-hidden="true" {}
350+ span .faint { (event_text(e)) }
351+ span .faint style="margin-left:auto" {
352+ (e.created_at.format("%Y-%m-%d %H:%M").to_string())
353+ }
354+ }
355+ }
356+ }
357+ }
358+ }
359+}
360+
361+/// One comment.
362+pub fn comment(cm: &CommentRow, c: &ChangeHead<'_>, base: &str, show_anchor: bool) -> Markup {
363+ html! {
364+ div .comment id=(format!("comment-{}", cm.id)) {
365+ div .row {
366+ strong { (cm.author) }
367+ span .faint { (cm.created_at.format("%Y-%m-%d %H:%M").to_string()) }
368+ @if cm.edited { span .faint { "edited" } }
369+ @if cm.anchor_state == "outdated" {
370+ span .chip title="the line this was written about has changed" { "outdated" }
371+ }
372+ @if cm.anchor_state == "orphaned" {
373+ span .chip title="the code this was written about is gone" { "orphaned" }
374+ }
375+ @if cm.resolved { span .chip { "resolved" } }
376+ }
377+
378+ @if show_anchor {
379+ @if let Some(p) = &cm.anchor_path {
380+ div .row .faint style="margin-top:4px;gap:6px" {
381+ span .mono { (p) }
382+ @if let Some(l) = cm.anchor_line { span { ":" (l) } }
383+ }
384+ }
385+ @if let Some(ctx_line) = &cm.anchor_context {
386+ // The original line, retained so an outdated or orphaned
387+ // comment still reads as being about something (spec §5).
388+ pre .anchor-context { code { (ctx_line) } }
389+ }
390+ }
391+
392+ div .comment-body.markdown-body { (PreEscaped(&cm.body_html)) }
393+
394+ @if c.can_manage && !cm.resolved && cm.anchor_path.is_some() {
395+ form method="post" action=(format!("{base}/comments/{}/resolve", cm.id)) {
396+ input type="hidden" name="_csrf" value=(c.csrf);
397+ button .btn type="submit" { "Resolve" }
398+ }
399+ }
400+ }
401+ }
402+}
403+
404+fn verdict_badge(v: &str) -> Markup {
405+ html! {
406+ @match v {
407+ "approve" => span .badge.badge-open { "approved" },
408+ "request_changes" => span .badge.badge-conflict { "changes requested" },
409+ _ => span .chip { "commented" },
410+ }
411+ }
412+}
413+
414+/// Human text for a timeline event.
415+///
416+/// Unknown kinds render their raw kind rather than being dropped: an event the
417+/// UI does not recognise still happened, and hiding it would make the timeline
418+/// quietly lie.
419+/// Which state colour an event's kind reads as, in the timeline dot.
420+///
421+/// Mirrors the grouping the design uses for its timeline markers: conflict
422+/// states get the conflict colour, anything that lands a change gets the
423+/// merged/open colours, and routine events (pushes, rebases) stay neutral.
424+fn event_dot_class(kind: &str) -> &'static str {
425+ match kind {
426+ "change.conflicted" => "timeline-dot-conflict",
427+ "change.resolved" | "change.merged" => "timeline-dot-merged",
428+ "change.opened" | "change.reopened" | "change.ready" => "timeline-dot-open",
429+ "change.abandoned" => "timeline-dot-abandoned",
430+ "change.reviewed" => "timeline-dot-action",
431+ _ => "timeline-dot-dim",
432+ }
433+}
434+
435+fn event_text(e: &EventRow) -> String {
436+ let who = e.actor.as_deref().unwrap_or("someone");
437+ let n = |k: &str| e.payload.get(k).and_then(|v| v.as_str()).unwrap_or("");
438+
439+ match e.kind.as_str() {
440+ "change.opened" => format!("{who} opened this change"),
441+ "change.pushed" => {
442+ let rev = n("rev");
443+ if rev.is_empty() {
444+ format!("{who} pushed a new revision")
445+ } else {
446+ format!("{who} pushed revision {}", df_store::abbreviate_rev(rev))
447+ }
448+ }
449+ "change.rebased" => format!("{who} rewrote this change"),
450+ "change.conflicted" => "this change became conflicted".into(),
451+ "change.resolved" => "the conflict was resolved".into(),
452+ "change.merged" => format!("{who} merged this into {}", n("bookmark")),
453+ "change.abandoned" => format!("{who} abandoned this change"),
454+ "change.reopened" => format!("{who} reopened this change"),
455+ "change.drafted" => format!("{who} converted this to a draft"),
456+ "change.ready" => format!("{who} marked this ready for review"),
457+ "change.reviewed" => format!("{who} reviewed this change"),
458+ "comments.rebased" => {
459+ let n_out = e.payload.get("outdated").and_then(|v| v.as_i64()).unwrap_or(0);
460+ let n_orph = e.payload.get("orphaned").and_then(|v| v.as_i64()).unwrap_or(0);
461+ format!("comments re-anchored onto the new revision — {n_out} outdated, {n_orph} orphaned")
462+ }
463+ other => format!("{who}: {other}"),
464+ }
465+}
466+
467+// ─── files ───────────────────────────────────────────────────────────────────
468+
469+pub struct FilesView<'a> {
470+ pub diff: Option<&'a Diff>,
471+ /// Inline comments, grouped by `(path, line)` on the new side.
472+ pub comments: &'a [CommentRow],
473+ /// The revision being viewed.
474+ pub rev: &'a str,
475+ /// What it is being compared against — a revision, or the parent.
476+ pub against: Option<&'a str>,
477+ /// Every revision of the change, for the compare selectors.
478+ pub revisions: &'a [(i32, String)],
479+}
480+
481+pub fn files(ctx: &RepoContext, c: &ChangeHead<'_>, f: FilesView<'_>) -> Markup {
482+ let base = format!("{}/changes/{}", ctx.base(), c.number);
483+
484+ html! {
485+ div .panel {
486+ // Revision-to-revision diffing (M4): compare any two revisions of
487+ // the change, which is what makes "what changed since I reviewed"
488+ // answerable at all.
489+ form method="get" action=(format!("{base}/files")) .row style="gap:10px;flex-wrap:wrap" {
490+ label for="against" .label-condensed { "Compare" }
491+ select id="against" name="against" {
492+ option value="" selected[f.against.is_none()] { "against the parent" }
493+ @for (seq, rev) in f.revisions {
494+ option value=(rev) selected[f.against == Some(rev.as_str())] {
495+ "v" (seq) " · " (df_store::abbreviate_rev(rev))
496+ }
497+ }
498+ }
499+ label for="rev" .label-condensed { "of" }
500+ select id="rev" name="rev" {
501+ @for (seq, rev) in f.revisions {
502+ option value=(rev) selected[f.rev == rev] {
503+ "v" (seq) " · " (df_store::abbreviate_rev(rev))
504+ }
505+ }
506+ }
507+ button .btn type="submit" { "Show" }
508+ }
509+ }
510+
511+ @match f.diff {
512+ None => div .panel { div .empty { h2 { "Nothing to show" } p { "This change has no revisions yet." } } },
513+ Some(d) => (diff_with_comments(d, f.comments, c, &base, f.rev)),
514+ }
515+ }
516+}
517+
518+/// The diff, with inline comment threads under the lines they anchor to.
519+pub fn diff_with_comments(
520+ diff: &Diff,
521+ comments: &[CommentRow],
522+ c: &ChangeHead<'_>,
523+ base: &str,
524+ rev: &str,
525+) -> Markup {
526+ html! {
527+ div .panel {
528+ div .row style="margin-bottom:12px" {
529+ h2 style="margin:0" { "Files changed" }
530+ span .faint { (diff.files.len()) " file(s)" }
531+ span style="color:var(--diff-add-text)" { "+" (diff.total_additions) }
532+ span style="color:var(--diff-del-text)" { "−" (diff.total_deletions) }
533+ }
534+
535+ @if diff.truncated {
536+ div .banner.banner-error role="alert" {
537+ "This diff exceeds the render limits and is shown in part. \
538+ Fetch the revision with " code { "jj" } " to read it in full."
539+ }
540+ }
541+
542+ @if diff.files.is_empty() {
543+ p .dim { "No changes." }
544+ }
545+
546+ @for file in &diff.files {
547+ div .filediff {
548+ div .row .filediff-head id=(format!("f-{}", path_anchor(&file.path))) {
549+ span .mono { (file.path) }
550+ @if let Some(old) = &file.old_path { span .faint { " ← " (old) } }
551+ span .faint style="margin-left:auto" {
552+ "+" (file.additions) " −" (file.deletions)
553+ }
554+ }
555+
556+ @if file.binary {
557+ p .dim style="padding:10px" { "Binary file not shown." }
558+ } @else {
559+ table .mono .difftable {
560+ tbody {
561+ @for hunk in &file.hunks {
562+ tr .hunkhead {
563+ td colspan="3" {
564+ "@@ -" (hunk.old_start) "," (hunk.old_lines)
565+ " +" (hunk.new_start) "," (hunk.new_lines) " @@"
566+ }
567+ }
568+ @for l in &hunk.lines {
569+ tr .(line_class(l.kind)) {
570+ td .faint .lineno { @if let Some(n) = l.old_lineno { (n) } }
571+ td .faint .lineno { @if let Some(n) = l.new_lineno { (n) } }
572+ td .codeline {
573+ (marker(l.kind))
574+ (spans(&l.spans, l.kind))
575+ }
576+ }
577+
578+ // Threads anchored to this line, then the
579+ // form to add one. Both are plain HTML.
580+ @if let Some(n) = l.new_lineno {
581+ @let here: Vec<&CommentRow> = comments
582+ .iter()
583+ .filter(|cm| {
584+ cm.anchor_path.as_deref() == Some(file.path.as_str())
585+ && cm.anchor_line == Some(n as i32)
586+ })
587+ .collect();
588+ @if !here.is_empty() {
589+ tr { td colspan="3" .inline-thread {
590+ @for cm in here { (comment(cm, c, base, false)) }
591+ } }
592+ }
593+ @if c.can_comment {
594+ tr { td colspan="3" .inline-form {
595+ details {
596+ summary .faint { "Comment on line " (n) }
597+ form method="post"
598+ action=(format!("{base}/comments")) .stack {
599+ input type="hidden" name="_csrf" value=(c.csrf);
600+ input type="hidden" name="path" value=(file.path);
601+ input type="hidden" name="line" value=(n);
602+ input type="hidden" name="side" value="new";
603+ input type="hidden" name="rev" value=(rev);
604+ input type="hidden" name="context" value=(l.content);
605+ textarea name="body" rows="3" required {}
606+ button .btn.btn-primary type="submit" { "Comment" }
607+ }
608+ }
609+ } }
610+ }
611+ }
612+ }
613+ }
614+ }
615+ }
616+ }
617+ }
618+ }
619+ }
620+ }
621+}
622+
623+/// Render a line's word-level spans (spec §8).
624+fn spans(spans: &[DiffSpan], kind: DiffLineKind) -> Markup {
625+ html! {
626+ @for s in spans {
627+ @if s.emphasis && !matches!(kind, DiffLineKind::Context) {
628+ span .word-changed { (s.text) }
629+ } @else {
630+ (s.text)
631+ }
632+ }
633+ }
634+}
635+
636+// ─── revisions ───────────────────────────────────────────────────────────────
637+
638+pub struct RevisionDetail {
639+ pub seq: i32,
640+ pub rev: String,
641+ pub message: String,
642+ pub author_name: String,
643+ pub pushed_at: DateTime<Utc>,
644+ pub conflicted: bool,
645+ pub pushed_by: Option<String>,
646+}
647+
648+pub fn revisions(ctx: &RepoContext, c: &ChangeHead<'_>, revs: &[RevisionDetail]) -> Markup {
649+ let base = format!("{}/changes/{}", ctx.base(), c.number);
650+ html! {
651+ div .panel {
652+ h2 { "Revisions" }
653+ p .dim {
654+ "Each rewrite of this change appends a revision. The review, and every \
655+ comment on it, stays attached to the change — this is the thing a \
656+ branch-based forge cannot do."
657+ }
658+
659+ table .listing {
660+ thead { tr { th scope="col" { "" } th scope="col" { "Revision" } th scope="col" { "Summary" } th scope="col" { "Pushed" } th scope="col" { "" } } }
661+ tbody {
662+ @for (i, r) in revs.iter().enumerate() {
663+ tr {
664+ td .faint { "v" (r.seq) }
665+ td {
666+ a .mono href=(format!("{}/tree/{}/", ctx.base(), r.rev)) {
667+ (df_store::abbreviate_rev(&r.rev))
668+ }
669+ @if r.conflicted {
670+ " " span .badge.badge-conflict { "conflict" }
671+ }
672+ }
673+ td { (first_line(&r.message)) }
674+ td .faint {
675+ (r.pushed_at.format("%Y-%m-%d %H:%M").to_string())
676+ @if let Some(p) = &r.pushed_by { " by " (p) }
677+ @if r.author_name != *p_or_empty(&r.pushed_by) {
678+ " · authored by " (r.author_name)
679+ }
680+ }
681+ td {
682+ a .btn href=(format!("{base}/files?rev={}", r.rev)) { "Diff" }
683+ @if i > 0 {
684+ " "
685+ a .btn href=(format!(
686+ "{base}/files?rev={}&against={}", r.rev, revs[i - 1].rev
687+ )) { "vs v" (revs[i - 1].seq) }
688+ }
689+ }
690+ }
691+ }
692+ }
693+ }
694+ }
695+ }
696+}
697+
698+fn p_or_empty(s: &Option<String>) -> &str {
699+ s.as_deref().unwrap_or("")
700+}
701+
702+// ─── conflicts (M4) ──────────────────────────────────────────────────────────
703+
704+pub fn conflicts(_ctx: &RepoContext, _c: &ChangeHead<'_>, files: &[ConflictedFile]) -> Markup {
705+ html! {
706+ div .panel {
707+ h2 { "Conflicts" }
708+ p .dim {
709+ "Dogfood shows conflicts read-only. Resolve them in your working copy \
710+ with " code { "jj resolve" } " and push again — the change, this review, \
711+ and every comment on it stay where they are."
712+ }
713+
714+ @if files.is_empty() {
715+ p .hint {
716+ "The head revision is marked conflicted but no conflicted file could \
717+ be read. This usually means the conflict is structural — a delete \
718+ against a modify at the directory level."
719+ }
720+ }
721+
722+ @for f in files {
723+ div .filediff {
724+ div .row .filediff-head { span .mono { (f.path) } }
725+ div .conflict-columns {
726+ @for (label, content) in &f.sides {
727+ div .conflict-side .conflict-base[label.is_base()] {
728+ div .label-condensed { (label.label()) }
729+ @match content {
730+ // A column that does not contain the file at
731+ // all is a delete/modify conflict, and saying
732+ // so is the whole point of showing it.
733+ None => p .hint { "not present in this side" },
734+ Some(text) => pre { code { (text) } },
735+ }
736+ }
737+ }
738+ }
739+ }
740+ }
741+ }
742+ }
743+}
744+
745+// ─── stack graph (M4) ────────────────────────────────────────────────────────
746+
747+pub struct StackNode {
748+ pub number: i64,
749+ pub change_id: String,
750+ pub synthetic: bool,
751+ pub title: String,
752+ pub state: String,
753+ pub conflicted: bool,
754+ pub is_current: bool,
755+ /// Depth from the bottom of the stack, for indentation.
756+ pub depth: usize,
757+}
758+
759+pub fn stack(ctx: &RepoContext, nodes: &[StackNode], change_id: &str) -> Markup {
760+ let base = ctx.base();
761+ html! {
762+ div .panel {
763+ h1 { "Stack" }
764+ p .lede {
765+ "A stack is a chain of changes where each builds on the one below and \
766+ none has landed yet. Edges are computed at index time from the commit \
767+ graph, so a rebase moves the whole stack without breaking it."
768+ }
769+
770+ @if nodes.len() <= 1 {
771+ div .empty {
772+ h2 { "Not stacked" }
773+ p { "This change does not sit in a stack." }
774+ p { a .btn href=(format!("{base}/changes/{change_id}")) { "Back to the change" } }
775+ }
776+ } @else {
777+ // Top of the stack first — that is how jj log reads, and the
778+ // change a reviewer is looking at is usually near the top.
779+ ol .stackgraph {
780+ @for n in nodes.iter().rev() {
781+ li .stacknode .current[n.is_current] style=(format!("margin-left:{}px", n.depth * 18)) {
782+ div .row {
783+ (state_badge(&n.state, n.conflicted))
784+ a href=(format!("{base}/changes/{}", n.number)) { (n.title) }
785+ @if n.is_current { span .chip { "you are here" } }
786+ }
787+ div .row style="margin-top:4px;gap:8px" {
788+ (change_chip(&n.change_id, n.synthetic))
789+ span .faint { "#" (n.number) }
790+ }
791+ }
792+ }
793+ }
794+
795+ p .hint {
796+ "Merging the bottom of a stack lands only that change. \
797+ Use " strong { "Merge stack" } " to land the whole chain bottom-up."
798+ }
799+ }
800+ }
801+ }
802+}
803+
804+// ─── helpers ─────────────────────────────────────────────────────────────────
805+
806+fn line_class(kind: DiffLineKind) -> &'static str {
807+ match kind {
808+ DiffLineKind::Added => "line-add",
809+ DiffLineKind::Deleted => "line-del",
810+ DiffLineKind::Context => "line-ctx",
811+ }
812+}
813+
814+fn marker(kind: DiffLineKind) -> &'static str {
815+ match kind {
816+ DiffLineKind::Added => "+",
817+ DiffLineKind::Deleted => "-",
818+ DiffLineKind::Context => " ",
819+ }
820+}
821+
822+fn first_line(s: &str) -> &str {
823+ s.lines().next().unwrap_or("").trim()
824+}
825+
826+/// A path turned into a fragment-safe anchor.
827+fn path_anchor(path: &str) -> String {
828+ path.chars()
829+ .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
830+ .collect()
831+}
832+
833+#[cfg(test)]
834+mod tests {
835+ use super::*;
836+
837+ fn ev(kind: &str, payload: serde_json::Value) -> EventRow {
838+ EventRow {
839+ kind: kind.into(),
840+ actor: Some("alice".into()),
841+ created_at: Utc::now(),
842+ payload,
843+ }
844+ }
845+
846+ #[test]
847+ fn known_events_read_as_sentences() {
848+ assert_eq!(
849+ event_text(&ev("change.merged", serde_json::json!({"bookmark": "main"}))),
850+ "alice merged this into main"
851+ );
852+ assert_eq!(
853+ event_text(&ev("change.opened", serde_json::json!({}))),
854+ "alice opened this change"
855+ );
856+ }
857+
858+ /// An event the UI does not know about still happened. Dropping it would
859+ /// make the timeline quietly incomplete, which is worse than an ugly line.
860+ #[test]
861+ fn unknown_events_are_shown_rather_than_hidden() {
862+ let text = event_text(&ev("something.new", serde_json::json!({})));
863+ assert!(text.contains("something.new"), "{text}");
864+ }
865+
866+ #[test]
867+ fn an_actorless_event_still_renders() {
868+ let mut e = ev("change.opened", serde_json::json!({}));
869+ e.actor = None;
870+ assert_eq!(event_text(&e), "someone opened this change");
871+ }
872+
873+ #[test]
874+ fn revision_abbreviation_goes_through_the_store() {
875+ // Spec §3 rule 2: RevId is opaque and abbreviation lives in df-store.
876+ let text = event_text(&ev(
877+ "change.pushed",
878+ serde_json::json!({"rev": "0123456789abcdef0123456789abcdef01234567"}),
879+ ));
880+ assert!(text.ends_with("0123456789ab"), "{text}");
881+ }
882+
883+ #[test]
884+ fn path_anchors_cannot_break_out_of_a_fragment() {
885+ assert_eq!(path_anchor("src/a b.rs"), "src-a-b-rs");
886+ assert_eq!(path_anchor("a\"><script>"), "a---script-");
887+ }
888+}
Acrates/df-web/src/views/settings.rs544 lines+544−0
@@ −0,0 +1,544 @@
1+//! Settings pages — the user's own, and a repository's (spec §7).
2+//!
3+//! Every form here is a plain `<form method="post">`. Spec §7 makes progressive
4+//! enhancement a hard requirement, and settings are exactly where a
5+//! JavaScript-only affordance would be most annoying to hit.
6+//!
7+//! Note the deletion forms POST rather than sending `DELETE`. The spec's route
8+//! table says `DELETE /settings/keys/{id}`, and that route exists for API
9+//! clients — but browsers cannot emit it from a form without JavaScript, so the
10+//! POST alias is what the UI uses. Both are registered; both do the same thing.
11+
12+use maud::{html, Markup};
13+
14+use crate::repo_ctx::RepoContext;
15+
16+// ─── the user's own settings ─────────────────────────────────────────────────
17+
18+pub struct SshKeyRow {
19+ pub id: uuid::Uuid,
20+ pub name: String,
21+ pub key_type: String,
22+ pub fingerprint: String,
23+ pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
24+}
25+
26+pub struct TokenRow {
27+ pub id: uuid::Uuid,
28+ pub name: String,
29+ pub prefix: String,
30+ pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
31+ pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
32+}
33+
34+pub struct UserSettings<'a> {
35+ pub user: &'a df_db::models::User,
36+ pub csrf: &'a str,
37+ pub keys: &'a [SshKeyRow],
38+ pub tokens: &'a [TokenRow],
39+ /// A token's plaintext, shown exactly once immediately after minting it.
40+ pub new_token: Option<&'a str>,
41+ pub error: Option<&'a str>,
42+ pub notice: Option<&'a str>,
43+ pub ssh_host: &'a str,
44+}
45+
46+pub fn user_settings(s: UserSettings<'_>) -> Markup {
47+ html! {
48+ h1 { "Settings" }
49+
50+ @if let Some(e) = s.error { div .banner.banner-error role="alert" { (e) } }
51+ @if let Some(n) = s.notice { div .banner.banner-ok role="status" { (n) } }
52+
53+ div .panel {
54+ h2 { "Profile" }
55+ dl .kv {
56+ dt { "Handle" } dd { code { (s.user.handle) } }
57+ dt { "Name" } dd { (s.user.display_name.as_deref().unwrap_or("—")) }
58+ dt { "Email" } dd { (s.user.email.as_deref().unwrap_or("—")) }
59+ dt { "Role" } dd { @if s.user.is_admin { "Site administrator" } @else { "Member" } }
60+ }
61+ p .hint {
62+ "Your name and email come from the identity provider you sign in with, \
63+ and change there rather than here."
64+ }
65+ }
66+
67+ // ─── SSH keys ────────────────────────────────────────────────────────
68+ div .panel {
69+ h2 { "SSH keys" }
70+ p .dim {
71+ "Push over SSH with "
72+ code { "jj git push" } " or " code { "git push" } ". Clone URLs look like "
73+ code { (s.ssh_host) }
74+ "."
75+ }
76+
77+ @if s.keys.is_empty() {
78+ p .hint { "No keys yet." }
79+ } @else {
80+ table .listing {
81+ thead {
82+ tr { th scope="col" { "Name" } th scope="col" { "Fingerprint" } th scope="col" { "Last used" } th scope="col" {} }
83+ }
84+ tbody {
85+ @for k in s.keys {
86+ tr {
87+ td { (k.name) " " span .chip { (k.key_type) } }
88+ td { code .faint { (k.fingerprint) } }
89+ td .faint { (opt_date(k.last_used_at)) }
90+ td {
91+ form method="post" action=(format!("/settings/keys/{}/delete", k.id)) {
92+ input type="hidden" name="_csrf" value=(s.csrf);
93+ button .btn.btn-danger type="submit" { "Remove" }
94+ }
95+ }
96+ }
97+ }
98+ }
99+ }
100+ }
101+
102+ form method="post" action="/settings/keys" .stack {
103+ input type="hidden" name="_csrf" value=(s.csrf);
104+ div .field {
105+ label for="key" { "New key" }
106+ textarea id="key" name="key" rows="4" required
107+ placeholder="ssh-ed25519 AAAA… you@your-machine" {}
108+ p .hint {
109+ "Paste the contents of your "
110+ code { ".pub" }
111+ " file — never the private key."
112+ }
113+ }
114+ div .field {
115+ label for="key_name" { "Name (optional)" }
116+ input type="text" id="key_name" name="name" maxlength="100"
117+ placeholder="taken from the key's comment";
118+ }
119+ button .btn.btn-primary type="submit" { "Add key" }
120+ }
121+ }
122+
123+ // ─── access tokens ───────────────────────────────────────────────────
124+ div .panel {
125+ h2 { "Access tokens" }
126+ p .dim {
127+ "For Git over HTTPS. Use your handle as the username and the token as \
128+ the password."
129+ }
130+
131+ @if let Some(t) = s.new_token {
132+ div .banner.banner-ok role="status" {
133+ p { strong { "Copy this now — it is not shown again." } }
134+ div .clone-box { code { (t) } }
135+ }
136+ }
137+
138+ @if s.tokens.is_empty() {
139+ p .hint { "No tokens yet." }
140+ } @else {
141+ table .listing {
142+ thead {
143+ tr { th scope="col" { "Name" } th scope="col" { "Prefix" } th scope="col" { "Expires" } th scope="col" { "Last used" } th scope="col" {} }
144+ }
145+ tbody {
146+ @for t in s.tokens {
147+ tr {
148+ td { (t.name) }
149+ td { code .faint { (t.prefix) "…" } }
150+ td .faint { (opt_date(t.expires_at)) }
151+ td .faint { (opt_date(t.last_used_at)) }
152+ td {
153+ form method="post" action=(format!("/settings/tokens/{}/delete", t.id)) {
154+ input type="hidden" name="_csrf" value=(s.csrf);
155+ button .btn.btn-danger type="submit" { "Revoke" }
156+ }
157+ }
158+ }
159+ }
160+ }
161+ }
162+ }
163+
164+ form method="post" action="/settings/tokens" .stack {
165+ input type="hidden" name="_csrf" value=(s.csrf);
166+ div .field {
167+ label for="token_name" { "Name" }
168+ input type="text" id="token_name" name="name" required maxlength="100"
169+ placeholder="laptop";
170+ }
171+ div .field {
172+ label for="expires_days" { "Expires in" }
173+ select id="expires_days" name="expires_days" {
174+ option value="30" { "30 days" }
175+ option value="90" selected { "90 days" }
176+ option value="365" { "1 year" }
177+ option value="0" { "Never" }
178+ }
179+ }
180+ button .btn.btn-primary type="submit" { "Generate token" }
181+ }
182+ }
183+ }
184+}
185+
186+// ─── repository settings ─────────────────────────────────────────────────────
187+
188+pub struct CollaboratorRow {
189+ pub handle: String,
190+ pub role: df_db::models::RepoRole,
191+}
192+
193+pub struct BookmarkRow {
194+ pub name: String,
195+ pub protected: bool,
196+ pub is_default: bool,
197+}
198+
199+pub struct RepoSettings<'a> {
200+ pub ctx: &'a RepoContext,
201+ pub csrf: &'a str,
202+ pub tab: &'a str,
203+ pub collaborators: &'a [CollaboratorRow],
204+ pub bookmarks: &'a [BookmarkRow],
205+ pub size_bytes: u64,
206+ pub error: Option<&'a str>,
207+ pub notice: Option<&'a str>,
208+}
209+
210+pub fn repo_settings(s: RepoSettings<'_>) -> Markup {
211+ let base = s.ctx.base();
212+ let settings = format!("{base}/settings");
213+
214+ html! {
215+ @if let Some(e) = s.error { div .banner.banner-error role="alert" { (e) } }
216+ @if let Some(n) = s.notice { div .banner.banner-ok role="status" { (n) } }
217+
218+ nav .subtabs aria-label="Settings sections" {
219+ @for (key, label) in [("general", "General"), ("collaborators", "Collaborators"),
220+ ("bookmarks", "Bookmarks"), ("danger", "Danger zone")] {
221+ a href=(format!("{settings}?tab={key}"))
222+ .active[s.tab == key]
223+ aria-current=[(s.tab == key).then_some("page")] { (label) }
224+ }
225+ }
226+
227+ @match s.tab {
228+ "collaborators" => (collaborators_tab(&s)),
229+ "bookmarks" => (bookmarks_tab(&s)),
230+ "danger" => (danger_tab(&s)),
231+ _ => (general_tab(&s)),
232+ }
233+ }
234+}
235+
236+fn general_tab(s: &RepoSettings<'_>) -> Markup {
237+ let action = format!("{}/settings/general", s.ctx.base());
238+ let private = matches!(s.ctx.repo.visibility, df_db::models::Visibility::Private);
239+
240+ html! {
241+ div .panel {
242+ h2 { "General" }
243+ form method="post" action=(action) .stack {
244+ input type="hidden" name="_csrf" value=(s.csrf);
245+
246+ div .field {
247+ label for="description" { "Description" }
248+ input type="text" id="description" name="description" maxlength="500"
249+ value=(s.ctx.repo.description.as_deref().unwrap_or(""));
250+ }
251+
252+ div .field {
253+ label for="default_bookmark" { "Default bookmark" }
254+ select id="default_bookmark" name="default_bookmark" {
255+ @for b in s.bookmarks {
256+ option value=(b.name) selected[b.is_default] { (b.name) }
257+ }
258+ // A repository with no pushes yet has no bookmark rows,
259+ // so keep the configured name selectable.
260+ @if s.bookmarks.is_empty() {
261+ option value=(s.ctx.repo.default_bookmark) selected {
262+ (s.ctx.repo.default_bookmark)
263+ }
264+ }
265+ }
266+ p .hint { "Where the code view opens, and what changes are measured against." }
267+ }
268+
269+ div .field {
270+ label {
271+ input type="checkbox" name="private" value="1" checked[private];
272+ " Private"
273+ }
274+ p .hint {
275+ "A private repository is invisible to everyone but its collaborators — \
276+ it returns the same 404 a nonexistent repository does."
277+ }
278+ }
279+
280+ button .btn.btn-primary type="submit" { "Save" }
281+ }
282+ }
283+
284+ div .panel {
285+ h2 { "Storage" }
286+ dl .kv {
287+ dt { "On disk" } dd { (crate::views::repo::human_size(s.size_bytes)) }
288+ dt { "Created" } dd { (s.ctx.repo.created_at.format("%Y-%m-%d")) }
289+ dt { "Last push" } dd { (opt_date(s.ctx.repo.pushed_at)) }
290+ }
291+ }
292+ }
293+}
294+
295+fn collaborators_tab(s: &RepoSettings<'_>) -> Markup {
296+ let base = s.ctx.base();
297+ html! {
298+ div .panel {
299+ h2 { "Collaborators" }
300+ p .dim {
301+ "Effective access is the highest of a direct role here, an organization \
302+ role, and repository ownership."
303+ }
304+
305+ @if s.collaborators.is_empty() {
306+ p .hint { "No collaborators. Only the owner can reach this repository." }
307+ } @else {
308+ table .listing {
309+ thead { tr { th scope="col" { "User" } th scope="col" { "Role" } th scope="col" {} } }
310+ tbody {
311+ @for c in s.collaborators {
312+ tr {
313+ td { a href=(format!("/{}", c.handle)) { (c.handle) } }
314+ td {
315+ form method="post" action=(format!("{base}/settings/collaborators")) .row {
316+ input type="hidden" name="_csrf" value=(s.csrf);
317+ input type="hidden" name="handle" value=(c.handle);
318+ select name="role" {
319+ @for r in ["read", "write", "maintain", "admin"] {
320+ option value=(r) selected[role_str(c.role) == r] { (r) }
321+ }
322+ }
323+ button .btn type="submit" { "Update" }
324+ }
325+ }
326+ td {
327+ form method="post" action=(format!("{base}/settings/collaborators/remove")) {
328+ input type="hidden" name="_csrf" value=(s.csrf);
329+ input type="hidden" name="handle" value=(c.handle);
330+ button .btn.btn-danger type="submit" { "Remove" }
331+ }
332+ }
333+ }
334+ }
335+ }
336+ }
337+ }
338+
339+ form method="post" action=(format!("{base}/settings/collaborators")) .stack {
340+ input type="hidden" name="_csrf" value=(s.csrf);
341+ div .field {
342+ label for="handle" { "Add a collaborator" }
343+ input type="text" id="handle" name="handle" required
344+ pattern="[a-z0-9][a-z0-9-]*" maxlength="39" placeholder="handle";
345+ }
346+ div .field {
347+ label for="role" { "Role" }
348+ select id="role" name="role" {
349+ option value="read" { "read — browse and comment" }
350+ option value="write" selected { "write — also push" }
351+ option value="maintain" { "maintain — also manage changes and settings" }
352+ option value="admin" { "admin — also delete" }
353+ }
354+ }
355+ button .btn.btn-primary type="submit" { "Add" }
356+ }
357+ }
358+ }
359+}
360+
361+fn bookmarks_tab(s: &RepoSettings<'_>) -> Markup {
362+ let base = s.ctx.base();
363+ html! {
364+ div .panel {
365+ h2 { "Bookmarks" }
366+ p .dim {
367+ "A protected bookmark cannot be deleted or force-updated by a push. \
368+ The default bookmark is always protected."
369+ }
370+
371+ @if s.bookmarks.is_empty() {
372+ p .hint { "Nothing has been pushed yet." }
373+ } @else {
374+ table .listing {
375+ thead { tr { th scope="col" { "Bookmark" } th scope="col" { "Protected" } th scope="col" {} } }
376+ tbody {
377+ @for b in s.bookmarks {
378+ tr {
379+ td {
380+ code { (b.name) }
381+ @if b.is_default { " " span .chip { "default" } }
382+ }
383+ td { @if b.protected || b.is_default { "yes" } @else { "no" } }
384+ td {
385+ @if !b.is_default {
386+ form method="post" action=(format!("{base}/settings/bookmarks")) {
387+ input type="hidden" name="_csrf" value=(s.csrf);
388+ input type="hidden" name="name" value=(b.name);
389+ input type="hidden" name="protected"
390+ value=(if b.protected { "0" } else { "1" });
391+ button .btn type="submit" {
392+ @if b.protected { "Unprotect" } @else { "Protect" }
393+ }
394+ }
395+ } @else {
396+ span .faint { "always" }
397+ }
398+ }
399+ }
400+ }
401+ }
402+ }
403+ }
404+ }
405+ }
406+}
407+
408+fn danger_tab(s: &RepoSettings<'_>) -> Markup {
409+ let base = s.ctx.base();
410+ let full = format!("{}/{}", s.ctx.owner, s.ctx.repo.name);
411+ html! {
412+ div .panel.panel-danger {
413+ h2 { "Danger zone" }
414+
415+ form method="post" action=(format!("{base}/settings/archive")) .stack {
416+ input type="hidden" name="_csrf" value=(s.csrf);
417+ h3 { @if s.ctx.repo.archived { "Unarchive" } @else { "Archive" } }
418+ p .dim {
419+ "An archived repository is read-only: it still browses, but pushes \
420+ are refused."
421+ }
422+ input type="hidden" name="archived" value=(if s.ctx.repo.archived { "0" } else { "1" });
423+ button .btn type="submit" {
424+ @if s.ctx.repo.archived { "Unarchive repository" } @else { "Archive repository" }
425+ }
426+ }
427+
428+ hr;
429+
430+ @if s.ctx.access.can_delete() {
431+ form method="post" action=(format!("{base}/settings/delete")) .stack {
432+ input type="hidden" name="_csrf" value=(s.csrf);
433+ h3 { "Delete this repository" }
434+ p .dim {
435+ "This removes the repository, its changes, its reviews, and every \
436+ comment on them. It cannot be undone and the stored objects are \
437+ deleted from disk."
438+ }
439+ div .field {
440+ label for="confirm" {
441+ "Type " code { (full) } " to confirm"
442+ }
443+ input type="text" id="confirm" name="confirm" required autocomplete="off";
444+ }
445+ button .btn.btn-danger type="submit" { "Delete repository" }
446+ }
447+ } @else {
448+ p .hint { "Only a repository administrator can delete it." }
449+ }
450+ }
451+ }
452+}
453+
454+// ─── owner profile ───────────────────────────────────────────────────────────
455+
456+pub struct ProfileRepo {
457+ pub name: String,
458+ pub description: Option<String>,
459+ pub private: bool,
460+ pub pushed_at: Option<chrono::DateTime<chrono::Utc>>,
461+}
462+
463+pub struct Profile<'a> {
464+ pub handle: &'a str,
465+ pub display_name: Option<&'a str>,
466+ pub description: Option<&'a str>,
467+ pub is_org: bool,
468+ pub repos: &'a [ProfileRepo],
469+ /// Org members, when the profile is an organization.
470+ pub members: &'a [(String, df_db::models::OrgRole)],
471+ pub joined: chrono::DateTime<chrono::Utc>,
472+}
473+
474+pub fn profile(p: Profile<'_>) -> Markup {
475+ html! {
476+ div .panel {
477+ div .row {
478+ h1 style="margin:0" { (p.display_name.unwrap_or(p.handle)) }
479+ @if p.is_org { span .chip { "organization" } }
480+ }
481+ p .dim { "@" (p.handle) }
482+ @if let Some(d) = p.description { p { (d) } }
483+ p .hint { "Here since " (p.joined.format("%B %Y")) "." }
484+ }
485+
486+ div .panel {
487+ h2 { "Repositories" }
488+ @if p.repos.is_empty() {
489+ p .hint { "Nothing visible to you here." }
490+ } @else {
491+ div .stack {
492+ @for r in p.repos {
493+ div .row {
494+ a href=(format!("/{}/{}", p.handle, r.name)) { strong { (r.name) } }
495+ @if r.private { span .chip { "private" } }
496+ @if let Some(d) = &r.description { span .dim { (d) } }
497+ span .faint style="margin-left:auto" {
498+ @if let Some(t) = r.pushed_at { "pushed " (t.format("%Y-%m-%d")) }
499+ }
500+ }
501+ }
502+ }
503+ }
504+ }
505+
506+ @if p.is_org && !p.members.is_empty() {
507+ div .panel {
508+ h2 { "Members" }
509+ div .stack {
510+ @for (handle, role) in p.members {
511+ div .row {
512+ a href=(format!("/{handle}")) { (handle) }
513+ span .chip { (org_role_str(*role)) }
514+ }
515+ }
516+ }
517+ }
518+ }
519+ }
520+}
521+
522+// ─── helpers ─────────────────────────────────────────────────────────────────
523+
524+fn opt_date(t: Option<chrono::DateTime<chrono::Utc>>) -> String {
525+ t.map(|t| t.format("%Y-%m-%d").to_string())
526+ .unwrap_or_else(|| "never".into())
527+}
528+
529+pub fn role_str(r: df_db::models::RepoRole) -> &'static str {
530+ use df_db::models::RepoRole::*;
531+ match r {
532+ Read => "read",
533+ Write => "write",
534+ Maintain => "maintain",
535+ Admin => "admin",
536+ }
537+}
538+
539+fn org_role_str(r: df_db::models::OrgRole) -> &'static str {
540+ match r {
541+ df_db::models::OrgRole::Member => "member",
542+ df_db::models::OrgRole::Admin => "admin",
543+ }
544+}
Acrates/df-worker/Cargo.toml+26−0
@@ −0,0 +1,26 @@
1+[package]
2+name = "df-worker"
3+version.workspace = true
4+edition.workspace = true
5+rust-version.workspace = true
6+license.workspace = true
7+
8+[[bin]]
9+name = "dogfood-worker"
10+path = "src/main.rs"
11+
12+[dependencies]
13+df-db = { path = "../df-db" }
14+df-index = { path = "../df-index" }
15+df-store = { path = "../df-store" }
16+
17+anyhow.workspace = true
18+chrono.workspace = true
19+dotenvy.workspace = true
20+serde.workspace = true
21+serde_json.workspace = true
22+sqlx.workspace = true
23+tokio.workspace = true
24+tracing.workspace = true
25+tracing-subscriber.workspace = true
26+uuid.workspace = true
Acrates/df-worker/src/anchors.rs+220−0
@@ −0,0 +1,220 @@
1+//! Step 6 of the indexing pipeline: rebase comment anchors onto the new
2+//! revision (spec §4, §5).
3+//!
4+//! > This is the mechanism that makes stable change identity actually pay off
5+//! > for reviewers, and it is the single most valuable piece of logic in the
6+//! > product.
7+//!
8+//! The decision logic is `df_index::rebase_anchor`, which is pure and heavily
9+//! tested. This module is the part that touches the world: read the two
10+//! versions of each commented file out of the store, build the line mapping,
11+//! and write the outcome back.
12+//!
13+//! Two properties it has to hold:
14+//!
15+//! * **Idempotent.** Re-running the indexer over the same revisions must not
16+//! walk a comment further down the file each time. The anchor is always
17+//! rebased *from the revision it is currently anchored to*, so a second run
18+//! over the same pair is a no-op rather than a second shift.
19+//! * **Never destructive.** A comment whose anchor cannot be resolved is marked
20+//! `outdated` or `orphaned`. It is never deleted and its `anchor_context` —
21+//! the line as it read when the comment was written — is never overwritten.
22+
23+use std::collections::HashMap;
24+use std::path::Path;
25+
26+use anyhow::Result;
27+use df_index::{rebase_anchor, AnchorOutcome};
28+use df_store::{RepoId, RepoStore, RevId};
29+use sqlx::PgPool;
30+use uuid::Uuid;
31+
32+/// How many comments to rebase in one pass.
33+///
34+/// A change with thousands of inline comments is pathological, and reading two
35+/// blobs per distinct file is the expensive part; the cap keeps one push from
36+/// occupying the worker indefinitely.
37+const MAX_COMMENTS: i64 = 2_000;
38+
39+#[derive(Debug, Default, PartialEq, Eq)]
40+pub struct Summary {
41+ pub moved: usize,
42+ pub outdated: usize,
43+ pub orphaned: usize,
44+ pub unchanged: usize,
45+}
46+
47+impl Summary {
48+ pub fn touched(&self) -> usize {
49+ self.moved + self.outdated + self.orphaned
50+ }
51+}
52+
53+struct Anchor {
54+ id: Uuid,
55+ path: String,
56+ line: i32,
57+ context: Option<String>,
58+ state: String,
59+}
60+
61+/// Re-anchor every inline comment on `change_uuid` from `from_rev` onto
62+/// `to_rev`.
63+pub async fn rebase(
64+ db: &PgPool,
65+ store: &dyn RepoStore,
66+ repo: RepoId,
67+ change_uuid: Uuid,
68+ from_rev: &str,
69+ to_rev: &str,
70+ new_revision_id: Uuid,
71+) -> Result<Summary> {
72+ if from_rev == to_rev {
73+ return Ok(Summary::default());
74+ }
75+
76+ let rows: Vec<(Uuid, String, i32, Option<String>, String)> = sqlx::query_as(
77+ "SELECT id, anchor_path, anchor_line, anchor_context, anchor_state::text
78+ FROM comments
79+ WHERE change_id_fk = $1
80+ AND anchor_path IS NOT NULL
81+ AND anchor_line IS NOT NULL
82+ -- An orphaned anchor has nothing left to track. Re-examining it on
83+ -- every push would be work with no possible outcome.
84+ AND anchor_state <> 'orphaned'
85+ ORDER BY id
86+ LIMIT $2",
87+ )
88+ .bind(change_uuid)
89+ .bind(MAX_COMMENTS)
90+ .fetch_all(db)
91+ .await?;
92+
93+ if rows.is_empty() {
94+ return Ok(Summary::default());
95+ }
96+
97+ let anchors: Vec<Anchor> = rows
98+ .into_iter()
99+ .map(|(id, path, line, context, state)| Anchor { id, path, line, context, state })
100+ .collect();
101+
102+ // Group by file so each file's two blobs are read once, however many
103+ // comments sit in it.
104+ let mut by_path: HashMap<&str, Vec<&Anchor>> = HashMap::new();
105+ for a in &anchors {
106+ by_path.entry(a.path.as_str()).or_default().push(a);
107+ }
108+
109+ let from = RevId::from_stored(from_rev.to_owned());
110+ let to = RevId::from_stored(to_rev.to_owned());
111+
112+ let mut summary = Summary::default();
113+
114+ for (path, group) in by_path {
115+ let p = Path::new(path);
116+
117+ let old_text = read_text(store, repo, &from, p).await;
118+ let new_text = read_text(store, repo, &to, p).await;
119+
120+ // Gone from the new revision: every comment in it is orphaned and goes
121+ // to the overview timeline (spec §5 step 5).
122+ let Some(new_text) = new_text else {
123+ for a in group {
124+ set_state(db, a.id, "orphaned", None, new_revision_id).await?;
125+ summary.orphaned += 1;
126+ }
127+ continue;
128+ };
129+
130+ // Present in the new revision but unreadable in the old one — a file
131+ // added by this rewrite, or one that used to be binary. There is no
132+ // mapping to follow, so the content search inside `rebase_anchor` is the
133+ // only thing that can save the anchor. An empty old side gives it
134+ // exactly that.
135+ let old_text = old_text.unwrap_or_default();
136+
137+ let mapping = df_index::line_map(&old_text, &new_text);
138+
139+ for a in group {
140+ let outcome = rebase_anchor(a.line as u32, a.context.as_deref(), &mapping, true);
141+
142+ match outcome {
143+ AnchorOutcome::Unchanged => {
144+ // Still `current`, but the anchor now names the new
145+ // revision — that is what makes the next rebase start from
146+ // the right place, and what makes re-running idempotent.
147+ set_state(db, a.id, "current", Some(a.line), new_revision_id).await?;
148+ summary.unchanged += 1;
149+ }
150+ AnchorOutcome::Moved { new_line } => {
151+ set_state(db, a.id, "current", Some(new_line as i32), new_revision_id).await?;
152+ summary.moved += 1;
153+ }
154+ AnchorOutcome::Outdated => {
155+ // The line number is deliberately left alone: it is where
156+ // the comment was written, and moving it to a guess would
157+ // make the retained context misleading.
158+ set_state(db, a.id, "outdated", None, new_revision_id).await?;
159+ if a.state != "outdated" {
160+ summary.outdated += 1;
161+ }
162+ }
163+ AnchorOutcome::Orphaned => {
164+ set_state(db, a.id, "orphaned", None, new_revision_id).await?;
165+ summary.orphaned += 1;
166+ }
167+ }
168+ }
169+ }
170+
171+ Ok(summary)
172+}
173+
174+/// Read a file as text, or `None` if it is missing, binary, or too large.
175+async fn read_text(
176+ store: &dyn RepoStore,
177+ repo: RepoId,
178+ rev: &RevId,
179+ path: &Path,
180+) -> Option<String> {
181+ match store.read_blob(repo, rev, path).await {
182+ Ok(b) => b.text().map(str::to_owned),
183+ Err(df_store::StoreError::NoSuchPath) | Err(df_store::StoreError::IsDirectory) => None,
184+ Err(e) => {
185+ // Anything else — too large, unreadable — is treated as "cannot
186+ // follow this file", which produces an honest `orphaned` rather
187+ // than a wrong line number.
188+ tracing::debug!(?path, "reading a commented file failed: {e}");
189+ None
190+ }
191+ }
192+}
193+
194+/// Write one anchor's new state.
195+///
196+/// `anchor_context` is never touched: spec §5 step 4 says an outdated comment
197+/// retains the original text for display, and that text is the only record of
198+/// what the reviewer was looking at.
199+async fn set_state(
200+ db: &PgPool,
201+ comment_id: Uuid,
202+ state: &str,
203+ line: Option<i32>,
204+ revision_id: Uuid,
205+) -> Result<()> {
206+ sqlx::query(
207+ "UPDATE comments
208+ SET anchor_state = $2::anchor_state,
209+ anchor_line = COALESCE($3, anchor_line),
210+ anchor_revision = $4
211+ WHERE id = $1",
212+ )
213+ .bind(comment_id)
214+ .bind(state)
215+ .bind(line)
216+ .bind(revision_id)
217+ .execute(db)
218+ .await?;
219+ Ok(())
220+}
Acrates/df-worker/src/index_push.rs579 lines+579−0
@@ −0,0 +1,579 @@
1+//! The `IndexPush` job (spec §4).
2+//!
3+//! Turns pushed Git objects into the change index. Everything here must be
4+//! **idempotent** — running it twice over the same repository produces the same
5+//! rows — because `dogfood-admin reindex` runs it against live repositories
6+//! every time indexing logic changes.
7+
8+use std::collections::{HashMap, HashSet};
9+
10+use anyhow::{Context, Result};
11+use df_db::ids::new_id;
12+use df_index::indexer::{self, ChangeState, IndexedCommit, StackEdge};
13+use df_store::{DiffOpts, RepoId, RepoStore, RevId};
14+use sqlx::PgPool;
15+use uuid::Uuid;
16+
17+/// How far back to walk from each bookmark.
18+///
19+/// Bounded so indexing a repository with a very long history cannot run
20+/// unboundedly. Older commits were indexed when they were pushed.
21+const WALK_LIMIT: usize = 1000;
22+
23+pub struct Outcome {
24+ pub changes_seen: usize,
25+ pub revisions_added: usize,
26+}
27+
28+/// Index everything reachable from a repository's bookmarks.
29+pub async fn run(
30+ db: &PgPool,
31+ store: &dyn RepoStore,
32+ repo_id: Uuid,
33+ pushed_by: Option<Uuid>,
34+) -> Result<Outcome> {
35+ let sid = RepoId(repo_id);
36+
37+ let (default_bookmark,): (String,) =
38+ sqlx::query_as("SELECT default_bookmark FROM repos WHERE id = $1")
39+ .bind(repo_id)
40+ .fetch_one(db)
41+ .await
42+ .context("loading repository")?;
43+
44+ // ── 1. walk commits reachable from every bookmark ────────────────────────
45+ let bookmarks = store.bookmarks(sid).await.context("listing bookmarks")?;
46+
47+ let mut commits: HashMap<String, IndexedCommit> = HashMap::new();
48+ for b in &bookmarks {
49+ let revs = match store.log(sid, &b.target, WALK_LIMIT).await {
50+ Ok(r) => r,
51+ Err(e) => {
52+ tracing::warn!(bookmark = %b.name, "walking bookmark failed: {e}");
53+ continue;
54+ }
55+ };
56+ for r in revs {
57+ commits.entry(r.rev.as_str().to_string()).or_insert_with(|| {
58+ IndexedCommit {
59+ rev: r.rev.as_str().to_string(),
60+ change_id: r.change_id.as_deref().and_then(df_index::ChangeId::parse),
61+ parents: r.parents.iter().map(|p| p.as_str().to_string()).collect(),
62+ author_name: r.author.name.clone(),
63+ author_email: r.author.email.clone(),
64+ authored_at: r.author.when,
65+ message: r.message.clone(),
66+ conflicted: r.conflicted,
67+ conflict_sides: r.conflict_sides.clone(),
68+ conflict_bases: r.conflict_bases.clone(),
69+ }
70+ });
71+ }
72+ }
73+
74+ // Persist the bookmarks themselves.
75+ sync_bookmarks(db, repo_id, &bookmarks).await?;
76+
77+ // ── 2. which changes are already on the default bookmark? ────────────────
78+ // A change reachable from the target has landed.
79+ let mut merged: HashSet<String> = HashSet::new();
80+ if let Some(target) = bookmarks.iter().find(|b| b.name == default_bookmark) {
81+ if let Ok(revs) = store.log(sid, &target.target, WALK_LIMIT).await {
82+ for r in revs {
83+ if let Some(c) = r.change_id {
84+ merged.insert(c);
85+ }
86+ }
87+ }
88+ }
89+
90+ // ── 3. upsert changes and revisions ──────────────────────────────────────
91+ let commit_list: Vec<IndexedCommit> = commits.into_values().collect();
92+
93+ let mut change_rows: HashMap<String, Uuid> = HashMap::new();
94+ let mut revisions_added = 0usize;
95+
96+ for c in &commit_list {
97+ // jj commits carry their change id. Plain-git commits do not, and get a
98+ // patch-derived synthetic identity instead so `git push` users are not
99+ // second-class (spec §4).
100+ let (change_id, synthetic) = match c.change_id.as_ref() {
101+ Some(id) => (id.as_str().to_string(), false),
102+ None => match synthetic_identity(store, sid, c).await {
103+ Some(id) => (id, true),
104+ None => {
105+ tracing::warn!(rev = %c.rev, "could not derive a synthetic identity; skipping");
106+ continue;
107+ }
108+ },
109+ };
110+
111+ // The root change (all-z) is jj's synthetic root, not user work.
112+ if change_id.bytes().all(|b| b == b'z') {
113+ continue;
114+ }
115+
116+ let change_uuid = upsert_change(
117+ db,
118+ repo_id,
119+ &change_id,
120+ c,
121+ &default_bookmark,
122+ merged.contains(&change_id),
123+ synthetic,
124+ )
125+ .await?;
126+
127+ change_rows.insert(change_id.clone(), change_uuid);
128+
129+ // Step 6: rebase comment anchors onto the new revision. Done here,
130+ // while the previous head is still known, rather than in a second pass
131+ // that would have to reconstruct it (spec §4).
132+ match insert_revision(db, change_uuid, c, pushed_by).await? {
133+ Inserted::Existing => {}
134+ Inserted::New { id, previous_head } => {
135+ revisions_added += 1;
136+
137+ if let Some(prev) = previous_head {
138+ match crate::anchors::rebase(
139+ db, store, sid, change_uuid, &prev, &c.rev, id,
140+ )
141+ .await
142+ {
143+ Ok(summary) if summary.touched() > 0 => {
144+ emit_event(
145+ db,
146+ repo_id,
147+ "comments.rebased",
148+ change_uuid,
149+ serde_json::json!({
150+ "moved": summary.moved,
151+ "outdated": summary.outdated,
152+ "orphaned": summary.orphaned,
153+ }),
154+ )
155+ .await;
156+ }
157+ Ok(_) => {}
158+ // A failure here must not lose the revision that was
159+ // just indexed. The comments keep their old anchors,
160+ // which are stale but not wrong about anything.
161+ Err(e) => tracing::error!(
162+ change = %change_uuid,
163+ "rebasing comment anchors failed: {e}"
164+ ),
165+ }
166+ }
167+
168+ emit_event(
169+ db,
170+ repo_id,
171+ "change.pushed",
172+ change_uuid,
173+ serde_json::json!({ "rev": c.rev }),
174+ )
175+ .await;
176+
177+ if c.conflicted {
178+ emit_event(
179+ db,
180+ repo_id,
181+ "change.conflicted",
182+ change_uuid,
183+ serde_json::json!({ "rev": c.rev }),
184+ )
185+ .await;
186+ }
187+ }
188+ }
189+ }
190+
191+ // ── 4. stack edges ───────────────────────────────────────────────────────
192+ let edges = indexer::stack_edges(&commit_list, &merged);
193+ sync_stack_edges(db, repo_id, &edges, &change_rows).await?;
194+
195+ // ── 5. notify open pages ─────────────────────────────────────────────────
196+ if let Err(e) = sqlx::query("SELECT pg_notify('dogfood_repo', $1)")
197+ .bind(repo_id.to_string())
198+ .execute(db)
199+ .await
200+ {
201+ tracing::warn!("NOTIFY failed: {e}");
202+ }
203+
204+ Ok(Outcome {
205+ changes_seen: change_rows.len(),
206+ revisions_added,
207+ })
208+}
209+
210+/// Derive a patch-based identity for a commit with no jj change id.
211+///
212+/// Returns `None` when the diff cannot be read, in which case the commit is
213+/// skipped rather than given an identity we cannot reproduce on a later run —
214+/// an unstable identity is worse than no row, because it would split one change
215+/// into a new one on every reindex.
216+async fn synthetic_identity(
217+ store: &dyn RepoStore,
218+ repo: RepoId,
219+ c: &IndexedCommit,
220+) -> Option<String> {
221+ // Bounded: a synthetic identity is not worth reading a pathological diff for.
222+ let opts = DiffOpts {
223+ context_lines: 0,
224+ max_files: 1_000,
225+ max_lines: 50_000,
226+ };
227+
228+ let diff = match store
229+ .diff_from_parent(repo, &RevId::from_stored(c.rev.clone()), opts)
230+ .await
231+ {
232+ Ok(d) => d,
233+ Err(e) => {
234+ tracing::warn!(rev = %c.rev, "diffing for synthetic identity failed: {e}");
235+ return None;
236+ }
237+ };
238+
239+ // A truncated diff would hash differently depending on where the limit fell,
240+ // so refuse rather than mint an unstable id.
241+ if diff.truncated {
242+ tracing::warn!(rev = %c.rev, "diff was truncated; not deriving a synthetic identity");
243+ return None;
244+ }
245+
246+ let patch = crate::patch_id::canonical_patch(&diff);
247+ Some(
248+ df_index::synthetic_change_id(&df_index::PatchIdentity {
249+ diff: &patch,
250+ author_email: &c.author_email,
251+ author_date: c.authored_at.timestamp(),
252+ })
253+ .as_str()
254+ .to_owned(),
255+ )
256+}
257+
258+/// Insert or update the `changes` row, returning its id.
259+async fn upsert_change(
260+ db: &PgPool,
261+ repo_id: Uuid,
262+ change_id: &str,
263+ c: &IndexedCommit,
264+ default_bookmark: &str,
265+ on_target: bool,
266+ synthetic: bool,
267+) -> Result<Uuid> {
268+ let existing: Option<(Uuid, ChangeStateSql)> = sqlx::query_as(
269+ "SELECT id, state FROM changes WHERE repo_id = $1 AND change_id = $2",
270+ )
271+ .bind(repo_id)
272+ .bind(change_id)
273+ .fetch_optional(db)
274+ .await?;
275+
276+ let title = c.summary();
277+ let description = c.message.split_once('\n').map(|(_, r)| r.trim()).unwrap_or("");
278+
279+ if let Some((id, state)) = existing {
280+ // Indexing must never overwrite an author's explicit draft/abandoned
281+ // choice (spec §4, and the decided draft behaviour).
282+ let next = indexer::next_state(state.into(), on_target);
283+
284+ sqlx::query(
285+ "UPDATE changes
286+ SET title = $2, description = $3, conflicted = $4,
287+ state = $5::change_state,
288+ merged_at = CASE WHEN $5 = 'merged' AND merged_at IS NULL
289+ THEN now() ELSE merged_at END,
290+ updated_at = now()
291+ WHERE id = $1",
292+ )
293+ .bind(id)
294+ .bind(title)
295+ .bind(description)
296+ .bind(c.conflicted)
297+ .bind(state_str(next))
298+ .execute(db)
299+ .await?;
300+
301+ return Ok(id);
302+ }
303+
304+ // New change. Allocate a per-repo display number under a row lock so two
305+ // concurrent index jobs cannot mint the same number.
306+ let mut tx = db.begin().await?;
307+
308+ let (number,): (i64,) = sqlx::query_as(
309+ "UPDATE repo_counters SET next_change = next_change + 1
310+ WHERE repo_id = $1
311+ RETURNING next_change - 1",
312+ )
313+ .bind(repo_id)
314+ .fetch_one(&mut *tx)
315+ .await
316+ .context("allocating a change number (is repo_counters seeded?)")?;
317+
318+ let id = new_id();
319+ let state = if on_target { "merged" } else { "open" };
320+
321+ // ON CONFLICT makes a concurrent insert of the same change harmless, which
322+ // is what keeps the job idempotent.
323+ let inserted: Option<(Uuid,)> = sqlx::query_as(
324+ "INSERT INTO changes (id, repo_id, change_id, number, title, description,
325+ state, conflicted, target_bookmark, synthetic, merged_at)
326+ VALUES ($1, $2, $3, $4, $5, $6, $7::change_state, $8, $9, $10,
327+ CASE WHEN $7 = 'merged' THEN now() ELSE NULL END)
328+ ON CONFLICT (repo_id, change_id) DO NOTHING
329+ RETURNING id",
330+ )
331+ .bind(id)
332+ .bind(repo_id)
333+ .bind(change_id)
334+ .bind(number)
335+ .bind(title)
336+ .bind(description)
337+ .bind(state)
338+ .bind(c.conflicted)
339+ .bind(default_bookmark)
340+ .bind(synthetic)
341+ .fetch_optional(&mut *tx)
342+ .await?;
343+
344+ tx.commit().await?;
345+
346+ match inserted {
347+ Some((id,)) => {
348+ // Timeline event for a newly seen change.
349+ let _ = sqlx::query(
350+ "INSERT INTO events (id, repo_id, kind, subject_type, subject_id, payload)
351+ VALUES ($1, $2, 'change.opened', 'change', $3, $4)",
352+ )
353+ .bind(new_id())
354+ .bind(repo_id)
355+ .bind(id)
356+ .bind(serde_json::json!({ "change_id": change_id }))
357+ .execute(db)
358+ .await;
359+ Ok(id)
360+ }
361+ None => {
362+ // Lost the race; read back the winner.
363+ let (id,): (Uuid,) =
364+ sqlx::query_as("SELECT id FROM changes WHERE repo_id = $1 AND change_id = $2")
365+ .bind(repo_id)
366+ .bind(change_id)
367+ .fetch_one(db)
368+ .await?;
369+ Ok(id)
370+ }
371+ }
372+}
373+
374+/// What appending a revision did.
375+enum Inserted {
376+ /// Already present. The indexer is re-runnable, so this is the normal case
377+ /// on a reindex and must have no side effects at all.
378+ Existing,
379+ New {
380+ /// Row id of the revision just written.
381+ id: Uuid,
382+ /// The revision that was the change's head before this one, when there
383+ /// was one. This is what comment anchors are rebased *from*.
384+ previous_head: Option<String>,
385+ },
386+}
387+
388+/// Append a revision.
389+async fn insert_revision(
390+ db: &PgPool,
391+ change_uuid: Uuid,
392+ c: &IndexedCommit,
393+ pushed_by: Option<Uuid>,
394+) -> Result<Inserted> {
395+ // Read before inserting: after the insert the new revision *is* the head.
396+ let previous_head: Option<String> = sqlx::query_scalar(
397+ "SELECT rev FROM revisions WHERE change_id_fk = $1 ORDER BY seq DESC LIMIT 1",
398+ )
399+ .bind(change_uuid)
400+ .fetch_optional(db)
401+ .await?;
402+
403+ let conflict_data = c.conflicted.then(|| {
404+ serde_json::json!({
405+ "sides": c.conflict_sides,
406+ "bases": c.conflict_bases,
407+ })
408+ });
409+
410+ // seq is per-change and 1-based. Computing it from the existing max keeps
411+ // re-runs stable: an already-present revision hits the conflict clause and
412+ // does not consume a number.
413+ let inserted: Option<(Uuid,)> = sqlx::query_as(
414+ "INSERT INTO revisions (id, change_id_fk, rev, seq, parents, author_name,
415+ author_email, authored_at, message, conflicted,
416+ conflict_data, pushed_by)
417+ SELECT $1, $2, $3,
418+ COALESCE((SELECT MAX(seq) FROM revisions WHERE change_id_fk = $2), 0) + 1,
419+ $4, $5, $6, $7, $8, $9, $10, $11
420+ ON CONFLICT (change_id_fk, rev) DO NOTHING
421+ RETURNING id",
422+ )
423+ .bind(new_id())
424+ .bind(change_uuid)
425+ .bind(&c.rev)
426+ .bind(&c.parents)
427+ .bind(&c.author_name)
428+ .bind(&c.author_email)
429+ .bind(c.authored_at)
430+ .bind(&c.message)
431+ .bind(c.conflicted)
432+ .bind(conflict_data)
433+ .bind(pushed_by)
434+ .fetch_optional(db)
435+ .await?;
436+
437+ let Some((rev_uuid,)) = inserted else {
438+ return Ok(Inserted::Existing);
439+ };
440+
441+ // The head is the most recently pushed revision of the change.
442+ sqlx::query("UPDATE changes SET head_revision_id = $2, updated_at = now() WHERE id = $1")
443+ .bind(change_uuid)
444+ .bind(rev_uuid)
445+ .execute(db)
446+ .await?;
447+
448+ Ok(Inserted::New { id: rev_uuid, previous_head })
449+}
450+
451+/// Write a timeline event (spec §4 step 8).
452+///
453+/// Best-effort and unauthored: these events describe what a *push* did, and the
454+/// pusher is recorded on the revision itself. Attributing them to a user here
455+/// would claim more than the indexer knows — a push can carry somebody else's
456+/// commits.
457+async fn emit_event(
458+ db: &PgPool,
459+ repo_id: Uuid,
460+ kind: &str,
461+ change_uuid: Uuid,
462+ payload: serde_json::Value,
463+) {
464+ if let Err(e) = sqlx::query(
465+ "INSERT INTO events (id, repo_id, kind, subject_type, subject_id, payload)
466+ VALUES ($1, $2, $3, 'change', $4, $5)",
467+ )
468+ .bind(new_id())
469+ .bind(repo_id)
470+ .bind(kind)
471+ .bind(change_uuid)
472+ .bind(payload)
473+ .execute(db)
474+ .await
475+ {
476+ tracing::error!(%kind, "writing a timeline event failed: {e}");
477+ }
478+}
479+
480+async fn sync_bookmarks(db: &PgPool, repo_id: Uuid, marks: &[df_store::Bookmark]) -> Result<()> {
481+ for b in marks {
482+ // `protected` is set for the default bookmark and otherwise left alone:
483+ // it is an operator's choice, and re-asserting it on every push would
484+ // undo an unprotect. Marking the default keeps the settings page in
485+ // step with the hook, which protects it whether or not the flag is set.
486+ sqlx::query(
487+ "INSERT INTO bookmarks (repo_id, name, target, protected, updated_at)
488+ SELECT $1, $2, $3, (r.default_bookmark = $2), now()
489+ FROM repos r WHERE r.id = $1
490+ ON CONFLICT (repo_id, name)
491+ DO UPDATE SET target = EXCLUDED.target,
492+ protected = bookmarks.protected OR EXCLUDED.protected,
493+ updated_at = now()",
494+ )
495+ .bind(repo_id)
496+ .bind(&b.name)
497+ .bind(b.target.as_str())
498+ .execute(db)
499+ .await?;
500+ }
501+
502+ // Drop bookmarks that no longer exist, so a deleted branch disappears.
503+ let names: Vec<String> = marks.iter().map(|b| b.name.clone()).collect();
504+ sqlx::query("DELETE FROM bookmarks WHERE repo_id = $1 AND NOT (name = ANY($2))")
505+ .bind(repo_id)
506+ .bind(&names)
507+ .execute(db)
508+ .await?;
509+
510+ Ok(())
511+}
512+
513+/// Replace this repository's stack edges with the freshly computed set.
514+///
515+/// Deleting and reinserting is what makes the job idempotent: a rebase that
516+/// removes an edge must actually remove it, which an insert-only pass cannot do.
517+async fn sync_stack_edges(
518+ db: &PgPool,
519+ repo_id: Uuid,
520+ edges: &HashSet<StackEdge>,
521+ change_rows: &HashMap<String, Uuid>,
522+) -> Result<()> {
523+ let mut tx = db.begin().await?;
524+
525+ sqlx::query("DELETE FROM change_edges WHERE repo_id = $1")
526+ .bind(repo_id)
527+ .execute(&mut *tx)
528+ .await?;
529+
530+ for e in edges {
531+ let (Some(parent), Some(child)) = (change_rows.get(&e.parent), change_rows.get(&e.child))
532+ else {
533+ continue;
534+ };
535+ sqlx::query(
536+ "INSERT INTO change_edges (repo_id, parent_change, child_change)
537+ VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
538+ )
539+ .bind(repo_id)
540+ .bind(parent)
541+ .bind(child)
542+ .execute(&mut *tx)
543+ .await?;
544+ }
545+
546+ tx.commit().await?;
547+ Ok(())
548+}
549+
550+// ─── enum bridging ───────────────────────────────────────────────────────────
551+
552+#[derive(sqlx::Type, Clone, Copy)]
553+#[sqlx(type_name = "change_state", rename_all = "lowercase")]
554+enum ChangeStateSql {
555+ Draft,
556+ Open,
557+ Merged,
558+ Abandoned,
559+}
560+
561+impl From<ChangeStateSql> for ChangeState {
562+ fn from(s: ChangeStateSql) -> Self {
563+ match s {
564+ ChangeStateSql::Draft => ChangeState::Draft,
565+ ChangeStateSql::Open => ChangeState::Open,
566+ ChangeStateSql::Merged => ChangeState::Merged,
567+ ChangeStateSql::Abandoned => ChangeState::Abandoned,
568+ }
569+ }
570+}
571+
572+fn state_str(s: ChangeState) -> &'static str {
573+ match s {
574+ ChangeState::Draft => "draft",
575+ ChangeState::Open => "open",
576+ ChangeState::Merged => "merged",
577+ ChangeState::Abandoned => "abandoned",
578+ }
579+}
Acrates/df-worker/src/main.rs+246−0
@@ −0,0 +1,246 @@
1+//! `dogfood-worker` — the job runner (spec §5).
2+//!
3+//! Jobs are claimed with `SELECT … FOR UPDATE SKIP LOCKED`. A `NOTIFY` on
4+//! insert wakes the worker so it does not poll on the hot path; a 5-second poll
5+//! remains as a fallback for missed notifications.
6+
7+use std::sync::Arc;
8+use std::time::Duration;
9+
10+use anyhow::{Context, Result};
11+use df_store::RepoStore;
12+use sqlx::postgres::PgListener;
13+use sqlx::PgPool;
14+use uuid::Uuid;
15+
16+mod anchors;
17+mod index_push;
18+mod patch_id;
19+
20+/// Identifies this worker in `jobs.locked_by`, so a stuck job can be traced
21+/// back to the process that took it.
22+fn worker_id() -> String {
23+ format!(
24+ "{}-{}",
25+ std::env::var("HOSTNAME").unwrap_or_else(|_| "worker".into()),
26+ std::process::id()
27+ )
28+}
29+
30+#[tokio::main]
31+async fn main() -> Result<()> {
32+ let _ = dotenvy::dotenv();
33+ init_tracing();
34+
35+ let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
36+ let repo_root = std::env::var("REPO_ROOT").unwrap_or_else(|_| "/srv/repos".into());
37+ let max_conn = std::env::var("DATABASE_MAX_CONNECTIONS")
38+ .ok()
39+ .and_then(|v| v.parse().ok())
40+ .unwrap_or(5);
41+
42+ let db = df_db::connect(&database_url, max_conn)
43+ .await
44+ .context("connecting to the database")?;
45+
46+ // The worker does NOT run migrations — `web` owns that (spec §10), and two
47+ // processes racing to migrate is exactly what the advisory lock exists to
48+ // prevent. If the schema is behind, the queries below fail loudly.
49+ let store: Arc<dyn RepoStore> = Arc::new(df_store::GitStore::new(&repo_root));
50+
51+ let me = worker_id();
52+ tracing::info!(worker = %me, root = %repo_root, "dogfood-worker started");
53+
54+ // LISTEN for wake-ups. If the listener cannot be established the worker
55+ // still functions on the poll fallback, so this is a warning, not fatal.
56+ let mut listener = match PgListener::connect(&database_url).await {
57+ Ok(mut l) => match l.listen("dogfood_jobs").await {
58+ Ok(()) => Some(l),
59+ Err(e) => {
60+ tracing::warn!("LISTEN failed, falling back to polling: {e}");
61+ None
62+ }
63+ },
64+ Err(e) => {
65+ tracing::warn!("could not open a listener, falling back to polling: {e}");
66+ None
67+ }
68+ };
69+
70+ let mut shutdown = std::pin::pin!(shutdown_signal());
71+
72+ loop {
73+ // Drain everything currently runnable before sleeping.
74+ loop {
75+ match claim_and_run(&db, store.as_ref(), &me).await {
76+ Ok(true) => continue,
77+ Ok(false) => break,
78+ Err(e) => {
79+ tracing::error!("job loop error: {e:#}");
80+ break;
81+ }
82+ }
83+ }
84+
85+ // Wait for a notification, the poll interval, or shutdown.
86+ let wake = async {
87+ match listener.as_mut() {
88+ Some(l) => {
89+ let _ = l.recv().await;
90+ }
91+ None => std::future::pending::<()>().await,
92+ }
93+ };
94+
95+ tokio::select! {
96+ _ = wake => {}
97+ _ = tokio::time::sleep(Duration::from_secs(5)) => {}
98+ _ = &mut shutdown => {
99+ tracing::info!("shutting down");
100+ return Ok(());
101+ }
102+ }
103+ }
104+}
105+
106+/// Claim one job and run it. Returns whether a job was found.
107+async fn claim_and_run(db: &PgPool, store: &dyn RepoStore, me: &str) -> Result<bool> {
108+ let mut tx = db.begin().await?;
109+
110+ // SKIP LOCKED lets several workers share the queue without blocking.
111+ let job: Option<(Uuid, String, serde_json::Value, i32, i32)> = sqlx::query_as(
112+ "SELECT id, kind, payload, attempts, max_attempts
113+ FROM jobs
114+ WHERE locked_at IS NULL AND run_at <= now()
115+ ORDER BY run_at
116+ FOR UPDATE SKIP LOCKED
117+ LIMIT 1",
118+ )
119+ .fetch_optional(&mut *tx)
120+ .await?;
121+
122+ let Some((id, kind, payload, attempts, max_attempts)) = job else {
123+ tx.rollback().await?;
124+ return Ok(false);
125+ };
126+
127+ sqlx::query("UPDATE jobs SET locked_at = now(), locked_by = $2, attempts = attempts + 1 WHERE id = $1")
128+ .bind(id)
129+ .bind(me)
130+ .execute(&mut *tx)
131+ .await?;
132+ tx.commit().await?;
133+
134+ tracing::info!(job = %id, %kind, attempt = attempts + 1, "running job");
135+ let started = std::time::Instant::now();
136+
137+ let result = match kind.as_str() {
138+ "index_push" => run_index_push(db, store, &payload).await,
139+ other => Err(anyhow::anyhow!("unknown job kind: {other}")),
140+ };
141+
142+ match result {
143+ Ok(()) => {
144+ sqlx::query("DELETE FROM jobs WHERE id = $1")
145+ .bind(id)
146+ .execute(db)
147+ .await?;
148+ tracing::info!(job = %id, ms = started.elapsed().as_millis(), "job complete");
149+ }
150+ Err(e) => {
151+ let attempts_now = attempts + 1;
152+ let give_up = attempts_now >= max_attempts;
153+ tracing::error!(job = %id, attempt = attempts_now, "job failed: {e:#}");
154+
155+ if give_up {
156+ // Leave the row, unlocked and exhausted, so it is visible for
157+ // inspection rather than silently vanishing.
158+ sqlx::query(
159+ "UPDATE jobs SET locked_at = NULL, locked_by = NULL, last_error = $2
160+ WHERE id = $1",
161+ )
162+ .bind(id)
163+ .bind(format!("{e:#}"))
164+ .execute(db)
165+ .await?;
166+ tracing::error!(job = %id, "job exhausted its attempts");
167+ } else {
168+ // Exponential backoff, capped.
169+ let delay = Duration::from_secs(2u64.saturating_pow(attempts_now as u32).min(300));
170+ sqlx::query(
171+ "UPDATE jobs SET locked_at = NULL, locked_by = NULL, last_error = $2,
172+ run_at = now() + $3
173+ WHERE id = $1",
174+ )
175+ .bind(id)
176+ .bind(format!("{e:#}"))
177+ .bind(delay)
178+ .execute(db)
179+ .await?;
180+ }
181+ }
182+ }
183+
184+ Ok(true)
185+}
186+
187+async fn run_index_push(
188+ db: &PgPool,
189+ store: &dyn RepoStore,
190+ payload: &serde_json::Value,
191+) -> Result<()> {
192+ let repo_id: Uuid = payload
193+ .get("repo_id")
194+ .and_then(|v| v.as_str())
195+ .and_then(|s| s.parse().ok())
196+ .context("index_push payload is missing a valid repo_id")?;
197+
198+ let pushed_by: Option<Uuid> = payload
199+ .get("pushed_by")
200+ .and_then(|v| v.as_str())
201+ .and_then(|s| s.parse().ok());
202+
203+ let outcome = index_push::run(db, store, repo_id, pushed_by).await?;
204+ tracing::info!(
205+ repo = %repo_id,
206+ changes = outcome.changes_seen,
207+ revisions = outcome.revisions_added,
208+ "indexed push"
209+ );
210+ Ok(())
211+}
212+
213+fn init_tracing() {
214+ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
215+
216+ let filter = EnvFilter::try_from_default_env()
217+ .unwrap_or_else(|_| EnvFilter::new("info,df_worker=debug,df_index=debug"));
218+ let json = !std::io::IsTerminal::is_terminal(&std::io::stdout());
219+
220+ let registry = tracing_subscriber::registry().with(filter);
221+ if json {
222+ registry.with(tracing_subscriber::fmt::layer().json()).init();
223+ } else {
224+ registry.with(tracing_subscriber::fmt::layer()).init();
225+ }
226+}
227+
228+async fn shutdown_signal() {
229+ let ctrl_c = async {
230+ tokio::signal::ctrl_c().await.expect("Ctrl+C handler");
231+ };
232+ #[cfg(unix)]
233+ let terminate = async {
234+ tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
235+ .expect("SIGTERM handler")
236+ .recv()
237+ .await;
238+ };
239+ #[cfg(not(unix))]
240+ let terminate = std::future::pending::<()>();
241+
242+ tokio::select! {
243+ _ = ctrl_c => {}
244+ _ = terminate => {}
245+ }
246+}
Acrates/df-worker/src/patch_id.rs+208−0
@@ −0,0 +1,208 @@
1+//! Patch identity for commits authored by plain git (spec §4).
2+//!
3+//! "Commits without a change ID still need identity so that `git push` users
4+//! are not second-class. Synthesize a stable identity from the commit's
5+//! *patch* rather than its SHA — a patch-id style digest over the diff plus the
6+//! author date — so that a rebase of the same work continues to map to the
7+//! same change."
8+//!
9+//! The serialisation below is the contract: two commits produce the same
10+//! identity exactly when this byte stream matches. It deliberately excludes
11+//! everything a rebase changes — blob OIDs, hunk offsets, parent ids, the
12+//! commit id, and the committer.
13+
14+use df_store::{ChangeKind, Diff};
15+
16+/// Render a diff into a canonical byte form for hashing.
17+///
18+/// Files are sorted by path so that a change in tree-walk order cannot alter
19+/// the identity. Hunk line numbers are omitted entirely — only the sequence of
20+/// added and removed content matters, which is what makes the digest survive a
21+/// rebase that shifts everything down by ten lines.
22+pub fn canonical_patch(diff: &Diff) -> Vec<u8> {
23+ let mut files: Vec<&df_store::FileDiff> = diff.files.iter().collect();
24+ files.sort_by(|a, b| a.path.cmp(&b.path));
25+
26+ let mut out = Vec::new();
27+ for f in files {
28+ out.extend_from_slice(b"--- file ");
29+ out.extend_from_slice(f.path.as_bytes());
30+ out.push(b'\n');
31+
32+ // A rename with no content change must still be a distinct patch from
33+ // no change at all, so the kind is part of the identity.
34+ out.extend_from_slice(match f.kind {
35+ ChangeKind::Added => b"kind added\n".as_slice(),
36+ ChangeKind::Deleted => b"kind deleted\n".as_slice(),
37+ ChangeKind::Modified => b"kind modified\n".as_slice(),
38+ ChangeKind::Renamed => b"kind renamed\n".as_slice(),
39+ });
40+ if let Some(old) = &f.old_path {
41+ out.extend_from_slice(b"from ");
42+ out.extend_from_slice(old.as_bytes());
43+ out.push(b'\n');
44+ }
45+
46+ if f.binary {
47+ // Binary content is not diffed; record only that it changed, so the
48+ // identity stays stable without reading megabytes.
49+ out.extend_from_slice(b"binary\n");
50+ continue;
51+ }
52+
53+ for h in &f.hunks {
54+ // NOTE: no @@ header. Hunk offsets move under a rebase while the
55+ // work stays the same, and including them would defeat the purpose.
56+ for l in &h.lines {
57+ match l.kind {
58+ df_store::DiffLineKind::Added => out.push(b'+'),
59+ df_store::DiffLineKind::Deleted => out.push(b'-'),
60+ // Context lines shift with surrounding edits, so they are
61+ // excluded: only the actual change identifies the patch.
62+ df_store::DiffLineKind::Context => continue,
63+ }
64+ out.extend_from_slice(l.content.as_bytes());
65+ out.push(b'\n');
66+ }
67+ }
68+ }
69+ out
70+}
71+
72+#[cfg(test)]
73+mod tests {
74+ use super::*;
75+ use df_store::{DiffLine, DiffLineKind, FileDiff, Hunk};
76+
77+ fn line(kind: DiffLineKind, content: &str) -> DiffLine {
78+ DiffLine {
79+ kind,
80+ old_lineno: None,
81+ new_lineno: None,
82+ content: content.into(),
83+ // The canonical patch is line-based; the intra-line spans are a
84+ // rendering concern and must not affect the synthetic identity.
85+ // Populated here anyway so the fixture matches what the store
86+ // actually produces.
87+ spans: vec![df_store::DiffSpan { text: content.into(), emphasis: false }],
88+ }
89+ }
90+
91+ fn file(path: &str, lines: Vec<DiffLine>) -> FileDiff {
92+ FileDiff {
93+ path: path.into(),
94+ old_path: None,
95+ kind: ChangeKind::Modified,
96+ binary: false,
97+ additions: 0,
98+ deletions: 0,
99+ hunks: vec![Hunk {
100+ old_start: 1,
101+ old_lines: 1,
102+ new_start: 1,
103+ new_lines: 1,
104+ lines,
105+ }],
106+ }
107+ }
108+
109+ fn diff(files: Vec<FileDiff>) -> Diff {
110+ Diff {
111+ files,
112+ truncated: false,
113+ total_additions: 0,
114+ total_deletions: 0,
115+ }
116+ }
117+
118+ #[test]
119+ fn identical_patches_serialise_identically() {
120+ let a = diff(vec![file("a.txt", vec![line(DiffLineKind::Added, "hello")])]);
121+ let b = diff(vec![file("a.txt", vec![line(DiffLineKind::Added, "hello")])]);
122+ assert_eq!(canonical_patch(&a), canonical_patch(&b));
123+ }
124+
125+ #[test]
126+ fn context_lines_do_not_affect_identity() {
127+ // The property that makes this survive a rebase: surrounding code moved
128+ // or changed, but the edit itself is the same.
129+ let a = diff(vec![file(
130+ "a.txt",
131+ vec![
132+ line(DiffLineKind::Context, "fn main() {"),
133+ line(DiffLineKind::Added, "hello"),
134+ ],
135+ )]);
136+ let b = diff(vec![file(
137+ "a.txt",
138+ vec![
139+ line(DiffLineKind::Context, "fn completely_different() {"),
140+ line(DiffLineKind::Added, "hello"),
141+ ],
142+ )]);
143+ assert_eq!(canonical_patch(&a), canonical_patch(&b));
144+ }
145+
146+ #[test]
147+ fn file_order_does_not_affect_identity() {
148+ let a = diff(vec![
149+ file("a.txt", vec![line(DiffLineKind::Added, "x")]),
150+ file("b.txt", vec![line(DiffLineKind::Added, "y")]),
151+ ]);
152+ let b = diff(vec![
153+ file("b.txt", vec![line(DiffLineKind::Added, "y")]),
154+ file("a.txt", vec![line(DiffLineKind::Added, "x")]),
155+ ]);
156+ assert_eq!(canonical_patch(&a), canonical_patch(&b));
157+ }
158+
159+ #[test]
160+ fn different_content_differs() {
161+ let a = diff(vec![file("a.txt", vec![line(DiffLineKind::Added, "hello")])]);
162+ let b = diff(vec![file("a.txt", vec![line(DiffLineKind::Added, "world")])]);
163+ assert_ne!(canonical_patch(&a), canonical_patch(&b));
164+ }
165+
166+ #[test]
167+ fn an_addition_differs_from_a_deletion_of_the_same_text() {
168+ let a = diff(vec![file("a.txt", vec![line(DiffLineKind::Added, "x")])]);
169+ let b = diff(vec![file("a.txt", vec![line(DiffLineKind::Deleted, "x")])]);
170+ assert_ne!(canonical_patch(&a), canonical_patch(&b));
171+ }
172+
173+ #[test]
174+ fn the_same_edit_in_a_different_file_differs() {
175+ let a = diff(vec![file("a.txt", vec![line(DiffLineKind::Added, "x")])]);
176+ let b = diff(vec![file("b.txt", vec![line(DiffLineKind::Added, "x")])]);
177+ assert_ne!(canonical_patch(&a), canonical_patch(&b));
178+ }
179+
180+ #[test]
181+ fn a_rename_is_distinguishable_from_a_modification() {
182+ let mut renamed = file("new.txt", vec![]);
183+ renamed.kind = ChangeKind::Renamed;
184+ renamed.old_path = Some("old.txt".into());
185+
186+ let modified = file("new.txt", vec![]);
187+
188+ assert_ne!(
189+ canonical_patch(&diff(vec![renamed])),
190+ canonical_patch(&diff(vec![modified]))
191+ );
192+ }
193+
194+ #[test]
195+ fn binary_files_are_recorded_without_their_content() {
196+ let mut f = file("image.png", vec![]);
197+ f.binary = true;
198+ let bytes = canonical_patch(&diff(vec![f]));
199+ let text = String::from_utf8_lossy(&bytes);
200+ assert!(text.contains("binary"));
201+ assert!(text.contains("image.png"));
202+ }
203+
204+ #[test]
205+ fn an_empty_diff_yields_an_empty_patch() {
206+ assert!(canonical_patch(&diff(vec![])).is_empty());
207+ }
208+}
Adocker/Dockerfile+103−0
@@ −0,0 +1,103 @@
1+# Dogfood — multi-stage build.
2+#
3+# One builder stage produces every binary; each runtime target copies just the
4+# one it needs, so the three images share all their build layers (spec §10).
5+
6+# ─── editor bundle ───────────────────────────────────────────────────────────
7+# CodeMirror is ESM and has to be bundled. Node lives in this stage only — the
8+# runtime images below copy a single .js file and never see a package manager,
9+# which is the objection spec §1 raises against a Node toolchain, not to
10+# bundling as such.
11+FROM node:22-alpine AS editor
12+WORKDIR /editor
13+COPY crates/df-web/assets/editor/package.json ./
14+RUN npm install --no-audit --no-fund --silent
15+COPY crates/df-web/assets/editor/editor.js ./
16+RUN npx esbuild editor.js --bundle --minify --format=iife --target=es2020 \
17+ --outfile=/editor/editor.min.js \
18+ && ls -la /editor/editor.min.js
19+
20+# ─── planner: cache dependency compilation ───────────────────────────────────
21+FROM rust:1-slim-bookworm AS chef
22+WORKDIR /build
23+# `build-essential` is needed for the tree-sitter grammars, which are C
24+# libraries compiled into df-render (spec §8).
25+RUN apt-get update \
26+ && apt-get install -y --no-install-recommends pkg-config libssl-dev build-essential \
27+ && rm -rf /var/lib/apt/lists/*
28+RUN cargo install cargo-chef --locked
29+
30+FROM chef AS planner
31+COPY Cargo.toml Cargo.lock* ./
32+COPY crates ./crates
33+COPY migrations ./migrations
34+RUN cargo chef prepare --recipe-path recipe.json
35+
36+# ─── builder ─────────────────────────────────────────────────────────────────
37+FROM chef AS builder
38+COPY --from=planner /build/recipe.json recipe.json
39+# Dependencies are built once and cached; only workspace source changes below
40+# this line invalidate the layer.
41+RUN cargo chef cook --release --recipe-path recipe.json
42+
43+COPY Cargo.toml Cargo.lock* ./
44+COPY crates ./crates
45+# `sqlx::migrate!` embeds these at compile time, so they must be present.
46+COPY migrations ./migrations
47+# …and so is the editor bundle, by `include_str!`. It has to land before the
48+# build, not after.
49+COPY --from=editor /editor/editor.min.js ./crates/df-web/assets/editor.min.js
50+RUN cargo build --release --bin dogfood-web --bin dogfood-worker --bin dogfood-admin --bin dogfood-hook --bin dogfood-ssh
51+
52+# ─── runtime base ────────────────────────────────────────────────────────────
53+FROM debian:bookworm-slim AS runtime-base
54+RUN apt-get update \
55+ && apt-get install -y --no-install-recommends ca-certificates git wget \
56+ && rm -rf /var/lib/apt/lists/* \
57+ && useradd --system --create-home --uid 10001 dogfood \
58+ && mkdir -p /srv/repos \
59+ && chown dogfood:dogfood /srv/repos
60+USER dogfood
61+WORKDIR /app
62+
63+# ─── web ─────────────────────────────────────────────────────────────────────
64+FROM runtime-base AS web
65+COPY --from=builder /build/target/release/dogfood-web /usr/local/bin/dogfood-web
66+# Installed into each repository's hooks/ directory at creation.
67+COPY --from=builder /build/target/release/dogfood-hook /usr/local/bin/dogfood-hook
68+EXPOSE 8080
69+# The container is healthy when it can serve; readiness (database reachable) is
70+# checked separately by the orchestrator via /readyz.
71+HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
72+ CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1
73+ENTRYPOINT ["/usr/local/bin/dogfood-web"]
74+
75+# ─── worker ──────────────────────────────────────────────────────────────────
76+FROM runtime-base AS worker
77+COPY --from=builder /build/target/release/dogfood-worker /usr/local/bin/dogfood-worker
78+# The admin CLI ships in the worker image so operational commands run where the
79+# repository volume is mounted.
80+COPY --from=builder /build/target/release/dogfood-admin /usr/local/bin/dogfood-admin
81+COPY --from=builder /build/target/release/dogfood-hook /usr/local/bin/dogfood-hook
82+ENTRYPOINT ["/usr/local/bin/dogfood-worker"]
83+
84+# ─── ssh ─────────────────────────────────────────────────────────────────────
85+# Git over SSH (spec §6, §9). Public-key auth only, and `dogfood-ssh` dispatches
86+# nothing but `git-upload-pack` and `git-receive-pack` — no shell, no PTY, no
87+# forwarding — so the image needs `git` and nothing else.
88+FROM runtime-base AS ssh
89+# The host-key directory must exist in the *image*, owned by the runtime user.
90+# Docker seeds a named volume from the image path it is mounted at, including
91+# ownership — an empty volume on a path that does not exist comes up owned by
92+# root, and the process (uid 10001) then cannot write its host key.
93+USER root
94+RUN mkdir -p /etc/dogfood && chown dogfood:dogfood /etc/dogfood
95+USER dogfood
96+COPY --from=builder /build/target/release/dogfood-ssh /usr/local/bin/dogfood-ssh
97+# Installed into each repository's hooks/ directory; the SSH path receives
98+# pushes and must validate them the same way HTTPS does.
99+COPY --from=builder /build/target/release/dogfood-hook /usr/local/bin/dogfood-hook
100+# The host sshd owns :22 on this deployment, so the container listens on 2222
101+# and is published there too — clone URLs carry the explicit port.
102+EXPOSE 2222
103+ENTRYPOINT ["/usr/local/bin/dogfood-ssh"]
Adocker/compose.yaml+93−0
@@ −0,0 +1,93 @@
1+# Dogfood — deployment.
2+#
3+# Deviations from spec §10, both forced by the environment:
4+#
5+# * No `postgres` service. The database is the externally provided instance in
6+# .env (PostgreSQL 17 behind a TLS proxy on :6543).
7+# * No `caddy` service. TLS and routing are already handled by the existing
8+# caddy-proxy-manager edge (compose project `edge`), which owns :80/:443 on
9+# this host. Dogfood joins its network and the edge proxies to it by
10+# container name.
11+
12+name: dogfood
13+
14+services:
15+ web:
16+ build:
17+ context: ..
18+ dockerfile: docker/Dockerfile
19+ target: web
20+ container_name: dogfood-web
21+ env_file: ../.env
22+ environment:
23+ BIND: 0.0.0.0:8080
24+ volumes:
25+ - repos:/srv/repos
26+ networks:
27+ - default
28+ # Reachable from the edge proxy for TLS termination at dogfood.sh.
29+ - edge_caddy-network
30+ restart: unless-stopped
31+ # Repository storage is the only thing on disk; everything else is in
32+ # Postgres. Read-only root keeps a compromised process from rewriting the
33+ # binary or dropping a hook script.
34+ read_only: true
35+ tmpfs:
36+ - /tmp
37+ security_opt:
38+ - no-new-privileges:true
39+
40+ # Git over SSH. Published on 2222 because the host sshd owns :22 (spec §10
41+ # assumes otherwise; the environment does not allow it), so clone URLs are
42+ # ssh://git@dogfood.sh:2222/owner/repo.git.
43+ ssh:
44+ build:
45+ context: ..
46+ dockerfile: docker/Dockerfile
47+ target: ssh
48+ container_name: dogfood-ssh
49+ env_file: ../.env
50+ environment:
51+ SSH_BIND: 0.0.0.0:2222
52+ SSH_HOST_KEY_PATH: /etc/dogfood/ssh_host_ed25519_key
53+ ports:
54+ - "2222:2222"
55+ volumes:
56+ - repos:/srv/repos
57+ # The host key must survive a redeploy or every client reports a changed
58+ # host key, which is indistinguishable from an attack.
59+ - ssh_keys:/etc/dogfood
60+ networks:
61+ - default
62+ restart: unless-stopped
63+ read_only: true
64+ tmpfs:
65+ - /tmp
66+ security_opt:
67+ - no-new-privileges:true
68+
69+ worker:
70+ build:
71+ context: ..
72+ dockerfile: docker/Dockerfile
73+ target: worker
74+ container_name: dogfood-worker
75+ env_file: ../.env
76+ volumes:
77+ - repos:/srv/repos
78+ networks:
79+ - default
80+ restart: unless-stopped
81+ read_only: true
82+ tmpfs:
83+ - /tmp
84+ security_opt:
85+ - no-new-privileges:true
86+
87+volumes:
88+ repos:
89+ ssh_keys:
90+
91+networks:
92+ edge_caddy-network:
93+ external: true
Adocs/change-id-format.md+154−0
@@ −0,0 +1,154 @@
1+# jj change-id on the Git backend — verified format
2+
3+**Status:** RESOLVED. This closes spec §13 open question 1, the hard prerequisite for M1.
4+
5+**Method:** empirical, against real repositories produced by the `jj` CLI
6+(`jj 0.43.0-89f62ede8c1c611eaf134c0c49252efd65c7945d`, x86_64-unknown-linux-musl).
7+No commit objects were hand-authored. Every claim below is a direct `git cat-file commit`
8+observation, and the reproduction steps live in `fixtures/gen.sh`.
9+
10+---
11+
12+## 1. The header
13+
14+```
15+change-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu
16+```
17+
18+- **Key:** `change-id` — lowercase, hyphenated. Not `Change-Id`, not `jj:change-id`.
19+- **Value:** 32 characters, stored **in jj's reverse-hex letter encoding** — the exact
20+ string the CLI displays. It is *not* stored as hex and rendered as letters.
21+
22+This is the single most important finding, and it is the opposite of what the spec
23+anticipated ("the CLI's letter form is a presentation encoding of the stored bytes").
24+On this backend the stored bytes **are** the letter form.
25+
26+**Consequence:** the canonical encoding for `changes.change_id` is the letter string
27+exactly as it arrives on the wire. Do not hex-decode on ingest and re-encode for display —
28+that round-trip is pure risk with no benefit, and any mismatch means users cannot
29+cross-reference the UI against their terminal.
30+
31+The alphabet is the reverse-hex mapping `0123456789abcdef` → `zyxwvutsrqponmlk`.
32+Corroborated by the root change, which jj displays as `zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz`
33+against commit id `0000000000000000000000000000000000000000`.
34+
35+## 2. Header position is NOT fixed — parse properly
36+
37+Two observed layouts place `change-id` in different positions:
38+
39+Signed commit — `change-id` *before* `gpgsig`:
40+```
41+tree ec67420ed747b72ce94854190b4c59deff01b9db
42+author Fixture Author <fixture@dogfood.sh> 1785482470 +0000
43+committer Fixture Author <fixture@dogfood.sh> 1785482470 +0000
44+change-id rwsosmnkorymmtvvzwkuuzzqqmuonwnn
45+gpgsig -----BEGIN SSH SIGNATURE-----
46+ U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgpDlDGOvAOfzKu8EtSuCO+q7OMl
47+ ...
48+ -----END SSH SIGNATURE-----
49+
50+signed via jj
51+```
52+
53+Conflicted commit — `change-id` *after* two other `jj:` headers, one of which is multi-line:
54+```
55+tree 125bc2eed773e5eacbb11544e9966dab850ed4f9
56+parent 8fa0e2bff61fc63eb09ade2565647e3bda6e8cbf
57+parent e2263f3f9f90bbfac26edb8e43b348bc9ab32e99
58+author Fixture Author <fixture@dogfood.sh> 1785482444 +0000
59+committer Fixture Author <fixture@dogfood.sh> 1785482444 +0000
60+jj:conflict-labels vtrtrnpx 8fa0e2bf "sideA"
61+ wokvnsmz ebc596ab "base"
62+ lvtnxnzk e2263f3f "sideB"
63+jj:trees ada380c77b2f1923458d0b93cbfee42f297a58e9 892cad2b4d1f22ae36e2107a6a187a5dc227f965 14a356d6227b63a22f94c18817912d5e37b77c21
64+change-id otuuklrtzzlqvnwuxvsvttrrsompzpzx
65+
66+conflicted merge
67+```
68+
69+`extract_change_id` must therefore be a real RFC-822-style header parser:
70+
71+1. Read lines until the **first empty line** (that terminates the header block; everything
72+ after is the message).
73+2. A line beginning with a **single space** is a continuation of the previous header's value.
74+ Both `gpgsig` and `jj:conflict-labels` use this. Splitting naively on `\n` and matching
75+ `^change-id ` happens to work today, but a continuation line inside a signature whose
76+ base64 payload begins with `change-id ` would produce a bogus id — cheap to prevent, so
77+ prevent it.
78+3. Match the header key `change-id` exactly, case-sensitively.
79+4. Validate the value: exactly 32 bytes, all within `[k-z]`. Reject anything else rather than
80+ storing a malformed id.
81+
82+Do not assume the header block ends at a fixed line number, and do not assume `change-id`
83+precedes or follows any particular other header.
84+
85+## 3. Stability across rewrite — confirmed
86+
87+The property the entire product rests on:
88+
89+| description | commit id | change id |
90+|---|---|---|
91+| `first change` | `857ec6861ea3cd5dfe57f0751c0435c8165c1460` | `qstvwxmpkvovsosxxmrnxpqmuuyllsqu` |
92+| `first change AMENDED` | `9d7c2334d79969ff8e788af6407e04e1d4fd1a35` | `qstvwxmpkvovsosxxmrnxpqmuuyllsqu` |
93+
94+The commit id changed, the change id did not. Verified to survive `jj git push` into a bare
95+remote — the header is present byte-identical when read back with
96+`git --git-dir=remote.git cat-file commit main`, which is exactly the path the indexer reads.
97+
98+## 4. Plain-git commits carry no header — confirmed
99+
100+```
101+tree 4997ca7a42e3ad9b729fbad3acd44fbabd07b6bd
102+author Plain Git <pg@dogfood.sh> 1785482410 +0000
103+committer Plain Git <pg@dogfood.sh> 1785482410 +0000
104+
105+plain git commit
106+```
107+
108+`extract_change_id` returns `None`; these take the synthetic-identity path (spec §4) and are
109+flagged `changes.synthetic = true`.
110+
111+## 5. Merge commits carry the header normally — confirmed
112+
113+A two-parent jj commit carries `change-id` like any other. No special handling needed beyond
114+recording both parents.
115+
116+## 6. Conflict representation — for the M4 indexer
117+
118+A conflicted revision is detectable **two** ways, and they agree:
119+
120+- **Header:** `jj:trees` lists the constituent tree OIDs, in alternating
121+ `side_0 base_0 side_1 [base_1 side_2 …]` order — always `sides == bases + 1`.
122+ `jj:conflict-labels` carries human-readable labels for each, in the same order.
123+- **Tree:** the commit's root tree contains `.jjconflict-side-N` and `.jjconflict-base-N`
124+ subtrees, plus a `JJ-CONFLICT-README` blob explaining the layout to plain-Git users.
125+
126+Observed for a 2-sided conflict:
127+
128+| header `jj:trees` position | tree entry | label |
129+|---|---|---|
130+| 0 | `.jjconflict-side-0` → `ada380c7…` | `vtrtrnpx 8fa0e2bf "sideA"` |
131+| 1 | `.jjconflict-base-0` → `892cad2b…` | `wokvnsmz ebc596ab "base"` |
132+| 2 | `.jjconflict-side-1` → `14a356d6…` | `lvtnxnzk e2263f3f "sideB"` |
133+
134+Note the root tree **also** contains the materialized path (`c.txt`) holding one side's
135+content, alongside the `.jjconflict-*` subtrees. A tree listing must filter the
136+`.jjconflict-*` entries and `JJ-CONFLICT-README` out of the user-facing file list, or every
137+conflicted change will show phantom directories in the browser.
138+
139+Prefer the `jj:trees` header for detection — it is unambiguous and does not require loading
140+the tree. Store the parsed sides/bases into `revisions.conflict_data` at index time so the
141+conflict viewer never re-walks objects (spec §4).
142+
143+## 7. Reference: jj's own documentation
144+
145+jj documents this mapping at
146+<https://docs.jj-vcs.dev/latest/git-compatibility/#format-mapping-details>, cited by the
147+`JJ-CONFLICT-README` blob jj itself writes into conflicted trees.
148+
149+## 8. Version sensitivity
150+
151+All of the above is observed on jj 0.43.0. The `jj:` header namespace and the letter-encoded
152+`change-id` are backend format, not CLI presentation, so they are stable in practice — but
153+the fixture corpus is generated by a pinned jj version in CI precisely so a format change
154+fails a test rather than silently corrupting the index.
Adocs/revset-semantics.md+65−0
@@ −0,0 +1,65 @@
1+# Revset semantics — notes for the §8 subset
2+
3+Spec §8 requires a deliberately small revset subset (`author()`, `description()`,
4+`conflicted()`, `bookmarks()`, `open()`, plus `&`, `|`, `~`) parsed into SQL, and warns:
5+
6+> Do not attempt to implement jj's full revset language against a Git-backed index; it will not
7+> be faithful, and a subtly wrong revset is worse than an honestly limited one.
8+
9+These are behaviours verified against jj 0.43.0 that a naive SQL translation gets wrong.
10+
11+## `description()` is an EXACT match, not a substring match
12+
13+This is the trap. Verified directly:
14+
15+```
16+$ jj log -r 'description("merge base")' # commit described exactly "merge base"
17+(empty result)
18+
19+$ jj log -r 'description(substring:"base")'
20+mmnulkozlxru merge base
21+
22+$ jj log -r 'subject("merge base")'
23+mmnulkozlxru merge base
24+```
25+
26+The obvious implementation — `WHERE description ILIKE '%' || $1 || '%'` — is **wrong**, and
27+wrong in the most damaging direction: it silently returns *more* than the CLI would, so a
28+reviewer filtering a change list sees results their terminal does not. That is precisely the
29+"subtly wrong revset" the spec says is worse than no revset.
30+
31+Correct mapping for the v1 subset:
32+
33+| revset | SQL |
34+|---|---|
35+| `description("x")` | `description = 'x'` — exact, including jj's trailing newline handling |
36+| `description(substring:"x")` | `description ILIKE '%x%'` |
37+| `subject("x")` | exact match against the **first line** of the description |
38+
39+If only one form is implemented in v1, implement `description(substring:)`, because it is the
40+one users actually want — but then **do not accept the bare `description("x")` form at all**.
41+Reject it with the "unsupported expression" message rather than aliasing it to substring.
42+Accepting it with different semantics than the CLI is the failure mode to avoid.
43+
44+Note also that jj descriptions carry a trailing newline. Normalise on ingest (store without
45+it) so exact matching does not depend on invisible whitespace.
46+
47+## Prefix matching
48+
49+Ambiguity handling matters. jj resolves the shortest unique prefix and errors on ambiguity;
50+spec §7 requires Dogfood render a disambiguation page rather than guess. The index has
51+`changes_prefix_idx (repo_id, change_id text_pattern_ops)` for this — use
52+`change_id LIKE $1 || '%'` and count results before choosing a page.
53+
54+## Functions deliberately NOT in the v1 subset
55+
56+`heads()`, `roots()`, `ancestors()`, `descendants()`, `::`, `..`, `latest()`, `merges()`,
57+`file()`, `diff_contains()` — all require graph traversal or content search that the SQL index
58+cannot answer faithfully. The parser must reject these by name with a clear message naming the
59+unsupported function, not fail to parse with a generic syntax error.
60+
61+## `mine()` / `author()`
62+
63+`author()` in jj matches against the author *email and name* as a substring. Dogfood stores
64+`revisions.author_email` and `author_name`; match against both, and be explicit in the UI that
65+this is a substring match so it is not confused with `description()`'s exact semantics.
Afixtures/gen.sh+369−0
@@ −0,0 +1,369 @@
1+#!/usr/bin/env bash
2+#
3+# Generate the jj/git fixture corpus the indexer is tested against.
4+#
5+# Spec §12: "a script that drives the real jj and git CLIs to produce repositories
6+# exhibiting every state the indexer must handle. Commit the script, generate the
7+# fixtures in CI. Never hand-author Git objects."
8+#
9+# Output: fixtures/repos/<case>.git — bare repositories, exactly what Dogfood
10+# stores on disk. The indexer reads these; nothing here is hand-written.
11+#
12+# Usage: fixtures/gen.sh [output_dir]
13+# Env: JJ_VERSION pin (default below). CI must pin so a jj format change fails
14+# a test instead of silently corrupting the index.
15+
16+set -euo pipefail
17+
18+JJ_VERSION="${JJ_VERSION:-0.43.0}"
19+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20+OUT="${1:-$ROOT/repos}"
21+BIN="$ROOT/bin"
22+WORK="$(mktemp -d)"
23+trap 'rm -rf "$WORK"' EXIT
24+
25+# ─── toolchain ───────────────────────────────────────────────────────────────
26+
27+export PATH="$BIN:$PATH"
28+
29+if ! command -v jj >/dev/null 2>&1 || [[ "$(jj --version | awk '{print $2}' | cut -d- -f1)" != "$JJ_VERSION" ]]; then
30+ echo "==> fetching jj $JJ_VERSION"
31+ mkdir -p "$BIN"
32+ curl -fsSL "https://github.com/jj-vcs/jj/releases/download/v${JJ_VERSION}/jj-v${JJ_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
33+ | tar xz -C "$BIN" jj
34+ chmod +x "$BIN/jj"
35+fi
36+
37+echo "==> jj: $(jj --version)"
38+echo "==> git: $(git --version)"
39+
40+# Deterministic identity and timestamps. Without pinned times the change ids stay
41+# stable (they are random) but the commit ids do not, and the tests assert on the
42+# relationship between them, not on literal values — so we pin what we can and
43+# the corpus manifest records the rest.
44+export JJ_CONFIG="$WORK/jjconfig.toml"
45+cat > "$JJ_CONFIG" <<'EOF'
46+[user]
47+name = "Fixture Author"
48+email = "fixture@dogfood.sh"
49+
50+[ui]
51+paginate = "never"
52+color = "never"
53+
54+# The `rewritten` case force-pushes the same change five times. jj defaults to
55+# treating commits reachable from a remote bookmark as immutable, which is right
56+# for humans and wrong for a fixture that must simulate exactly that workflow.
57+[revset-aliases]
58+"immutable_heads()" = "none()"
59+EOF
60+
61+export GIT_AUTHOR_NAME="Fixture Author"
62+export GIT_AUTHOR_EMAIL="fixture@dogfood.sh"
63+export GIT_COMMITTER_NAME="Fixture Author"
64+export GIT_COMMITTER_EMAIL="fixture@dogfood.sh"
65+
66+rm -rf "$OUT"
67+mkdir -p "$OUT"
68+
69+MANIFEST="$OUT/manifest.json"
70+# Opened here, closed at the bottom. `note` appends ",<key>:<value>" entries, so
71+# the object starts with a sentinel key to make the leading comma always valid.
72+printf '{"jj_version":"%s","cases":{"_generated_by":"fixtures/gen.sh"' "$JJ_VERSION" > "$MANIFEST.tmp"
73+
74+# Record a case's interesting revisions into the manifest so Rust tests can assert
75+# against named commits without re-deriving them.
76+note() { # note <case> <json>
77+ printf ',"%s":%s' "$1" "$2" >> "$MANIFEST.tmp"
78+}
79+
80+# Publish a working repo as a bare repo under $OUT, the way a push would land it.
81+publish() { # publish <case> <workdir> [default_branch]
82+ local case="$1" work="$2" head="${3:-main}"
83+ git init --bare -q "$OUT/$case.git"
84+ git -C "$work" push -q --all "$OUT/$case.git"
85+ git -C "$work" push -q --tags "$OUT/$case.git" 2>/dev/null || true
86+ # `git init --bare` leaves HEAD on refs/heads/master, which none of these
87+ # repos have. Dogfood creates repos with HEAD pointing at the default
88+ # bookmark, so the corpus must match or every fixture has an unborn HEAD.
89+ git --git-dir="$OUT/$case.git" symbolic-ref HEAD "refs/heads/$head"
90+}
91+
92+cid() { jj -R "$1" log --no-graph --ignore-working-copy -T 'commit_id' -r "$2"; }
93+chid() { jj -R "$1" log --no-graph --ignore-working-copy -T 'change_id' -r "$2"; }
94+
95+# ─────────────────────────────────────────────────────────────────────────────
96+# case: basic — jj-authored linear history, one bookmark
97+#
98+# The baseline. Every commit carries a `change-id` header.
99+# ─────────────────────────────────────────────────────────────────────────────
100+echo "==> case: basic"
101+W="$WORK/basic"
102+jj git init --colocate "$W" >/dev/null 2>&1
103+(
104+ cd "$W"
105+ echo "one" > a.txt
106+ jj describe -m "add a" >/dev/null
107+ jj new -m "add b" >/dev/null
108+ echo "two" > b.txt
109+ jj new -m "add c" >/dev/null
110+ echo "three" > c.txt
111+ jj bookmark create main -r @- >/dev/null
112+)
113+note basic "$(printf '{"head_change":"%s","head_commit":"%s"}' "$(chid "$W" 'bookmarks(main)')" "$(cid "$W" 'bookmarks(main)')")"
114+publish basic "$W"
115+
116+# ─────────────────────────────────────────────────────────────────────────────
117+# case: rewritten — the property the whole product rests on
118+#
119+# One change, rewritten five times (spec §5: "a change that is rewritten five
120+# times"). The change id must be identical across all five; the commit ids must
121+# all differ. Every intermediate commit is kept reachable via a tag so the bare
122+# repo actually contains them and the test can walk the whole rewrite chain.
123+# ─────────────────────────────────────────────────────────────────────────────
124+echo "==> case: rewritten"
125+W="$WORK/rewritten"
126+jj git init --colocate "$W" >/dev/null 2>&1
127+git init --bare -q "$OUT/rewritten.git"
128+(cd "$W" && jj git remote add origin "$OUT/rewritten.git" >/dev/null 2>&1)
129+(
130+ cd "$W"
131+ echo "v1" > f.txt
132+ jj describe -m "evolving change v1" >/dev/null
133+ jj bookmark create main -r @ >/dev/null
134+ jj git push -b main --allow-empty-description >/dev/null 2>&1
135+)
136+TARGET="$(chid "$W" 'bookmarks(main)')"
137+REWRITES="$(cid "$W" 'bookmarks(main)')"
138+for i in 2 3 4 5; do
139+ (
140+ cd "$W"
141+ echo "v$i" > f.txt
142+ jj describe -r 'bookmarks(main)' -m "evolving change v$i" >/dev/null
143+ # Force-push each rewrite, exactly as a user amending and re-pushing does.
144+ # The bare repo accumulates every revision as an unreferenced object, which
145+ # is precisely the state the indexer sees in production.
146+ jj git push -b main >/dev/null 2>&1
147+ )
148+ REWRITES="$REWRITES $(cid "$W" 'bookmarks(main)')"
149+done
150+git --git-dir="$OUT/rewritten.git" symbolic-ref HEAD refs/heads/main
151+# All five commit ids must differ; the change id must be identical across them.
152+note rewritten "$(printf '{"stable_change":"%s","revisions":["%s"]}' \
153+ "$TARGET" "$(echo "$REWRITES" | sed 's/ /","/g')")"
154+
155+# ─────────────────────────────────────────────────────────────────────────────
156+# case: plain-git — no change-id header anywhere
157+#
158+# Drives the synthetic-identity fallback (spec §4). extract_change_id must return
159+# None for every commit here.
160+# ─────────────────────────────────────────────────────────────────────────────
161+echo "==> case: plain-git"
162+W="$WORK/plaingit"
163+git init -q "$W"
164+(
165+ cd "$W"
166+ git symbolic-ref HEAD refs/heads/main
167+ echo "x" > f.txt && git add -A && git commit -qm "plain commit one"
168+ echo "y" >> f.txt && git add -A && git commit -qm "plain commit two"
169+)
170+note plain_git "$(printf '{"head_commit":"%s"}' "$(git -C "$W" rev-parse HEAD)")"
171+publish plain-git "$W"
172+
173+# ─────────────────────────────────────────────────────────────────────────────
174+# case: mixed — jj commits on top of plain-git commits in one history
175+#
176+# The realistic case for a repo migrated to jj. The indexer must produce real
177+# changes for the jj commits and synthetic ones for the git commits, in a single
178+# connected graph.
179+# ─────────────────────────────────────────────────────────────────────────────
180+echo "==> case: mixed"
181+W="$WORK/mixed"
182+git init -q "$W"
183+(
184+ cd "$W"
185+ git symbolic-ref HEAD refs/heads/main
186+ echo "legacy" > old.txt && git add -A && git commit -qm "pre-jj history"
187+)
188+(
189+ cd "$W"
190+ jj git init --colocate . >/dev/null 2>&1
191+ jj new main -m "first jj change on top" >/dev/null
192+ echo "new" > new.txt
193+ jj bookmark set main -r @ >/dev/null 2>&1 || jj bookmark create main -r @ >/dev/null
194+)
195+publish mixed "$W"
196+
197+# ─────────────────────────────────────────────────────────────────────────────
198+# case: stack — a chain of changes, none merged
199+#
200+# Exercises stack edge computation (spec §4). Four changes stacked on main.
201+# ─────────────────────────────────────────────────────────────────────────────
202+echo "==> case: stack"
203+W="$WORK/stack"
204+jj git init --colocate "$W" >/dev/null 2>&1
205+(
206+ cd "$W"
207+ echo "base" > base.txt
208+ jj describe -m "base of stack" >/dev/null
209+ jj bookmark create main -r @ >/dev/null
210+ for n in 1 2 3 4; do
211+ jj new -m "stacked change $n" >/dev/null
212+ echo "layer $n" > "layer$n.txt"
213+ done
214+ jj bookmark create top -r @ >/dev/null
215+)
216+note stack "$(printf '{"bottom":"%s","top":"%s"}' "$(chid "$W" 'bookmarks(main)')" "$(chid "$W" 'bookmarks(top)')")"
217+publish stack "$W"
218+
219+# ─────────────────────────────────────────────────────────────────────────────
220+# case: merge — a two-parent jj commit
221+# ─────────────────────────────────────────────────────────────────────────────
222+echo "==> case: merge"
223+W="$WORK/merge"
224+jj git init --colocate "$W" >/dev/null 2>&1
225+(
226+ cd "$W"
227+ # NOTE: revisions are addressed by captured change id, never by a
228+ # description() revset. In jj 0.43 `description("x")` is an EXACT match, not a
229+ # substring match (substring: needs an explicit `substring:` prefix), which
230+ # silently returns the empty set and produces a corrupt fixture.
231+ echo "base" > base.txt
232+ jj describe -m "merge base" >/dev/null
233+ BASE="$(jj log --no-graph --ignore-working-copy -T change_id -r @)"
234+ jj new -m "left side" >/dev/null && echo L > l.txt
235+ LEFT="$(jj log --no-graph --ignore-working-copy -T change_id -r @)"
236+ jj new -m "right side" "$BASE" >/dev/null && echo R > r.txt
237+ RIGHT="$(jj log --no-graph --ignore-working-copy -T change_id -r @)"
238+ jj new "$LEFT" "$RIGHT" -m "the merge" >/dev/null
239+ jj bookmark create main -r @ >/dev/null
240+)
241+note merge "$(printf '{"merge_commit":"%s","merge_change":"%s"}' "$(cid "$W" 'bookmarks(main)')" "$(chid "$W" 'bookmarks(main)')")"
242+publish merge "$W"
243+
244+# ─────────────────────────────────────────────────────────────────────────────
245+# case: conflict — a real 2-sided conflict
246+#
247+# Produces the `jj:trees` + `jj:conflict-labels` headers and the
248+# .jjconflict-side-N / .jjconflict-base-N tree layout documented in
249+# docs/change-id-format.md §6. Drives conflict detection and the conflict viewer.
250+# ─────────────────────────────────────────────────────────────────────────────
251+echo "==> case: conflict"
252+W="$WORK/conflict"
253+jj git init --colocate "$W" >/dev/null 2>&1
254+(
255+ cd "$W"
256+ printf 'line1\nline2\nline3\n' > c.txt
257+ jj describe -m "conflict base" >/dev/null
258+ BASE="$(jj log --no-graph --ignore-working-copy -T change_id -r @)"
259+ jj new -m "side A" >/dev/null && printf 'line1\nAAA\nline3\n' > c.txt
260+ SIDEA="$(jj log --no-graph --ignore-working-copy -T change_id -r @)"
261+ jj new -m "side B" "$BASE" >/dev/null && printf 'line1\nBBB\nline3\n' > c.txt
262+ SIDEB="$(jj log --no-graph --ignore-working-copy -T change_id -r @)"
263+ # Both sides edit line 2 of c.txt from the same base -> a real 2-sided conflict.
264+ jj new "$SIDEA" "$SIDEB" -m "conflicted merge" >/dev/null
265+ jj bookmark create main -r @ >/dev/null
266+)
267+note conflict "$(printf '{"conflicted_commit":"%s","conflicted_change":"%s"}' "$(cid "$W" 'bookmarks(main)')" "$(chid "$W" 'bookmarks(main)')")"
268+publish conflict "$W"
269+
270+# ─────────────────────────────────────────────────────────────────────────────
271+# case: signed — change-id alongside a multi-line gpgsig header
272+#
273+# The parser hazard from docs/change-id-format.md §2: a naive line scan can walk
274+# into the base64 signature body. Uses an SSH signing key so CI needs no GPG.
275+# ─────────────────────────────────────────────────────────────────────────────
276+echo "==> case: signed"
277+W="$WORK/signed"
278+KEY="$WORK/signkey"
279+ssh-keygen -t ed25519 -N "" -f "$KEY" -q
280+jj git init --colocate "$W" >/dev/null 2>&1
281+(
282+ cd "$W"
283+ git config gpg.format ssh
284+ git config user.signingkey "$KEY.pub"
285+ echo "signed content" > s.txt
286+ jj describe -m "a signed change" >/dev/null
287+ jj sign -r @ \
288+ --config 'signing.behavior="own"' \
289+ --config 'signing.backend="ssh"' \
290+ --config "signing.key=\"$KEY\"" >/dev/null 2>&1
291+ jj bookmark create main -r @ >/dev/null
292+)
293+note signed "$(printf '{"signed_commit":"%s","signed_change":"%s"}' "$(cid "$W" 'bookmarks(main)')" "$(chid "$W" 'bookmarks(main)')")"
294+publish signed "$W"
295+
296+# ─────────────────────────────────────────────────────────────────────────────
297+# case: renames — for comment anchor rebasing across a file rename (spec §5)
298+# ─────────────────────────────────────────────────────────────────────────────
299+echo "==> case: renames"
300+W="$WORK/renames"
301+jj git init --colocate "$W" >/dev/null 2>&1
302+(
303+ cd "$W"
304+ printf 'alpha\nbravo\ncharlie\ndelta\necho\n' > original.txt
305+ jj describe -m "before rename" >/dev/null
306+ jj bookmark create main -r @ >/dev/null
307+ jj new -m "rename and edit" >/dev/null
308+ git mv original.txt renamed.txt 2>/dev/null || mv original.txt renamed.txt
309+ printf 'alpha\nbravo\nCHARLIE\ndelta\necho\nfoxtrot\n' > renamed.txt
310+ jj bookmark create renamed -r @ >/dev/null
311+)
312+publish renames "$W"
313+
314+# ─────────────────────────────────────────────────────────────────────────────
315+# case: whitespace — anchor rebasing must not treat reindentation as a content
316+# change (spec §5: "whitespace-only changes")
317+# ─────────────────────────────────────────────────────────────────────────────
318+echo "==> case: whitespace"
319+W="$WORK/whitespace"
320+jj git init --colocate "$W" >/dev/null 2>&1
321+(
322+ cd "$W"
323+ printf 'fn main() {\nlet x = 1;\nprintln!("{}", x);\n}\n' > m.rs
324+ jj describe -m "unindented" >/dev/null
325+ jj bookmark create main -r @ >/dev/null
326+ jj new -m "reindent only" >/dev/null
327+ printf 'fn main() {\n let x = 1;\n println!("{}", x);\n}\n' > m.rs
328+ jj bookmark create reindented -r @ >/dev/null
329+)
330+publish whitespace "$W"
331+
332+# ─────────────────────────────────────────────────────────────────────────────
333+# case: hostile — inputs the security tests in spec §9 need
334+#
335+# A repo containing a symlink escaping the repo root, a path with unusual
336+# characters, and a deeply nested tree. Serving any of these naively is a CVE.
337+# ─────────────────────────────────────────────────────────────────────────────
338+echo "==> case: hostile"
339+W="$WORK/hostile"
340+git init -q "$W"
341+(
342+ cd "$W"
343+ git symbolic-ref HEAD refs/heads/main
344+ ln -s /etc/passwd escape-absolute
345+ ln -s ../../../../etc/shadow escape-relative
346+ mkdir -p a/b/c/d/e/f/g/h
347+ echo "deep" > a/b/c/d/e/f/g/h/deep.txt
348+ printf 'no trailing newline' > weird-name$'\t'tab.txt 2>/dev/null || echo skip > normal.txt
349+ echo "content" > "spaces in name.txt"
350+ git add -A && git commit -qm "hostile paths"
351+)
352+publish hostile "$W"
353+
354+# ─── finish ──────────────────────────────────────────────────────────────────
355+
356+printf '}}' >> "$MANIFEST.tmp"
357+# Reformat, and fail loudly on malformed JSON rather than shipping a corpus whose
358+# manifest the Rust tests cannot parse.
359+python3 -c "
360+import json
361+raw = open('$MANIFEST.tmp').read()
362+json.dump(json.loads(raw), open('$MANIFEST','w'), indent=2)
363+print('==> manifest ok')
364+"
365+rm -f "$MANIFEST.tmp"
366+
367+echo
368+echo "==> corpus written to $OUT"
369+ls -1 "$OUT" | sed 's/^/ /'
Amigrations/0001_initial.sql448 lines+448−0
@@ −0,0 +1,448 @@
1+-- Dogfood initial schema (spec §5).
2+--
3+-- Target: PostgreSQL 17 (the provided instance; the spec said 16, the extra
4+-- version is harmless — nothing here depends on 17-only behaviour).
5+--
6+-- UUIDv7 primary keys are generated in the application, not by the database, so
7+-- that IDs are known before insert and a row can reference itself in the same
8+-- transaction. Time-ordered, index-friendly, and they do not leak counts.
9+
10+CREATE EXTENSION IF NOT EXISTS citext;
11+CREATE EXTENSION IF NOT EXISTS pg_trgm;
12+
13+-- ─── identity ────────────────────────────────────────────────────────────────
14+
15+CREATE TABLE users (
16+ id uuid PRIMARY KEY,
17+ subject text NOT NULL UNIQUE, -- OIDC sub claim (Kratos identity id)
18+ handle citext NOT NULL UNIQUE,
19+ display_name text,
20+ email citext,
21+ avatar_url text,
22+ is_admin boolean NOT NULL DEFAULT false,
23+ created_at timestamptz NOT NULL DEFAULT now(),
24+ -- NOTE the ::text cast. `handle` is citext, and citext makes the `~`
25+ -- operator case-INSENSITIVE, so without the cast this constraint accepts
26+ -- 'Bob' despite the lowercase-only character class. Verified.
27+ CONSTRAINT handle_format CHECK (handle::text ~ '^[a-z0-9][a-z0-9-]{0,38}$')
28+);
29+
30+CREATE TABLE orgs (
31+ id uuid PRIMARY KEY,
32+ handle citext NOT NULL UNIQUE,
33+ display_name text,
34+ description text,
35+ created_at timestamptz NOT NULL DEFAULT now(),
36+ -- NOTE the ::text cast. `handle` is citext, and citext makes the `~`
37+ -- operator case-INSENSITIVE, so without the cast this constraint accepts
38+ -- 'Bob' despite the lowercase-only character class. Verified.
39+ CONSTRAINT handle_format CHECK (handle::text ~ '^[a-z0-9][a-z0-9-]{0,38}$')
40+);
41+
42+-- Users and orgs share a handle namespace so that /{handle} is unambiguous.
43+-- Enforced with triggers on both tables rather than a CHECK, which cannot see
44+-- another table.
45+CREATE FUNCTION assert_handle_unused() RETURNS trigger AS $$
46+BEGIN
47+ IF TG_TABLE_NAME = 'users' THEN
48+ IF EXISTS (SELECT 1 FROM orgs WHERE handle = NEW.handle) THEN
49+ RAISE EXCEPTION 'handle % is already taken by an organization', NEW.handle
50+ USING ERRCODE = 'unique_violation';
51+ END IF;
52+ ELSE
53+ IF EXISTS (SELECT 1 FROM users WHERE handle = NEW.handle) THEN
54+ RAISE EXCEPTION 'handle % is already taken by a user', NEW.handle
55+ USING ERRCODE = 'unique_violation';
56+ END IF;
57+ END IF;
58+ RETURN NEW;
59+END;
60+$$ LANGUAGE plpgsql;
61+
62+CREATE TRIGGER users_handle_namespace
63+ BEFORE INSERT OR UPDATE OF handle ON users
64+ FOR EACH ROW EXECUTE FUNCTION assert_handle_unused();
65+
66+CREATE TRIGGER orgs_handle_namespace
67+ BEFORE INSERT OR UPDATE OF handle ON orgs
68+ FOR EACH ROW EXECUTE FUNCTION assert_handle_unused();
69+
70+-- Reserved handles: these are top-level routes, so a user or org holding one
71+-- would shadow them (spec §7 puts /settings, /new, /search at the root).
72+CREATE TABLE reserved_handles (handle citext PRIMARY KEY);
73+INSERT INTO reserved_handles (handle) VALUES
74+ ('new'), ('settings'), ('search'), ('login'), ('logout'), ('auth'),
75+ ('setup'), ('admin'), ('api'), ('static'), ('assets'), ('healthz'),
76+ ('readyz'), ('metrics'), ('about'), ('help'), ('docs'), ('status'),
77+ ('explore'), ('notifications'), ('dashboard'), ('repos'), ('orgs'),
78+ ('users'), ('git'), ('jj'), ('www'), ('mail'), ('root');
79+
80+CREATE TYPE org_role AS ENUM ('member', 'admin');
81+
82+CREATE TABLE org_members (
83+ org_id uuid NOT NULL REFERENCES orgs ON DELETE CASCADE,
84+ user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE,
85+ role org_role NOT NULL DEFAULT 'member',
86+ created_at timestamptz NOT NULL DEFAULT now(),
87+ PRIMARY KEY (org_id, user_id)
88+);
89+CREATE INDEX org_members_user_idx ON org_members (user_id);
90+
91+CREATE TABLE ssh_keys (
92+ id uuid PRIMARY KEY,
93+ user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE,
94+ name text NOT NULL,
95+ key_type text NOT NULL,
96+ fingerprint text NOT NULL UNIQUE, -- SHA256:… — the auth lookup key
97+ public_key text NOT NULL,
98+ last_used_at timestamptz,
99+ created_at timestamptz NOT NULL DEFAULT now()
100+);
101+CREATE INDEX ssh_keys_fingerprint_idx ON ssh_keys (fingerprint);
102+CREATE INDEX ssh_keys_user_idx ON ssh_keys (user_id);
103+
104+CREATE TABLE access_tokens (
105+ id uuid PRIMARY KEY,
106+ user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE,
107+ name text NOT NULL,
108+ token_hash text NOT NULL, -- argon2id PHC string; plaintext shown once
109+ prefix text NOT NULL, -- first 8 chars, for identification in UI
110+ scopes text[] NOT NULL DEFAULT '{}',
111+ expires_at timestamptz,
112+ last_used_at timestamptz,
113+ created_at timestamptz NOT NULL DEFAULT now()
114+);
115+CREATE INDEX access_tokens_user_idx ON access_tokens (user_id);
116+-- Token auth looks up by prefix, then verifies the argon2 hash. Hashing is
117+-- deliberately slow, so we must not hash against every row in the table.
118+CREATE INDEX access_tokens_prefix_idx ON access_tokens (prefix);
119+
120+CREATE TABLE sessions (
121+ id uuid PRIMARY KEY,
122+ user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE,
123+ expires_at timestamptz NOT NULL,
124+ user_agent text,
125+ ip inet,
126+ created_at timestamptz NOT NULL DEFAULT now()
127+);
128+CREATE INDEX sessions_user_idx ON sessions (user_id);
129+CREATE INDEX sessions_expiry_idx ON sessions (expires_at);
130+
131+-- In-flight OIDC authorization code flows. PKCE verifier and nonce are held
132+-- server-side and keyed by the opaque state parameter; nothing sensitive is
133+-- round-tripped through the browser.
134+CREATE TABLE auth_flows (
135+ state text PRIMARY KEY,
136+ pkce_verifier text NOT NULL,
137+ nonce text NOT NULL,
138+ redirect_after text,
139+ created_at timestamptz NOT NULL DEFAULT now(),
140+ expires_at timestamptz NOT NULL
141+);
142+CREATE INDEX auth_flows_expiry_idx ON auth_flows (expires_at);
143+
144+-- ─── access control (decided: invite/allowlist only) ─────────────────────────
145+
146+-- A successful OIDC login is not sufficient to get a Dogfood account. The
147+-- identity must additionally match an allowlist entry or an open invitation.
148+CREATE TABLE invitations (
149+ id uuid PRIMARY KEY,
150+ email citext NOT NULL UNIQUE,
151+ invited_by uuid REFERENCES users ON DELETE SET NULL,
152+ accepted_at timestamptz,
153+ accepted_by uuid REFERENCES users ON DELETE SET NULL,
154+ created_at timestamptz NOT NULL DEFAULT now()
155+);
156+
157+-- One-time site-admin bootstrap (decided). The web process mints a token on
158+-- first boot when no admin exists, logs it once, and stores only its hash.
159+CREATE TABLE setup_tokens (
160+ id uuid PRIMARY KEY,
161+ token_hash text NOT NULL,
162+ consumed_at timestamptz,
163+ consumed_by uuid REFERENCES users ON DELETE SET NULL,
164+ created_at timestamptz NOT NULL DEFAULT now()
165+);
166+
167+-- ─── repositories ────────────────────────────────────────────────────────────
168+
169+CREATE TYPE owner_kind AS ENUM ('user', 'org');
170+CREATE TYPE visibility AS ENUM ('public', 'private');
171+
172+CREATE TABLE repos (
173+ id uuid PRIMARY KEY,
174+ owner_kind owner_kind NOT NULL,
175+ owner_user_id uuid REFERENCES users ON DELETE CASCADE,
176+ owner_org_id uuid REFERENCES orgs ON DELETE CASCADE,
177+ name citext NOT NULL,
178+ description text,
179+ visibility visibility NOT NULL DEFAULT 'private',
180+ default_bookmark text NOT NULL DEFAULT 'main',
181+ -- Forks use separate storage (decided): this records provenance only, and
182+ -- never causes two repos to share an object database.
183+ fork_of_repo_id uuid REFERENCES repos ON DELETE SET NULL,
184+ size_bytes bigint NOT NULL DEFAULT 0,
185+ pushed_at timestamptz,
186+ archived boolean NOT NULL DEFAULT false,
187+ created_at timestamptz NOT NULL DEFAULT now(),
188+ CONSTRAINT one_owner CHECK (num_nonnulls(owner_user_id, owner_org_id) = 1),
189+ CONSTRAINT owner_kind_matches CHECK (
190+ (owner_kind = 'user' AND owner_user_id IS NOT NULL) OR
191+ (owner_kind = 'org' AND owner_org_id IS NOT NULL)
192+ ),
193+ CONSTRAINT name_format CHECK (name ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$' AND name !~ '\.\.')
194+);
195+CREATE UNIQUE INDEX repos_owner_name_idx ON repos (COALESCE(owner_user_id, owner_org_id), name);
196+CREATE INDEX repos_visibility_idx ON repos (visibility) WHERE archived = false;
197+
198+CREATE TYPE repo_role AS ENUM ('read', 'write', 'maintain', 'admin');
199+
200+CREATE TABLE repo_collaborators (
201+ repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE,
202+ user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE,
203+ role repo_role NOT NULL,
204+ created_at timestamptz NOT NULL DEFAULT now(),
205+ PRIMARY KEY (repo_id, user_id)
206+);
207+CREATE INDEX repo_collaborators_user_idx ON repo_collaborators (user_id);
208+
209+CREATE TABLE bookmarks (
210+ repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE,
211+ name text NOT NULL,
212+ target text NOT NULL, -- RevId
213+ protected boolean NOT NULL DEFAULT false,
214+ updated_at timestamptz NOT NULL DEFAULT now(),
215+ PRIMARY KEY (repo_id, name)
216+);
217+
218+-- ─── changes ─────────────────────────────────────────────────────────────────
219+
220+CREATE TYPE change_state AS ENUM ('draft', 'open', 'merged', 'abandoned');
221+
222+CREATE TABLE changes (
223+ id uuid PRIMARY KEY,
224+ repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE,
225+ change_id text NOT NULL, -- jj change id, canonical letter encoding
226+ synthetic boolean NOT NULL DEFAULT false,
227+ number bigint NOT NULL, -- per-repo display number
228+ title text NOT NULL,
229+ description text NOT NULL DEFAULT '',
230+ -- 'draft' is set by the author in the UI and is never inferred from pushed
231+ -- metadata (decided). The indexer may move a change to merged/abandoned but
232+ -- must not overwrite draft.
233+ state change_state NOT NULL DEFAULT 'open',
234+ conflicted boolean NOT NULL DEFAULT false,
235+ author_user_id uuid REFERENCES users ON DELETE SET NULL,
236+ target_bookmark text NOT NULL,
237+ head_revision_id uuid, -- FK added after revisions
238+ merged_at timestamptz,
239+ created_at timestamptz NOT NULL DEFAULT now(),
240+ updated_at timestamptz NOT NULL DEFAULT now(),
241+ UNIQUE (repo_id, change_id),
242+ UNIQUE (repo_id, number)
243+);
244+CREATE INDEX changes_list_idx ON changes (repo_id, state, updated_at DESC);
245+
246+-- Prefix lookup: users type a short prefix, exactly as in the CLI.
247+CREATE INDEX changes_prefix_idx ON changes (repo_id, change_id text_pattern_ops);
248+
249+CREATE TABLE revisions (
250+ id uuid PRIMARY KEY,
251+ change_id_fk uuid NOT NULL REFERENCES changes ON DELETE CASCADE,
252+ rev text NOT NULL, -- RevId
253+ seq int NOT NULL, -- 1-based, per change
254+ parents text[] NOT NULL DEFAULT '{}',
255+ author_name text NOT NULL,
256+ author_email text NOT NULL,
257+ authored_at timestamptz NOT NULL,
258+ message text NOT NULL,
259+ conflicted boolean NOT NULL DEFAULT false,
260+ conflict_data jsonb, -- sides, base, paths
261+ pushed_by uuid REFERENCES users ON DELETE SET NULL,
262+ pushed_at timestamptz NOT NULL DEFAULT now(),
263+ UNIQUE (change_id_fk, rev),
264+ UNIQUE (change_id_fk, seq)
265+);
266+CREATE INDEX revisions_rev_idx ON revisions (rev);
267+
268+ALTER TABLE changes ADD CONSTRAINT changes_head_fk
269+ FOREIGN KEY (head_revision_id) REFERENCES revisions ON DELETE SET NULL;
270+
271+-- Stack edges: parent_change is immediately below child_change in a stack.
272+-- Computed at index time; never recomputed on page render (spec §4).
273+CREATE TABLE change_edges (
274+ repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE,
275+ parent_change uuid NOT NULL REFERENCES changes ON DELETE CASCADE,
276+ child_change uuid NOT NULL REFERENCES changes ON DELETE CASCADE,
277+ PRIMARY KEY (parent_change, child_change),
278+ CONSTRAINT no_self_edge CHECK (parent_change <> child_change)
279+);
280+CREATE INDEX change_edges_child_idx ON change_edges (repo_id, child_change);
281+
282+-- ─── discussion ──────────────────────────────────────────────────────────────
283+
284+CREATE TYPE anchor_state AS ENUM ('current', 'outdated', 'orphaned');
285+
286+CREATE TABLE comments (
287+ id uuid PRIMARY KEY,
288+ repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE,
289+ change_id_fk uuid REFERENCES changes ON DELETE CASCADE,
290+ issue_id uuid, -- FK below
291+ parent_id uuid REFERENCES comments ON DELETE CASCADE,
292+ author_user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE,
293+ body text NOT NULL,
294+
295+ -- inline anchor, null for top-level comments
296+ anchor_revision uuid REFERENCES revisions ON DELETE SET NULL,
297+ anchor_path text,
298+ anchor_line int,
299+ anchor_side text CHECK (anchor_side IN ('old','new')),
300+ anchor_state anchor_state NOT NULL DEFAULT 'current',
301+ anchor_context text, -- the line's content when written
302+
303+ resolved_at timestamptz,
304+ resolved_by uuid REFERENCES users ON DELETE SET NULL,
305+ edited_at timestamptz,
306+ created_at timestamptz NOT NULL DEFAULT now(),
307+ CONSTRAINT one_target CHECK (num_nonnulls(change_id_fk, issue_id) = 1)
308+);
309+CREATE INDEX comments_change_idx ON comments (change_id_fk, created_at);
310+CREATE INDEX comments_issue_idx ON comments (issue_id, created_at);
311+-- Anchor rebasing loads every inline comment on a change by its anchored file.
312+CREATE INDEX comments_anchor_idx ON comments (change_id_fk, anchor_path)
313+ WHERE anchor_path IS NOT NULL;
314+
315+CREATE TYPE review_verdict AS ENUM ('approve', 'request_changes', 'comment');
316+
317+CREATE TABLE reviews (
318+ id uuid PRIMARY KEY,
319+ change_id_fk uuid NOT NULL REFERENCES changes ON DELETE CASCADE,
320+ revision_id uuid NOT NULL REFERENCES revisions ON DELETE CASCADE,
321+ reviewer_id uuid NOT NULL REFERENCES users ON DELETE CASCADE,
322+ verdict review_verdict NOT NULL,
323+ body text,
324+ created_at timestamptz NOT NULL DEFAULT now()
325+);
326+CREATE INDEX reviews_change_idx ON reviews (change_id_fk, created_at DESC);
327+
328+-- ─── issues ──────────────────────────────────────────────────────────────────
329+
330+CREATE TYPE issue_state AS ENUM ('open', 'closed');
331+
332+CREATE TABLE issues (
333+ id uuid PRIMARY KEY,
334+ repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE,
335+ number bigint NOT NULL,
336+ title text NOT NULL,
337+ body text NOT NULL DEFAULT '',
338+ state issue_state NOT NULL DEFAULT 'open',
339+ author_user_id uuid REFERENCES users ON DELETE SET NULL,
340+ closed_at timestamptz,
341+ created_at timestamptz NOT NULL DEFAULT now(),
342+ updated_at timestamptz NOT NULL DEFAULT now(),
343+ UNIQUE (repo_id, number)
344+);
345+CREATE INDEX issues_list_idx ON issues (repo_id, state, updated_at DESC);
346+
347+ALTER TABLE comments ADD CONSTRAINT comments_issue_fk
348+ FOREIGN KEY (issue_id) REFERENCES issues ON DELETE CASCADE;
349+
350+CREATE TABLE labels (
351+ id uuid PRIMARY KEY,
352+ repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE,
353+ name text NOT NULL,
354+ color text NOT NULL,
355+ UNIQUE (repo_id, name)
356+);
357+
358+CREATE TABLE issue_labels (
359+ issue_id uuid NOT NULL REFERENCES issues ON DELETE CASCADE,
360+ label_id uuid NOT NULL REFERENCES labels ON DELETE CASCADE,
361+ PRIMARY KEY (issue_id, label_id)
362+);
363+
364+CREATE TABLE issue_assignees (
365+ issue_id uuid NOT NULL REFERENCES issues ON DELETE CASCADE,
366+ user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE,
367+ PRIMARY KEY (issue_id, user_id)
368+);
369+
370+-- Per-repo issue and change numbering. A sequence per repo would be cleaner but
371+-- cannot be created dynamically without DDL on every repo creation; this row is
372+-- locked with SELECT … FOR UPDATE when allocating.
373+CREATE TABLE repo_counters (
374+ repo_id uuid PRIMARY KEY REFERENCES repos ON DELETE CASCADE,
375+ next_change bigint NOT NULL DEFAULT 1,
376+ next_issue bigint NOT NULL DEFAULT 1
377+);
378+
379+-- ─── activity ────────────────────────────────────────────────────────────────
380+
381+-- Timeline events. Also the substrate a future activity feed would read from —
382+-- written correctly now even though nothing renders a feed in v1.
383+CREATE TABLE events (
384+ id uuid PRIMARY KEY,
385+ repo_id uuid REFERENCES repos ON DELETE CASCADE,
386+ actor_id uuid REFERENCES users ON DELETE SET NULL,
387+ kind text NOT NULL, -- pushed, rebased, conflicted, resolved,
388+ -- merged, abandoned, reviewed, commented, …
389+ subject_type text NOT NULL, -- change | issue | repo | bookmark
390+ subject_id uuid,
391+ payload jsonb NOT NULL DEFAULT '{}',
392+ created_at timestamptz NOT NULL DEFAULT now()
393+);
394+CREATE INDEX events_repo_idx ON events (repo_id, created_at DESC);
395+CREATE INDEX events_subject_idx ON events (subject_type, subject_id, created_at);
396+
397+CREATE TABLE audit_log (
398+ id uuid PRIMARY KEY,
399+ actor_id uuid REFERENCES users ON DELETE SET NULL,
400+ action text NOT NULL,
401+ target text NOT NULL,
402+ ip inet,
403+ metadata jsonb NOT NULL DEFAULT '{}',
404+ created_at timestamptz NOT NULL DEFAULT now()
405+);
406+CREATE INDEX audit_log_actor_idx ON audit_log (actor_id, created_at DESC);
407+CREATE INDEX audit_log_created_idx ON audit_log (created_at DESC);
408+
409+-- ─── rendering cache ─────────────────────────────────────────────────────────
410+
411+-- Syntax highlighting is expensive and its input is content-addressed, so the
412+-- blob OID is a perfect cache key (spec §8). Highlighting on every request is
413+-- the easiest performance mistake to make here.
414+CREATE TABLE highlight_cache (
415+ blob_oid text PRIMARY KEY,
416+ language text,
417+ html text NOT NULL,
418+ bytes int NOT NULL,
419+ created_at timestamptz NOT NULL DEFAULT now()
420+);
421+
422+-- ─── jobs ────────────────────────────────────────────────────────────────────
423+
424+CREATE TABLE jobs (
425+ id uuid PRIMARY KEY,
426+ kind text NOT NULL,
427+ payload jsonb NOT NULL,
428+ run_at timestamptz NOT NULL DEFAULT now(),
429+ attempts int NOT NULL DEFAULT 0,
430+ max_attempts int NOT NULL DEFAULT 5,
431+ locked_at timestamptz,
432+ locked_by text,
433+ last_error text,
434+ created_at timestamptz NOT NULL DEFAULT now()
435+);
436+CREATE INDEX jobs_ready_idx ON jobs (run_at) WHERE locked_at IS NULL;
437+
438+-- Wake the worker on insert so it does not poll on the hot path. The worker
439+-- also polls every 5s as a fallback for missed notifications (spec §5).
440+CREATE FUNCTION notify_job() RETURNS trigger AS $$
441+BEGIN
442+ PERFORM pg_notify('dogfood_jobs', NEW.kind);
443+ RETURN NEW;
444+END;
445+$$ LANGUAGE plpgsql;
446+
447+CREATE TRIGGER jobs_notify AFTER INSERT ON jobs
448+ FOR EACH ROW EXECUTE FUNCTION notify_job();
Amigrations/0002_settings.sql+18−0
@@ −0,0 +1,18 @@
1+-- M2: repository settings and collaborator management.
2+
3+-- Who granted a collaborator role. The audit log records the action, but the
4+-- current state should also say who is responsible for it — an audit trail that
5+-- has been trimmed by retention must not lose the answer to "why does this
6+-- person have write access".
7+ALTER TABLE repo_collaborators
8+ ADD COLUMN added_by uuid REFERENCES users ON DELETE SET NULL;
9+
10+-- The default bookmark is protected implicitly (the UI shows it as "always"),
11+-- but the hook enforces protection from the `bookmarks` table alone, so the row
12+-- has to agree. Backfilled here and maintained by the indexer thereafter.
13+UPDATE bookmarks b
14+ SET protected = true
15+ FROM repos r
16+ WHERE r.id = b.repo_id
17+ AND b.name = r.default_bookmark
18+ AND b.protected = false;
Amigrations/0003_search.sql+68−0
@@ −0,0 +1,68 @@
1+-- M5: global search over repositories, changes and issues.
2+--
3+-- Spec §5 in-scope list ends at "Global search over repos, changes, and issues
4+-- (not code)", so this indexes titles, descriptions and bodies — never blobs.
5+--
6+-- Generated columns rather than triggers: the tsvector cannot drift from the
7+-- row, and there is no ordering hazard between an update and its index.
8+
9+-- ─── repositories ────────────────────────────────────────────────────────────
10+
11+ALTER TABLE repos ADD COLUMN search tsvector
12+ GENERATED ALWAYS AS (
13+ setweight(to_tsvector('english', coalesce(name::text, '')), 'A') ||
14+ setweight(to_tsvector('english', coalesce(description, '')), 'B')
15+ ) STORED;
16+
17+CREATE INDEX repos_search_idx ON repos USING gin (search);
18+
19+-- ─── changes ─────────────────────────────────────────────────────────────────
20+
21+ALTER TABLE changes ADD COLUMN search tsvector
22+ GENERATED ALWAYS AS (
23+ setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
24+ setweight(to_tsvector('english', coalesce(description, '')), 'B')
25+ ) STORED;
26+
27+CREATE INDEX changes_search_idx ON changes USING gin (search);
28+
29+-- ─── issues ──────────────────────────────────────────────────────────────────
30+
31+ALTER TABLE issues ADD COLUMN search tsvector
32+ GENERATED ALWAYS AS (
33+ setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
34+ setweight(to_tsvector('english', coalesce(body, '')), 'B')
35+ ) STORED;
36+
37+CREATE INDEX issues_search_idx ON issues USING gin (search);
38+
39+-- Cross-references between issues and changes, extracted from bodies and
40+-- comments when they are written. Stored rather than re-scanned so a change's
41+-- page can list the issues that mention it without a full-text query per view.
42+CREATE TABLE cross_references (
43+ id uuid PRIMARY KEY,
44+ repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE,
45+ -- What contains the reference.
46+ source_type text NOT NULL CHECK (source_type IN ('change', 'issue', 'comment')),
47+ source_id uuid NOT NULL,
48+ -- What it points at.
49+ target_type text NOT NULL CHECK (target_type IN ('change', 'issue')),
50+ target_id uuid NOT NULL,
51+ created_at timestamptz NOT NULL DEFAULT now(),
52+ UNIQUE (source_type, source_id, target_type, target_id)
53+);
54+CREATE INDEX cross_references_target_idx ON cross_references (target_type, target_id);
55+
56+-- Labels need a default set per repository or the issue form has nothing to
57+-- offer. Seeded for repositories that already exist; new ones are seeded in
58+-- application code at creation.
59+INSERT INTO labels (id, repo_id, name, color)
60+SELECT gen_random_uuid(), r.id, l.name, l.color
61+ FROM repos r
62+ CROSS JOIN (VALUES
63+ ('bug', '#d06b6b'),
64+ ('enhancement', '#6ba9b8'),
65+ ('question', '#d9a441'),
66+ ('documentation', '#8b7fd4')
67+ ) AS l(name, color)
68+ON CONFLICT (repo_id, name) DO NOTHING;
Amigrations/0004_session_id_token.sql+5−0
@@ −0,0 +1,5 @@
1+-- The raw OIDC ID token issued at login, kept only so sign-out can pass
2+-- `id_token_hint` to the provider's RP-Initiated Logout endpoint. Hydra
3+-- rejects `post_logout_redirect_uri` without it, which otherwise sends the
4+-- user to an error page while their local session is already gone.
5+ALTER TABLE sessions ADD COLUMN id_token text;
Arun.sh+221−0
@@ −0,0 +1,221 @@
1+#!/usr/bin/env bash
2+#
3+# Dogfood — build, deploy, and inspect the running stack.
4+#
5+# ./run.sh rebuild the image and restart the containers
6+# ./run.sh build build the image only
7+# ./run.sh restart restart without rebuilding
8+# ./run.sh up start (build if the image is missing)
9+# ./run.sh down stop and remove the containers
10+# ./run.sh logs [-f] show logs
11+# ./run.sh status containers, health, and the public endpoint
12+# ./run.sh test run the full Rust test suite in Docker
13+# ./run.sh check cargo check
14+# ./run.sh fixtures regenerate the jj/git fixture corpus
15+# ./run.sh psql open a psql shell on the configured database
16+# ./run.sh token show the outstanding admin setup token status
17+# ./run.sh shell a shell in the web container
18+# ./run.sh metrics scrape /metrics from inside the container
19+# ./run.sh reindex [id] re-run indexing (all repositories, or one)
20+# ./run.sh backup [dir] pg_dump + a snapshot of the repository volume
21+# ./run.sh rehearse prove a backup restores (spec §10)
22+# ./run.sh loadtest drive a large repository over HTTP (spec §11)
23+#
24+# The deploy path is `docker compose`, so this script is a convenience wrapper
25+# and never the only way to operate the stack.
26+
27+set -euo pipefail
28+
29+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
30+COMPOSE=(docker compose -f "$ROOT/docker/compose.yaml")
31+SERVICE=web
32+CONTAINER=dogfood-web
33+# Every service built and restarted by the default `deploy` path.
34+SERVICES=(web worker ssh)
35+
36+# Pinned so a toolchain bump is a deliberate edit. 1.83 and older fail to parse
37+# a transitive `time-core` manifest.
38+RUST_IMAGE="rust:slim"
39+
40+CARGO_ARGS=(
41+ --rm
42+ -v "$ROOT":/w -w /w
43+ -v dogfood-cargo-registry:/usr/local/cargo/registry
44+ -v dogfood-target:/w/target
45+)
46+
47+c_red() { printf '\033[31m%s\033[0m\n' "$*"; }
48+c_green() { printf '\033[32m%s\033[0m\n' "$*"; }
49+c_dim() { printf '\033[2m%s\033[0m\n' "$*"; }
50+
51+require_env() {
52+ if [[ ! -f "$ROOT/.env" ]]; then
53+ c_red "No .env at $ROOT/.env — the containers cannot start without it."
54+ echo "Copy .env.example and fill in DATABASE_URL and the OIDC_* values."
55+ exit 1
56+ fi
57+}
58+
59+# Read a value out of .env without sourcing it, so quoting and stray shell
60+# metacharacters in secrets cannot execute.
61+env_get() {
62+ sed -n "s/^$1=//p" "$ROOT/.env" | head -1
63+}
64+
65+cmd_build() {
66+ require_env
67+ c_dim "building $SERVICE…"
68+ "${COMPOSE[@]}" build "${SERVICES[@]}"
69+ c_green "built"
70+}
71+
72+cmd_up() {
73+ require_env
74+ "${COMPOSE[@]}" up -d
75+ wait_healthy
76+}
77+
78+cmd_down() {
79+ "${COMPOSE[@]}" down
80+}
81+
82+cmd_restart() {
83+ require_env
84+ # --force-recreate so a changed .env is actually picked up; compose does not
85+ # recreate a container just because its env_file changed on disk.
86+ "${COMPOSE[@]}" up -d --force-recreate
87+ wait_healthy
88+}
89+
90+cmd_deploy() {
91+ cmd_build
92+ cmd_restart
93+}
94+
95+# Poll until the app answers, so the script fails loudly rather than returning
96+# success on a container that crash-looped.
97+wait_healthy() {
98+ c_dim "waiting for $CONTAINER to serve…"
99+ for _ in $(seq 1 40); do
100+ if docker exec "$CONTAINER" wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1; then
101+ c_green "healthy"
102+ cmd_status
103+ return 0
104+ fi
105+ if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
106+ c_red "container exited — last 30 log lines:"
107+ docker logs --tail 30 "$CONTAINER" 2>&1 || true
108+ return 1
109+ fi
110+ sleep 1
111+ done
112+ c_red "timed out waiting for health — last 30 log lines:"
113+ docker logs --tail 30 "$CONTAINER" 2>&1 || true
114+ return 1
115+}
116+
117+cmd_logs() {
118+ "${COMPOSE[@]}" logs "${@:-}" "${SERVICES[@]}"
119+}
120+
121+cmd_status() {
122+ echo
123+ "${COMPOSE[@]}" ps
124+ local base
125+ base="$(env_get BASE_URL)"
126+ base="${base:-https://dogfood.sh}"
127+ echo
128+ printf '%-28s ' "$base/healthz"
129+ curl -s -o /dev/null -w '%{http_code}\n' "$base/healthz" --max-time 15 || echo "unreachable"
130+ printf '%-28s ' "$base/readyz"
131+ curl -s -o /dev/null -w '%{http_code}\n' "$base/readyz" --max-time 15 || echo "unreachable"
132+}
133+
134+cmd_test() {
135+ # Fixtures are required so the corpus tests cannot silently skip.
136+ [[ -d "$ROOT/fixtures/repos" ]] || cmd_fixtures
137+ docker run "${CARGO_ARGS[@]}" -e DF_REQUIRE_FIXTURES=1 "$RUST_IMAGE" bash -c '
138+ apt-get update -qq >/dev/null 2>&1
139+ apt-get install -y -qq git >/dev/null 2>&1
140+ cargo test --workspace'
141+}
142+
143+cmd_check() {
144+ docker run "${CARGO_ARGS[@]}" "$RUST_IMAGE" cargo check --workspace --all-targets
145+}
146+
147+cmd_fixtures() {
148+ "$ROOT/fixtures/gen.sh"
149+}
150+
151+cmd_psql() {
152+ require_env
153+ local url
154+ url="$(env_get DATABASE_URL)"
155+ [[ -n "$url" ]] || { c_red "DATABASE_URL not set in .env"; exit 1; }
156+ # Only request a TTY when we actually have one, so `./run.sh psql -tAc '…'`
157+ # works from a script or a pipeline.
158+ local tty=()
159+ [[ -t 0 && -t 1 ]] && tty=(-it)
160+ docker run --rm "${tty[@]}" postgres:17-alpine psql "$url" "$@"
161+}
162+
163+cmd_token() {
164+ require_env
165+ local url
166+ url="$(env_get DATABASE_URL)"
167+ echo "Outstanding setup tokens (plaintext is only ever printed to the log at first boot):"
168+ docker run --rm postgres:17-alpine psql "$url" -c \
169+ "SELECT id, created_at, consumed_at, consumed_by FROM setup_tokens ORDER BY created_at DESC;"
170+ echo "If none is outstanding and you still need admin, promote a user directly:"
171+ c_dim " ./run.sh psql -c \"UPDATE users SET is_admin = true WHERE handle = 'your-handle';\""
172+}
173+
174+cmd_shell() {
175+ docker exec -it "$CONTAINER" bash
176+}
177+
178+# /metrics is loopback-only by design (spec §7), so it is scraped from inside
179+# the container rather than over the network.
180+cmd_metrics() {
181+ docker exec "$CONTAINER" wget -qO- http://127.0.0.1:8080/metrics
182+}
183+
184+# The reconciling reindex (spec §4, §10). Runs in the worker, which is where the
185+# repository volume is mounted.
186+cmd_reindex() {
187+ if [[ -n "${1:-}" ]]; then
188+ "${COMPOSE[@]}" exec -T worker dogfood-admin reindex --repo "$1"
189+ else
190+ "${COMPOSE[@]}" exec -T worker dogfood-admin reindex --all
191+ fi
192+}
193+
194+case "${1:-deploy}" in
195+ ""|deploy) cmd_deploy ;;
196+ build) cmd_build ;;
197+ up) cmd_up ;;
198+ down) cmd_down ;;
199+ restart) cmd_restart ;;
200+ logs) shift; cmd_logs "$@" ;;
201+ status) cmd_status ;;
202+ test) cmd_test ;;
203+ check) cmd_check ;;
204+ fixtures) cmd_fixtures ;;
205+ psql) shift; cmd_psql "$@" ;;
206+ token) cmd_token ;;
207+ shell) cmd_shell ;;
208+ metrics) cmd_metrics ;;
209+ reindex) shift; cmd_reindex "${1:-}" ;;
210+ backup) shift; "$ROOT/scripts/backup.sh" backup "$@" ;;
211+ rehearse) shift; "$ROOT/scripts/backup.sh" rehearse "$@" ;;
212+ loadtest) shift; "$ROOT/scripts/loadtest.sh" "$@" ;;
213+ -h|--help|help)
214+ sed -n '2,36p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
215+ ;;
216+ *)
217+ c_red "unknown command: $1"
218+ sed -n '2,36p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
219+ exit 1
220+ ;;
221+esac
Ascripts/backup.sh+298−0
@@ −0,0 +1,298 @@
1+#!/usr/bin/env bash
2+#
3+# Dogfood — backup, restore, and the restore *rehearsal* (spec §10, M6).
4+#
5+# ./scripts/backup.sh backup [DIR] pg_dump + a snapshot of the repo volume
6+# ./scripts/backup.sh restore DIR restore both into the live stack
7+# ./scripts/backup.sh rehearse [DIR] restore into a throwaway database and
8+# prove the result is usable
9+#
10+# Spec §10:
11+#
12+# > Backups: nightly `pg_dump` plus a filesystem-level snapshot of
13+# > `/srv/repos`, both offsite. Test restores quarterly. Note that the database
14+# > and the repo volume can drift out of sync during a restore;
15+# > `dogfood-admin reindex --all` exists to reconcile them, and that recovery
16+# > path should be exercised at least once before the first real user.
17+#
18+# `rehearse` is that exercise, and it is the reason this script exists rather
19+# than a line in a runbook. It restores into a *separate* database, runs the
20+# reconciling reindex against it, and checks the result — so the recovery path
21+# is proven without touching production.
22+#
23+# The two artefacts are captured in this order, deliberately:
24+#
25+# 1. the repository volume, then
26+# 2. the database.
27+#
28+# A repository object that exists on disk but not in the index is invisible and
29+# fixable with `reindex`. A row that points at an object which was never captured
30+# is a broken page with no recovery. Capturing storage first makes the drift fall
31+# on the recoverable side.
32+
33+set -euo pipefail
34+
35+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
36+COMPOSE=(docker compose -f "$ROOT/docker/compose.yaml")
37+PG_IMAGE="postgres:17-alpine"
38+VOLUME="dogfood_repos"
39+
40+c_red() { printf '\033[31m%s\033[0m\n' "$*" >&2; }
41+c_green() { printf '\033[32m%s\033[0m\n' "$*"; }
42+c_dim() { printf '\033[2m%s\033[0m\n' "$*"; }
43+
44+env_get() { sed -n "s/^$1=//p" "$ROOT/.env" | head -1; }
45+
46+require_env() {
47+ [[ -f "$ROOT/.env" ]] || { c_red "no .env at $ROOT/.env"; exit 1; }
48+ DATABASE_URL="$(env_get DATABASE_URL)"
49+ [[ -n "$DATABASE_URL" ]] || { c_red "DATABASE_URL is not set in .env"; exit 1; }
50+}
51+
52+# ─── backup ──────────────────────────────────────────────────────────────────
53+
54+cmd_backup() {
55+ require_env
56+ local dir="${1:-$ROOT/backups/$(date -u +%Y%m%dT%H%M%SZ)}"
57+ mkdir -p "$dir"
58+
59+ # 1. Repository storage first (see the note at the top).
60+ c_dim "snapshotting the repository volume…"
61+ docker run --rm \
62+ -v "$VOLUME":/srv/repos:ro \
63+ -v "$dir":/backup \
64+ "$PG_IMAGE" \
65+ tar -C /srv/repos -czf /backup/repos.tar.gz .
66+
67+ # 2. The database. `--no-owner` and `--no-acl` so a restore does not depend
68+ # on the role names of the instance it came from.
69+ c_dim "dumping the database…"
70+ docker run --rm -v "$dir":/backup "$PG_IMAGE" \
71+ pg_dump --no-owner --no-acl --format=custom \
72+ --file=/backup/dogfood.dump "$DATABASE_URL"
73+
74+ # A manifest, so a restore can tell what it is holding without opening it.
75+ cat > "$dir/manifest.txt" <<EOF
76+dogfood backup
77+taken_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)
78+host: $(hostname)
79+repos_bytes: $(stat -c %s "$dir/repos.tar.gz")
80+dump_bytes: $(stat -c %s "$dir/dogfood.dump")
81+note: storage was captured before the database, so any drift is recoverable
82+ with 'dogfood-admin reindex --all'.
83+EOF
84+
85+ c_green "backup written to $dir"
86+ cat "$dir/manifest.txt"
87+}
88+
89+# ─── restore ─────────────────────────────────────────────────────────────────
90+
91+cmd_restore() {
92+ require_env
93+ local dir="${1:?usage: backup.sh restore DIR}"
94+ [[ -f "$dir/dogfood.dump" && -f "$dir/repos.tar.gz" ]] \
95+ || { c_red "$dir does not look like a Dogfood backup"; exit 1; }
96+
97+ c_red "This overwrites the live database and repository volume."
98+ read -rp "Type RESTORE to continue: " confirm
99+ [[ "$confirm" == "RESTORE" ]] || { echo "aborted"; exit 1; }
100+
101+ c_dim "stopping the stack…"
102+ "${COMPOSE[@]}" down
103+
104+ c_dim "restoring the repository volume…"
105+ docker run --rm -v "$VOLUME":/srv/repos -v "$dir":/backup:ro "$PG_IMAGE" \
106+ sh -c 'rm -rf /srv/repos/* && tar -C /srv/repos -xzf /backup/repos.tar.gz'
107+
108+ c_dim "restoring the database…"
109+ docker run --rm -v "$dir":/backup:ro "$PG_IMAGE" \
110+ pg_restore --clean --if-exists --no-owner --no-acl \
111+ --dbname "$DATABASE_URL" /backup/dogfood.dump
112+
113+ c_dim "starting the stack…"
114+ "${COMPOSE[@]}" up -d
115+
116+ # The whole point of §10's note: after a restore the two halves may disagree,
117+ # and this is what reconciles them.
118+ c_dim "reconciling the index with storage…"
119+ docker compose -f "$ROOT/docker/compose.yaml" exec -T worker \
120+ dogfood-admin reindex --all
121+
122+ c_green "restore complete"
123+}
124+
125+# ─── rehearsal ───────────────────────────────────────────────────────────────
126+
127+cmd_rehearse() {
128+ require_env
129+ local dir="${1:-}"
130+
131+ # No backup named: take one now, so the rehearsal exercises the real path
132+ # end to end rather than an artefact somebody prepared by hand.
133+ if [[ -z "$dir" ]]; then
134+ dir="$(mktemp -d)/backup"
135+ c_dim "no backup given; taking one into $dir"
136+ cmd_backup "$dir" >/dev/null
137+ fi
138+
139+ # Not `local`: the EXIT trap below runs after this function's frame is gone,
140+ # and a `local` would be unbound by then — which under `set -u` turns a
141+ # successful rehearsal into a non-zero exit during cleanup.
142+ pg_name="dogfood-restore-rehearsal-$$"
143+ repo_vol="dogfood-rehearsal-repos-$$"
144+ worker_name="dogfood-rehearsal-worker-$$"
145+
146+ cleanup() {
147+ c_dim "cleaning up the rehearsal environment…"
148+ docker rm -f "$worker_name" >/dev/null 2>&1 || true
149+ docker rm -f "$pg_name" >/dev/null 2>&1 || true
150+ docker volume rm "$repo_vol" >/dev/null 2>&1 || true
151+ }
152+ trap cleanup EXIT
153+
154+ c_dim "starting a throwaway database…"
155+ docker run -d --name "$pg_name" \
156+ -e POSTGRES_PASSWORD=rehearse -e POSTGRES_DB=dogfood \
157+ "$PG_IMAGE" >/dev/null
158+
159+ # Wait for it rather than sleeping a guess.
160+ for _ in $(seq 1 60); do
161+ docker exec "$pg_name" pg_isready -U postgres >/dev/null 2>&1 && break
162+ sleep 1
163+ done
164+ docker exec "$pg_name" pg_isready -U postgres >/dev/null \
165+ || { c_red "the rehearsal database never became ready"; exit 1; }
166+
167+ local url="postgres://postgres:rehearse@127.0.0.1:5432/dogfood"
168+
169+ c_dim "restoring the dump…"
170+ docker cp "$dir/dogfood.dump" "$pg_name":/tmp/dogfood.dump
171+ docker exec "$pg_name" pg_restore --clean --if-exists --no-owner --no-acl \
172+ --dbname "$url" /tmp/dogfood.dump
173+
174+ c_dim "restoring the repository volume…"
175+ docker volume create "$repo_vol" >/dev/null
176+ docker run --rm -v "$repo_vol":/srv/repos -v "$dir":/backup:ro "$PG_IMAGE" \
177+ tar -C /srv/repos -xzf /backup/repos.tar.gz
178+
179+ # ── the checks that make this a rehearsal rather than a copy ──────────────
180+ c_dim "checking the restored database…"
181+
182+ local checks_failed=0
183+ check() {
184+ local label="$1" sql="$2"
185+ local out
186+ out="$(docker exec "$pg_name" psql -tAX -U postgres -d dogfood -c "$sql" 2>&1 | tr -d '[:space:]')"
187+ if [[ "$out" == "t" || "$out" == "0" ]]; then
188+ printf ' \033[32m✓\033[0m %s\n' "$label"
189+ else
190+ printf ' \033[31m✗\033[0m %s (got: %s)\n' "$label" "$out"
191+ checks_failed=$((checks_failed + 1))
192+ fi
193+ }
194+
195+ check "schema is present" \
196+ "SELECT to_regclass('public.repos') IS NOT NULL"
197+ check "migrations are recorded" \
198+ "SELECT count(*) = 0 FROM _sqlx_migrations WHERE NOT success"
199+ check "no change points at a missing repository" \
200+ "SELECT count(*) FROM changes c LEFT JOIN repos r ON r.id = c.repo_id WHERE r.id IS NULL"
201+ check "no revision points at a missing change" \
202+ "SELECT count(*) FROM revisions v LEFT JOIN changes c ON c.id = v.change_id_fk WHERE c.id IS NULL"
203+ check "no comment is anchored to a missing revision" \
204+ "SELECT count(*) FROM comments m
205+ WHERE m.anchor_revision IS NOT NULL
206+ AND NOT EXISTS (SELECT 1 FROM revisions v WHERE v.id = m.anchor_revision)"
207+
208+ # Every repository row must have storage behind it. This is the drift §10
209+ # warns about, and the one the reindex exists to reconcile.
210+ c_dim "checking that every repository row has storage…"
211+ local missing=0 total=0
212+ while read -r id; do
213+ [[ -n "$id" ]] || continue
214+ total=$((total + 1))
215+ local hex="${id//-/}"
216+ if ! docker run --rm -v "$repo_vol":/srv/repos:ro "$PG_IMAGE" \
217+ test -d "/srv/repos/${hex:0:2}/${hex}.git"; then
218+ missing=$((missing + 1))
219+ c_dim " missing storage for repo $id"
220+ fi
221+ done < <(docker exec "$pg_name" psql -tAX -U postgres -d dogfood -c "SELECT id FROM repos")
222+
223+ printf ' %s %d of %d repositories have storage\n' \
224+ "$([[ $missing -eq 0 ]] && printf '\033[32m✓\033[0m' || printf '\033[33m!\033[0m')" \
225+ "$((total - missing))" "$total"
226+
227+ if [[ $missing -gt 0 ]]; then
228+ c_dim " (recoverable: 'dogfood-admin reindex --all' reconciles the index"
229+ c_dim " with storage, but it cannot invent objects that were not captured)"
230+ fi
231+
232+ # The reconciling reindex itself, against the rehearsal environment. This is
233+ # the §10 recovery path, actually run — queue the work, then run a worker
234+ # long enough to drain it, then check that it drained cleanly.
235+ #
236+ # `--entrypoint` is required: the worker image's entrypoint is the worker
237+ # daemon, so passing `dogfood-admin …` as the command would start the
238+ # daemon with those words as arguments and never return.
239+ c_dim "queueing 'dogfood-admin reindex --all' against the restore…"
240+ if docker run --rm --network "container:$pg_name" \
241+ --entrypoint dogfood-admin \
242+ -v "$repo_vol":/srv/repos \
243+ -e DATABASE_URL="$url" -e REPO_ROOT=/srv/repos \
244+ dogfood-worker reindex --all; then
245+ printf ' \033[32m✓\033[0m reindex queued\n'
246+ else
247+ printf ' \033[31m✗\033[0m reindex could not be queued\n'
248+ checks_failed=$((checks_failed + 1))
249+ fi
250+
251+ c_dim "running a worker against the restore until the queue drains…"
252+ docker run -d --name "$worker_name" --network "container:$pg_name" \
253+ -v "$repo_vol":/srv/repos \
254+ -e DATABASE_URL="$url" -e REPO_ROOT=/srv/repos \
255+ dogfood-worker >/dev/null
256+
257+ local drained=0
258+ for _ in $(seq 1 60); do
259+ local queued
260+ queued="$(docker exec "$pg_name" psql -tAX -U postgres -d dogfood \
261+ -c "SELECT count(*) FROM jobs WHERE locked_at IS NULL" 2>/dev/null | tr -d '[:space:]')"
262+ if [[ "$queued" == "0" ]]; then drained=1; break; fi
263+ sleep 1
264+ done
265+
266+ local failed
267+ failed="$(docker exec "$pg_name" psql -tAX -U postgres -d dogfood \
268+ -c "SELECT count(*) FROM jobs WHERE attempts >= max_attempts" 2>/dev/null | tr -d '[:space:]')"
269+
270+ docker logs "$worker_name" 2>&1 | grep -E '"level":"(ERROR|WARN)"' | head -5 || true
271+ docker rm -f "$worker_name" >/dev/null 2>&1 || true
272+
273+ if [[ "$drained" == "1" && "$failed" == "0" ]]; then
274+ printf ' \033[32m✓\033[0m the queue drained with no failed jobs\n'
275+ else
276+ printf ' \033[31m✗\033[0m reindex did not complete (drained=%s failed=%s)\n' \
277+ "$drained" "$failed"
278+ checks_failed=$((checks_failed + 1))
279+ fi
280+
281+ echo
282+ if [[ $checks_failed -eq 0 ]]; then
283+ c_green "restore rehearsal passed — this backup is restorable"
284+ else
285+ c_red "restore rehearsal FAILED $checks_failed check(s)"
286+ exit 1
287+ fi
288+}
289+
290+case "${1:-}" in
291+ backup) shift; cmd_backup "$@" ;;
292+ restore) shift; cmd_restore "$@" ;;
293+ rehearse) shift; cmd_rehearse "$@" ;;
294+ *)
295+ sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
296+ exit 1
297+ ;;
298+esac
Ascripts/check-store-boundary.sh+62−0
@@ −0,0 +1,62 @@
1+#!/usr/bin/env bash
2+#
3+# Enforce spec §3 rule 1: "No crate other than `df-store` may depend on `gix`.
4+# Add this to CI as a dependency check."
5+#
6+# The whole value of the RepoStore abstraction is that swapping in a jj-native
7+# backend later touches one crate. That guarantee is worth exactly as much as
8+# this check — the moment a second crate reaches for `gix`, the boundary is
9+# gone and nobody notices until the migration.
10+
11+set -euo pipefail
12+
13+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
14+FAIL=0
15+
16+echo "==> checking the df-store boundary"
17+
18+# 1. Only df-store may declare gix as a dependency.
19+while IFS= read -r manifest; do
20+ crate="$(basename "$(dirname "$manifest")")"
21+ [[ "$crate" == "df-store" ]] && continue
22+
23+ if grep -qE '^\s*gix\s*[=.]|^\s*gix\s*=\s*\{' "$manifest"; then
24+ echo " FAIL: $crate declares a dependency on gix ($manifest)"
25+ FAIL=1
26+ fi
27+done < <(find "$ROOT/crates" -name Cargo.toml)
28+
29+# 2. Only df-store may name gix in its source.
30+while IFS= read -r file; do
31+ case "$file" in
32+ "$ROOT"/crates/df-store/*) continue ;;
33+ esac
34+ if grep -nE '(^|[^A-Za-z_])gix::|extern crate gix' "$file" >/dev/null 2>&1; then
35+ echo " FAIL: $(realpath --relative-to="$ROOT" "$file") references gix::"
36+ grep -nE '(^|[^A-Za-z_])gix::|extern crate gix' "$file" | head -3 | sed 's/^/ /'
37+ FAIL=1
38+ fi
39+done < <(find "$ROOT/crates" -name '*.rs')
40+
41+# 3. RevId must stay opaque: nothing outside df-store may slice or measure it.
42+# Spec §3 rule 2 — "do not parse it, do not assume it is 40 hex characters,
43+# do not abbreviate it outside df-store."
44+while IFS= read -r file; do
45+ case "$file" in
46+ "$ROOT"/crates/df-store/*) continue ;;
47+ esac
48+ if grep -nE '\.as_str\(\)\s*\[|rev\s*\[\s*\.\.|\.rev\.as_str\(\)\[' "$file" >/dev/null 2>&1; then
49+ echo " FAIL: $(realpath --relative-to="$ROOT" "$file") appears to slice a RevId"
50+ FAIL=1
51+ fi
52+done < <(find "$ROOT/crates" -name '*.rs')
53+
54+if [[ $FAIL -eq 0 ]]; then
55+ echo " ok — gix is confined to df-store, RevId is opaque"
56+else
57+ echo
58+ echo "The storage boundary exists so that moving to a jj-native backend is a"
59+ echo "swap of one implementation rather than a rewrite. Route what you need"
60+ echo "through the RepoStore trait instead."
61+ exit 1
62+fi
Ascripts/loadtest.sh+162−0
@@ −0,0 +1,162 @@
1+#!/usr/bin/env bash
2+#
3+# Dogfood — load test against a large repository (spec §11, M6).
4+#
5+# > load test against a large repository (use the Linux kernel or the jj repo
6+# > itself)
7+#
8+# ./scripts/loadtest.sh [URL] [CONCURRENCY] [DURATION_SECONDS]
9+#
10+# What this measures, and why these three things:
11+#
12+# * **Cold browse.** Tree and blob pages on a large repository, with the
13+# highlight cache empty. This is the worst case the cache exists to remove,
14+# and the number to compare against the warm run.
15+# * **Warm browse.** The same pages again. If warm is not dramatically faster
16+# than cold, the highlight cache is not working, which is the §8 performance
17+# mistake spelled out by name.
18+# * **The change list.** Spec §4 calls it "the hottest page in the product",
19+# and it is the one that reads precomputed stack edges rather than walking
20+# the commit graph — so a regression there is a regression in that decision.
21+#
22+# It drives the *public* HTTP surface with curl, so it measures what a user
23+# experiences rather than what a benchmark harness inside the process would.
24+
25+set -euo pipefail
26+
27+URL="${1:-https://dogfood.sh}"
28+CONCURRENCY="${2:-8}"
29+DURATION="${3:-20}"
30+
31+c_green() { printf '\033[32m%s\033[0m\n' "$*"; }
32+c_dim() { printf '\033[2m%s\033[0m\n' "$*"; }
33+c_red() { printf '\033[31m%s\033[0m\n' "$*" >&2; }
34+
35+command -v curl >/dev/null || { c_red "curl is required"; exit 1; }
36+
37+# ─── pick a target ───────────────────────────────────────────────────────────
38+
39+# The repository to hammer. Overridable, because "a large repository" is
40+# whatever the instance actually has — the point is that it is large, not that
41+# it is a particular one.
42+REPO_PATH="${DF_LOADTEST_REPO:-}"
43+
44+# A file inside that repository with a grammar behind it, so the cold/warm
45+# comparison actually measures highlighting. A path that does not exist would
46+# 404 in microseconds and make the cache look infinitely fast.
47+BLOB_PATH="${DF_LOADTEST_BLOB:-/blob/main/README.md}"
48+
49+if [[ -z "$REPO_PATH" ]]; then
50+ c_red "Set DF_LOADTEST_REPO to a repository path, e.g. DF_LOADTEST_REPO=/dogfood/linux"
51+ echo
52+ echo "Import one first:"
53+ echo " jj git clone --colocate https://github.com/jj-vcs/jj /tmp/jj"
54+ echo " cd /tmp/jj && jj git remote add dogfood $URL/<owner>/jj.git && jj git push --all"
55+ exit 1
56+fi
57+
58+# ─── helpers ─────────────────────────────────────────────────────────────────
59+
60+# One sample per line: seconds, then status.
61+#
62+# The newline is part of the same write. Several workers append to one file
63+# concurrently, and a write short enough to be atomic keeps their lines from
64+# interleaving — which is why this is not `curl … && echo`.
65+timed() {
66+ curl -s -o /dev/null -w '%{time_total} %{http_code}\n' --max-time 60 "$1"
67+}
68+
69+# Drive one path with N workers for D seconds; print count, error count, and the
70+# p50/p95/p99 of the response times.
71+#
72+# Percentiles rather than a mean: spec §10 says to alert on push latency p99, and
73+# a mean hides exactly the tail that matters on a large repository.
74+hammer() {
75+ local label="$1" path="$2"
76+ local tmp
77+ tmp="$(mktemp)"
78+
79+ local deadline=$((SECONDS + DURATION))
80+ for _ in $(seq 1 "$CONCURRENCY"); do
81+ (
82+ while [[ $SECONDS -lt $deadline ]]; do
83+ timed "$URL$path" >> "$tmp" || true
84+ done
85+ ) &
86+ done
87+ wait
88+
89+ # Percentiles are computed over *successful* requests only. A 429 returns in
90+ # microseconds, so mixing them in would report a rate-limited run as the
91+ # fastest one — the opposite of the truth.
92+ local total ok throttled failed
93+ total="$(wc -l < "$tmp")"
94+ ok="$(awk '$2 == 200' "$tmp" | wc -l)"
95+ throttled="$(awk '$2 == 429' "$tmp" | wc -l)"
96+ failed=$((total - ok - throttled))
97+
98+ # `asort` is a gawk extension the default Debian `mawk` does not have, so
99+ # sorting happens in sort(1) and awk only indexes.
100+ awk '$2 == 200 { print $1 * 1000 }' "$tmp" | sort -n | awk \
101+ -v label="$label" -v ok="$ok" -v throttled="$throttled" -v failed="$failed" '
102+ { t[NR] = $1 }
103+ END {
104+ n = NR
105+ if (n == 0) {
106+ printf "%-22s ok=0 throttled=%-5d failed=%-4d (no successful samples)\n",
107+ label, throttled, failed
108+ exit
109+ }
110+ i50 = int(n * 0.50); if (i50 < 1) i50 = 1
111+ i95 = int(n * 0.95); if (i95 < 1) i95 = 1
112+ i99 = int(n * 0.99); if (i99 < 1) i99 = 1
113+ printf "%-22s ok=%-6d throttled=%-5d failed=%-4d p50=%7.1fms p95=%7.1fms p99=%7.1fms\n",
114+ label, ok, throttled, failed, t[i50], t[i95], t[i99]
115+ }
116+ '
117+
118+ rm -f "$tmp"
119+}
120+
121+# ─── run ─────────────────────────────────────────────────────────────────────
122+
123+echo "Dogfood load test"
124+echo " target $URL$REPO_PATH"
125+echo " concurrency $CONCURRENCY"
126+echo " duration ${DURATION}s per phase"
127+echo
128+
129+# Reachability first, so a typo in the URL fails in one second rather than after
130+# four phases of zeros.
131+code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$URL$REPO_PATH" || true)"
132+[[ "$code" == "200" ]] || { c_red "$URL$REPO_PATH returned $code — is it public?"; exit 1; }
133+
134+c_dim "phase 1: cold browse (highlight cache may be empty)"
135+hammer "tree (cold)" "$REPO_PATH"
136+hammer "blob (cold)" "$REPO_PATH$BLOB_PATH"
137+
138+c_dim "phase 2: warm browse (same pages, cache populated)"
139+hammer "tree (warm)" "$REPO_PATH"
140+hammer "blob (warm)" "$REPO_PATH$BLOB_PATH"
141+
142+c_dim "phase 3: the hottest page (spec §4)"
143+hammer "change list" "$REPO_PATH/changes"
144+hammer "change list (all)" "$REPO_PATH/changes?state=all"
145+
146+c_dim "phase 4: history and bookmarks"
147+hammer "log" "$REPO_PATH/log?limit=100"
148+hammer "bookmarks" "$REPO_PATH/bookmarks"
149+
150+echo
151+c_green "done"
152+echo
153+echo "What to look for:"
154+echo " · blob (warm) should be several times faster than blob (cold). If it is"
155+echo " not, the highlight cache is not being hit — spec §8 calls that 'the"
156+echo " easiest performance mistake to make here'."
157+echo " · throttled > 0 is the rate limiter working as designed (spec §9): every"
158+echo " request here comes from one address, so at concurrency ${CONCURRENCY} it will"
159+echo " engage. Percentiles above are over successful requests only."
160+echo " · failed > 0 is a real problem — a timeout or a 5xx."
161+echo " · the change list should not degrade with repository size — it reads"
162+echo " precomputed stack edges, never the commit graph (spec §4)."