forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0

🎉 feat: initial commit of forge

Tahinli/forge

3c314e0321f2064e63dd7e065978558dd286b696

Author Ahmet Kaan Gümüş ·

Root commit (no parents)

Browse files at this commit

🎉 feat: initial commit of forge

Self-hosted git forge in Rust: web UI, SSH and smart HTTP git,
issues, pull requests with reviews and merge rules, CI with a
sandboxed runner, webhooks, notifications, releases, LFS, mirrors,
replication and scheduled backups.

300 changed files +119653 -0

Showing the first 300 files; this commit changes more.

.gitignoreadded+12 -0
@@ -0,0 +1,12 @@
1+/target
2+/data
3+*.db
4+*.db-*
5+forge.key
6+ssh_host_ed25519_key
7+.env
8+*.pem
9+.env.*
10+
11+# local config; copy config/forge.example.toml
12+/config/forge.toml
Cargo.lockadded+8717 -0

Diff too large; view file

@@ -0,0 +1,8717 @@
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 0.1.7",
18+ "generic-array 0.14.7",
19+]
20+
21+[[package]]
22+name = "aead"
23+version = "0.6.1"
24+source = "registry+https://github.com/rust-lang/crates.io-index"
25+checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
26+dependencies = [
27+ "crypto-common 0.2.2",
28+ "inout 0.2.3",
29+]
30+
31+[[package]]
32+name = "aegis"
33+version = "0.9.20"
34+source = "registry+https://github.com/rust-lang/crates.io-index"
35+checksum = "41585f248ed9406567ae6d61ca2a55ed3dc7348e65203a01229881253acb95e3"
36+dependencies = [
37+ "cc",
38+ "softaes",
39+]
40+
41+[[package]]
42+name = "aes"
43+version = "0.8.4"
44+source = "registry+https://github.com/rust-lang/crates.io-index"
45+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
46+dependencies = [
47+ "cfg-if",
48+ "cipher 0.4.4",
49+ "cpufeatures 0.2.17",
50+]
51+
52+[[package]]
53+name = "aes"
54+version = "0.9.3"
55+source = "registry+https://github.com/rust-lang/crates.io-index"
56+checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
57+dependencies = [
58+ "cipher 0.5.2",
59+ "cpubits",
60+ "cpufeatures 0.3.1",
61+ "zeroize",
62+]
63+
64+[[package]]
65+name = "aes-gcm"
66+version = "0.10.3"
67+source = "registry+https://github.com/rust-lang/crates.io-index"
68+checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
69+dependencies = [
70+ "aead 0.5.2",
71+ "aes 0.8.4",
72+ "cipher 0.4.4",
73+ "ctr 0.9.2",
74+ "ghash 0.5.1",
75+ "subtle",
76+]
77+
78+[[package]]
79+name = "aes-gcm"
80+version = "0.11.1"
81+source = "registry+https://github.com/rust-lang/crates.io-index"
82+checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f"
83+dependencies = [
84+ "aead 0.6.1",
85+ "aes 0.9.3",
86+ "cipher 0.5.2",
87+ "ctr 0.10.1",
88+ "ctutils",
89+ "ghash 0.6.0",
90+ "zeroize",
91+]
92+
93+[[package]]
94+name = "ahash"
95+version = "0.8.12"
96+source = "registry+https://github.com/rust-lang/crates.io-index"
97+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
98+dependencies = [
99+ "cfg-if",
100+ "once_cell",
101+ "version_check",
102+ "zerocopy",
103+]
104+
105+[[package]]
106+name = "aho-corasick"
107+version = "1.1.5"
108+source = "registry+https://github.com/rust-lang/crates.io-index"
109+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
110+dependencies = [
111+ "memchr",
112+]
113+
114+[[package]]
115+name = "alloc-no-stdlib"
116+version = "3.0.0"
117+source = "registry+https://github.com/rust-lang/crates.io-index"
118+checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
119+
120+[[package]]
121+name = "alloc-stdlib"
122+version = "0.3.0"
123+source = "registry+https://github.com/rust-lang/crates.io-index"
124+checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
125+dependencies = [
126+ "alloc-no-stdlib",
127+]
128+
129+[[package]]
130+name = "allocator-api2"
131+version = "0.2.21"
132+source = "registry+https://github.com/rust-lang/crates.io-index"
133+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
134+
135+[[package]]
136+name = "allocator-api2"
137+version = "0.4.0"
138+source = "registry+https://github.com/rust-lang/crates.io-index"
139+checksum = "c880a97d28a3681c0267bd29cff89621202715b065127cd445fa0f0fe0aa2880"
140+
141+[[package]]
142+name = "ammonia"
143+version = "4.2.3"
144+source = "registry+https://github.com/rust-lang/crates.io-index"
145+checksum = "061e83b03c2681c18a6787d956e355c74e0b98ba7ba3d69b0822ade1e6f1d716"
146+dependencies = [
147+ "cssparser 0.38.0",
148+ "html5ever 0.40.1",
149+ "maplit",
150+ "url",
151+]
152+
153+[[package]]
154+name = "android_system_properties"
155+version = "0.1.6"
156+source = "registry+https://github.com/rust-lang/crates.io-index"
157+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
158+dependencies = [
159+ "libc",
160+]
161+
162+[[package]]
163+name = "anstream"
164+version = "1.0.0"
165+source = "registry+https://github.com/rust-lang/crates.io-index"
166+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
167+dependencies = [
168+ "anstyle",
169+ "anstyle-parse",
170+ "anstyle-query",
171+ "anstyle-wincon",
172+ "colorchoice",
173+ "is_terminal_polyfill",
174+ "utf8parse",
175+]
176+
177+[[package]]
178+name = "anstyle"
179+version = "1.0.14"
180+source = "registry+https://github.com/rust-lang/crates.io-index"
181+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
182+
183+[[package]]
184+name = "anstyle-parse"
185+version = "1.0.0"
186+source = "registry+https://github.com/rust-lang/crates.io-index"
187+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
188+dependencies = [
189+ "utf8parse",
190+]
191+
192+[[package]]
193+name = "anstyle-query"
194+version = "1.1.5"
195+source = "registry+https://github.com/rust-lang/crates.io-index"
196+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
197+dependencies = [
198+ "windows-sys 0.61.2",
199+]
200+
201+[[package]]
202+name = "anstyle-wincon"
203+version = "3.0.11"
204+source = "registry+https://github.com/rust-lang/crates.io-index"
205+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
206+dependencies = [
207+ "anstyle",
208+ "once_cell_polyfill",
209+ "windows-sys 0.61.2",
210+]
211+
212+[[package]]
213+name = "antithesis_sdk"
214+version = "0.3.0"
215+source = "registry+https://github.com/rust-lang/crates.io-index"
216+checksum = "a8ed6751597568a284314fd9e2db420665898600109dedc7d8d9b588f00ae9c4"
217+dependencies = [
218+ "libc",
219+ "libloading",
220+ "linkme",
221+ "once_cell",
222+ "rand 0.8.8",
223+ "rustc_version_runtime",
224+ "serde",
225+ "serde_json",
226+]
227+
228+[[package]]
229+name = "anyhow"
230+version = "1.0.104"
231+source = "registry+https://github.com/rust-lang/crates.io-index"
232+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
233+
234+[[package]]
235+name = "anymap3"
236+version = "1.1.0"
237+source = "registry+https://github.com/rust-lang/crates.io-index"
238+checksum = "fb5dfbc6d8d2675589ccbe4d0fd61df2419075625f8c1a62325e718e2b0049f9"
239+
240+[[package]]
241+name = "arc-swap"
242+version = "1.9.2"
243+source = "registry+https://github.com/rust-lang/crates.io-index"
244+checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
245+dependencies = [
246+ "rustversion",
247+]
248+
249+[[package]]
250+name = "argon2"
251+version = "0.6.0"
252+source = "registry+https://github.com/rust-lang/crates.io-index"
253+checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c"
254+dependencies = [
255+ "base64ct",
256+ "blake2",
257+ "cpufeatures 0.3.1",
258+ "password-hash",
259+]
260+
261+[[package]]
262+name = "aristo"
263+version = "0.4.1"
264+source = "registry+https://github.com/rust-lang/crates.io-index"
265+checksum = "fcdeefa800110050103e2459a2f8dbdbd560ad046e30f1f87e24ce19c2cb8bde"
266+dependencies = [
267+ "aristo-macros",
268+]
269+
270+[[package]]
271+name = "aristo-macros"
272+version = "0.4.1"
273+source = "registry+https://github.com/rust-lang/crates.io-index"
274+checksum = "d64a66d21a80182b35b1741997a6d2456911f54b7eb1918aa4e2382fc205268d"
275+dependencies = [
276+ "proc-macro2",
277+ "quote",
278+ "syn 2.0.119",
279+]
280+
281+[[package]]
282+name = "arrayvec"
283+version = "0.7.8"
284+source = "registry+https://github.com/rust-lang/crates.io-index"
285+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
286+
287+[[package]]
288+name = "assoc"
289+version = "0.1.3"
290+source = "registry+https://github.com/rust-lang/crates.io-index"
291+checksum = "bfdc70193dadb9d7287fa4b633f15f90c876915b31f6af17da307fc59c9859a8"
292+
293+[[package]]
294+name = "async-compression"
295+version = "0.4.50"
296+source = "registry+https://github.com/rust-lang/crates.io-index"
297+checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1"
298+dependencies = [
299+ "compression-codecs",
300+ "compression-core",
301+ "pin-project-lite",
302+ "tokio",
303+]
304+
305+[[package]]
306+name = "async-trait"
307+version = "0.1.92"
308+source = "registry+https://github.com/rust-lang/crates.io-index"
309+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
310+dependencies = [
311+ "proc-macro2",
312+ "quote",
313+ "syn 3.0.9",
314+]
315+
316+[[package]]
317+name = "atomic-waker"
318+version = "1.1.2"
319+source = "registry+https://github.com/rust-lang/crates.io-index"
320+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
321+
322+[[package]]
323+name = "autocfg"
324+version = "1.5.1"
325+source = "registry+https://github.com/rust-lang/crates.io-index"
326+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
327+
328+[[package]]
329+name = "aws-lc-rs"
330+version = "1.18.2"
331+source = "registry+https://github.com/rust-lang/crates.io-index"
332+checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13"
333+dependencies = [
334+ "aws-lc-sys",
335+ "untrusted 0.7.1",
336+ "zeroize",
337+]
338+
339+[[package]]
340+name = "aws-lc-sys"
341+version = "0.46.0"
342+source = "registry+https://github.com/rust-lang/crates.io-index"
343+checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc"
344+dependencies = [
345+ "cc",
346+ "cmake",
347+ "dunce",
348+ "fs_extra",
349+ "pkg-config",
350+]
351+
352+[[package]]
353+name = "base16ct"
354+version = "1.0.0"
355+source = "registry+https://github.com/rust-lang/crates.io-index"
356+checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
357+
358+[[package]]
359+name = "base64"
360+version = "0.22.1"
361+source = "registry+https://github.com/rust-lang/crates.io-index"
362+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
363+
364+[[package]]
365+name = "base64"
366+version = "0.23.1"
367+source = "registry+https://github.com/rust-lang/crates.io-index"
368+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
369+
370+[[package]]
371+name = "base64ct"
372+version = "1.8.3"
373+source = "registry+https://github.com/rust-lang/crates.io-index"
374+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
375+
376+[[package]]
377+name = "bcrypt-pbkdf"
378+version = "0.11.0"
379+source = "registry+https://github.com/rust-lang/crates.io-index"
380+checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0"
381+dependencies = [
382+ "blowfish",
383+ "pbkdf2",
384+ "sha2 0.11.1",
385+]
386+
387+[[package]]
388+name = "bigdecimal"
389+version = "0.4.11"
390+source = "registry+https://github.com/rust-lang/crates.io-index"
391+checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653"
392+dependencies = [
393+ "autocfg",
394+ "libm",
395+ "num-bigint 0.4.8",
396+ "num-integer",
397+ "num-traits",
398+]
399+
400+[[package]]
401+name = "bincode"
402+version = "1.3.3"
403+source = "registry+https://github.com/rust-lang/crates.io-index"
404+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
405+dependencies = [
406+ "serde",
407+]
408+
409+[[package]]
410+name = "bindgen"
411+version = "0.69.5"
412+source = "registry+https://github.com/rust-lang/crates.io-index"
413+checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088"
414+dependencies = [
415+ "bitflags 2.13.2",
416+ "cexpr",
417+ "clang-sys",
418+ "itertools 0.12.1",
419+ "lazy_static",
420+ "lazycell",
421+ "log",
422+ "prettyplease 0.2.37",
423+ "proc-macro2",
424+ "quote",
425+ "regex",
426+ "rustc-hash 1.1.0",
427+ "shlex 1.3.0",
428+ "syn 2.0.119",
429+ "which",
430+]
431+
432+[[package]]
433+name = "bit-set"
434+version = "0.8.0"
435+source = "registry+https://github.com/rust-lang/crates.io-index"
436+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
437+dependencies = [
438+ "bit-vec",
439+]
440+
441+[[package]]
442+name = "bit-vec"
443+version = "0.8.0"
444+source = "registry+https://github.com/rust-lang/crates.io-index"
445+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
446+
447+[[package]]
448+name = "bitflags"
449+version = "1.3.2"
450+source = "registry+https://github.com/rust-lang/crates.io-index"
451+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
452+
453+[[package]]
454+name = "bitflags"
455+version = "2.13.2"
456+source = "registry+https://github.com/rust-lang/crates.io-index"
457+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
458+
459+[[package]]
460+name = "bitpacking"
461+version = "0.9.3"
462+source = "registry+https://github.com/rust-lang/crates.io-index"
463+checksum = "96a7139abd3d9cebf8cd6f920a389cf3dc9576172e32f4563f188cae3c3eb019"
464+dependencies = [
465+ "crunchy",
466+]
467+
468+[[package]]
469+name = "bitvec"
470+version = "1.1.1"
471+source = "registry+https://github.com/rust-lang/crates.io-index"
472+checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837"
473+dependencies = [
474+ "funty",
475+ "radium",
476+ "tap",
477+ "wyz",
478+]
479+
480+[[package]]
481+name = "blake2"
482+version = "0.11.0"
483+source = "registry+https://github.com/rust-lang/crates.io-index"
484+checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
485+dependencies = [
486+ "digest 0.11.3",
487+]
488+
489+[[package]]
490+name = "block-buffer"
491+version = "0.10.4"
492+source = "registry+https://github.com/rust-lang/crates.io-index"
493+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
494+dependencies = [
495+ "generic-array 0.14.7",
496+]
497+
498+[[package]]
499+name = "block-buffer"
500+version = "0.12.1"
501+source = "registry+https://github.com/rust-lang/crates.io-index"
502+checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
503+dependencies = [
504+ "hybrid-array",
505+ "zeroize",
506+]
507+
508+[[package]]
509+name = "block-padding"
510+version = "0.4.2"
511+source = "registry+https://github.com/rust-lang/crates.io-index"
512+checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
513+dependencies = [
514+ "hybrid-array",
515+]
516+
517+[[package]]
518+name = "blowfish"
519+version = "0.10.0"
520+source = "registry+https://github.com/rust-lang/crates.io-index"
521+checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298"
522+dependencies = [
523+ "byteorder",
524+ "cipher 0.5.2",
525+]
526+
527+[[package]]
528+name = "bon"
529+version = "3.10.2"
530+source = "registry+https://github.com/rust-lang/crates.io-index"
531+checksum = "214f5df094ce551a10a30ffb9c70243d61f121a3d985a6495933e181dee6a7d2"
532+dependencies = [
533+ "bon-macros",
534+]
535+
536+[[package]]
537+name = "bon-macros"
538+version = "3.10.2"
539+source = "registry+https://github.com/rust-lang/crates.io-index"
540+checksum = "2706da6c749998cc555d04184909608956a69ad3f8ab1a076fbc867b4a3c3ce5"
541+dependencies = [
542+ "darling 0.24.1",
543+ "ident_case",
544+ "prettyplease 0.3.0",
545+ "proc-macro2",
546+ "quote",
547+ "syn 3.0.9",
548+]
549+
550+[[package]]
551+name = "branches"
552+version = "0.4.6"
553+source = "registry+https://github.com/rust-lang/crates.io-index"
554+checksum = "7436b21fd6195415058eb41c9090e156b556bc7e0377bb57c5c026a421521525"
555+dependencies = [
556+ "rustc_version",
557+]
558+
559+[[package]]
560+name = "brotli"
561+version = "9.0.0"
562+source = "registry+https://github.com/rust-lang/crates.io-index"
563+checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b"
564+dependencies = [
565+ "alloc-no-stdlib",
566+ "alloc-stdlib",
567+ "brotli-decompressor",
568+]
569+
570+[[package]]
571+name = "brotli-decompressor"
572+version = "6.0.1"
573+source = "registry+https://github.com/rust-lang/crates.io-index"
574+checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7"
575+dependencies = [
576+ "alloc-no-stdlib",
577+ "alloc-stdlib",
578+]
579+
580+[[package]]
581+name = "bstr"
582+version = "1.13.1"
583+source = "registry+https://github.com/rust-lang/crates.io-index"
584+checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
585+dependencies = [
586+ "memchr",
587+ "regex-automata",
588+ "serde_core",
589+]
590+
591+[[package]]
592+name = "bumpalo"
593+version = "3.20.3"
594+source = "registry+https://github.com/rust-lang/crates.io-index"
595+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
596+
597+[[package]]
598+name = "bytemuck"
599+version = "1.25.2"
600+source = "registry+https://github.com/rust-lang/crates.io-index"
601+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
602+dependencies = [
603+ "bytemuck_derive",
604+]
605+
606+[[package]]
607+name = "bytemuck_derive"
608+version = "1.12.1"
609+source = "registry+https://github.com/rust-lang/crates.io-index"
610+checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
611+dependencies = [
612+ "proc-macro2",
613+ "quote",
614+ "syn 3.0.9",
615+]
616+
617+[[package]]
618+name = "byteorder"
619+version = "1.5.0"
620+source = "registry+https://github.com/rust-lang/crates.io-index"
621+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
622+
623+[[package]]
624+name = "byteorder-lite"
625+version = "0.1.0"
626+source = "registry+https://github.com/rust-lang/crates.io-index"
627+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
628+
629+[[package]]
630+name = "bytes"
631+version = "1.12.1"
632+source = "registry+https://github.com/rust-lang/crates.io-index"
633+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
634+
635+[[package]]
636+name = "caps"
637+version = "0.5.6"
638+source = "registry+https://github.com/rust-lang/crates.io-index"
639+checksum = "fd1ddba47aba30b6a889298ad0109c3b8dcb0e8fc993b459daa7067d46f865e0"
640+dependencies = [
641+ "libc",
642+]
643+
644+[[package]]
645+name = "caseless"
646+version = "0.2.2"
647+source = "registry+https://github.com/rust-lang/crates.io-index"
648+checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8"
649+dependencies = [
650+ "unicode-normalization",
651+]
652+
653+[[package]]
654+name = "cbc"
655+version = "0.2.1"
656+source = "registry+https://github.com/rust-lang/crates.io-index"
657+checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
658+dependencies = [
659+ "cipher 0.5.2",
660+]
661+
662+[[package]]
663+name = "cc"
664+version = "1.8.0"
665+source = "registry+https://github.com/rust-lang/crates.io-index"
666+checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838"
667+dependencies = [
668+ "find-msvc-tools",
669+ "jobserver",
670+ "libc",
671+ "shlex 2.0.1",
672+]
673+
674+[[package]]
675+name = "census"
676+version = "0.4.2"
677+source = "registry+https://github.com/rust-lang/crates.io-index"
678+checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0"
679+
680+[[package]]
681+name = "cexpr"
682+version = "0.6.0"
683+source = "registry+https://github.com/rust-lang/crates.io-index"
684+checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
685+dependencies = [
686+ "nom 7.1.3",
687+]
688+
689+[[package]]
690+name = "cfg-if"
691+version = "1.0.5"
692+source = "registry+https://github.com/rust-lang/crates.io-index"
693+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
694+
695+[[package]]
696+name = "cfg_aliases"
697+version = "0.2.2"
698+source = "registry+https://github.com/rust-lang/crates.io-index"
699+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
700+
701+[[package]]
702+name = "cfg_block"
703+version = "0.1.1"
704+source = "registry+https://github.com/rust-lang/crates.io-index"
705+checksum = "18758054972164c3264f7c8386f5fc6da6114cb46b619fd365d4e3b2dc3ae487"
706+
707+[[package]]
708+name = "chacha20"
709+version = "0.10.2"
710+source = "registry+https://github.com/rust-lang/crates.io-index"
711+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
712+dependencies = [
713+ "cfg-if",
714+ "cipher 0.5.2",
715+ "cpufeatures 0.3.1",
716+ "rand_core 0.10.1",
717+ "zeroize",
718+]
719+
720+[[package]]
721+name = "chacha20poly1305"
722+version = "0.11.0"
723+source = "registry+https://github.com/rust-lang/crates.io-index"
724+checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb"
725+dependencies = [
726+ "aead 0.6.1",
727+ "chacha20",
728+ "cipher 0.5.2",
729+ "poly1305",
730+]
731+
732+[[package]]
733+name = "chrono"
734+version = "0.4.45"
735+source = "registry+https://github.com/rust-lang/crates.io-index"
736+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
737+dependencies = [
738+ "iana-time-zone",
739+ "js-sys",
740+ "num-traits",
741+ "serde",
742+ "wasm-bindgen",
743+ "windows-link",
744+]
745+
746+[[package]]
747+name = "cipher"
748+version = "0.4.4"
749+source = "registry+https://github.com/rust-lang/crates.io-index"
750+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
751+dependencies = [
752+ "crypto-common 0.1.7",
753+ "inout 0.1.4",
754+]
755+
756+[[package]]
757+name = "cipher"
758+version = "0.5.2"
759+source = "registry+https://github.com/rust-lang/crates.io-index"
760+checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
761+dependencies = [
762+ "block-buffer 0.12.1",
763+ "crypto-common 0.2.2",
764+ "inout 0.2.3",
765+ "zeroize",
766+]
767+
768+[[package]]
769+name = "clang-sys"
770+version = "1.9.1"
771+source = "registry+https://github.com/rust-lang/crates.io-index"
772+checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
773+dependencies = [
774+ "glob",
775+ "libc",
776+ "libloading",
777+]
778+
779+[[package]]
780+name = "clap"
781+version = "4.6.7"
782+source = "registry+https://github.com/rust-lang/crates.io-index"
783+checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
784+dependencies = [
785+ "clap_builder",
786+ "clap_derive",
787+]
788+
789+[[package]]
790+name = "clap_builder"
791+version = "4.6.7"
792+source = "registry+https://github.com/rust-lang/crates.io-index"
793+checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
794+dependencies = [
795+ "anstream",
796+ "anstyle",
797+ "clap_lex",
798+ "strsim",
799+ "terminal_size",
800+]
801+
802+[[package]]
803+name = "clap_derive"
804+version = "4.6.7"
805+source = "registry+https://github.com/rust-lang/crates.io-index"
806+checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
807+dependencies = [
808+ "heck",
809+ "proc-macro2",
810+ "quote",
811+ "syn 3.0.9",
812+]
813+
814+[[package]]
815+name = "clap_lex"
816+version = "1.1.1"
817+source = "registry+https://github.com/rust-lang/crates.io-index"
818+checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
819+
820+[[package]]
821+name = "clru"
822+version = "0.6.3"
823+source = "registry+https://github.com/rust-lang/crates.io-index"
824+checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5"
825+dependencies = [
826+ "hashbrown 0.16.1",
827+]
828+
829+[[package]]
830+name = "cmake"
831+version = "0.1.58"
832+source = "registry+https://github.com/rust-lang/crates.io-index"
833+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
834+dependencies = [
835+ "cc",
836+]
837+
838+[[package]]
839+name = "cmov"
840+version = "0.5.4"
841+source = "registry+https://github.com/rust-lang/crates.io-index"
842+checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
843+
844+[[package]]
845+name = "codemap"
846+version = "0.1.3"
847+source = "registry+https://github.com/rust-lang/crates.io-index"
848+checksum = "b9e769b5c8c8283982a987c6e948e540254f1058d5a74b8794914d4ef5fc2a24"
849+
850+[[package]]
851+name = "color_quant"
852+version = "1.1.0"
853+source = "registry+https://github.com/rust-lang/crates.io-index"
854+checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
855+
856+[[package]]
857+name = "colorchoice"
858+version = "1.0.5"
859+source = "registry+https://github.com/rust-lang/crates.io-index"
860+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
861+
862+[[package]]
863+name = "combine"
864+version = "4.6.8"
865+source = "registry+https://github.com/rust-lang/crates.io-index"
866+checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
867+dependencies = [
868+ "bytes",
869+ "memchr",
870+]
871+
872+[[package]]
873+name = "compression-codecs"
874+version = "0.4.45"
875+source = "registry+https://github.com/rust-lang/crates.io-index"
876+checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a"
877+dependencies = [
878+ "brotli",
879+ "compression-core",
880+ "flate2",
881+ "memchr",
882+]
883+
884+[[package]]
885+name = "compression-core"
886+version = "0.4.33"
887+source = "registry+https://github.com/rust-lang/crates.io-index"
888+checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
889+
890+[[package]]
891+name = "comrak"
892+version = "0.56.0"
893+source = "registry+https://github.com/rust-lang/crates.io-index"
894+checksum = "2544c4d6b6da7d54e95ac2833b464bf3dffa0666237868f2bf4ec5e11c9ba8cc"
895+dependencies = [
896+ "bon",
897+ "caseless",
898+ "clap",
899+ "emojis",
900+ "entities",
901+ "finl_unicode",
902+ "fmt2io",
903+ "memchr-n",
904+ "phf 0.14.0",
905+ "phf_codegen 0.14.0",
906+ "rustc-hash 2.1.3",
907+ "shell-words",
908+ "smallvec",
909+ "syntect",
910+ "typed-arena",
911+ "xdg",
912+]
913+
914+[[package]]
915+name = "concurrent-queue"
916+version = "2.5.0"
917+source = "registry+https://github.com/rust-lang/crates.io-index"
918+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
919+dependencies = [
920+ "crossbeam-utils",
921+]
922+
923+[[package]]
924+name = "const-oid"
925+version = "0.10.2"
926+source = "registry+https://github.com/rust-lang/crates.io-index"
927+checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
928+
929+[[package]]
930+name = "const_format"
931+version = "0.2.36"
932+source = "registry+https://github.com/rust-lang/crates.io-index"
933+checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e"
934+dependencies = [
935+ "const_format_proc_macros",
936+ "konst",
937+]
938+
939+[[package]]
940+name = "const_format_proc_macros"
941+version = "0.2.34"
942+source = "registry+https://github.com/rust-lang/crates.io-index"
943+checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744"
944+dependencies = [
945+ "proc-macro2",
946+ "quote",
947+ "unicode-xid",
948+]
949+
950+[[package]]
951+name = "cookie"
952+version = "0.18.2"
953+source = "registry+https://github.com/rust-lang/crates.io-index"
954+checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
955+dependencies = [
956+ "aes-gcm 0.10.3",
957+ "base64 0.22.1",
958+ "hkdf 0.12.4",
959+ "hmac 0.12.1",
960+ "percent-encoding",
961+ "rand 0.8.8",
962+ "sha2 0.10.9",
963+ "subtle",
964+ "time",
965+ "version_check",
966+]
967+
968+[[package]]
969+name = "core-foundation"
970+version = "0.10.1"
971+source = "registry+https://github.com/rust-lang/crates.io-index"
972+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
973+dependencies = [
974+ "core-foundation-sys",
975+ "libc",
976+]
977+
978+[[package]]
979+name = "core-foundation-sys"
980+version = "0.8.7"
981+source = "registry+https://github.com/rust-lang/crates.io-index"
982+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
983+
984+[[package]]
985+name = "core_detect"
986+version = "1.0.0"
987+source = "registry+https://github.com/rust-lang/crates.io-index"
988+checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
989+
990+[[package]]
991+name = "cpubits"
992+version = "0.1.1"
993+source = "registry+https://github.com/rust-lang/crates.io-index"
994+checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
995+
996+[[package]]
997+name = "cpufeatures"
998+version = "0.2.17"
999+source = "registry+https://github.com/rust-lang/crates.io-index"
1000+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
1001+dependencies = [
1002+ "libc",
1003+]
1004+
1005+[[package]]
1006+name = "cpufeatures"
1007+version = "0.3.1"
1008+source = "registry+https://github.com/rust-lang/crates.io-index"
1009+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
1010+dependencies = [
1011+ "libc",
1012+]
1013+
1014+[[package]]
1015+name = "crc32c"
1016+version = "0.6.8"
1017+source = "registry+https://github.com/rust-lang/crates.io-index"
1018+checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47"
1019+dependencies = [
1020+ "rustc_version",
1021+]
1022+
1023+[[package]]
1024+name = "crc32fast"
1025+version = "1.5.2"
1026+source = "registry+https://github.com/rust-lang/crates.io-index"
1027+checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
1028+dependencies = [
1029+ "cfg-if",
1030+]
1031+
1032+[[package]]
1033+name = "crossbeam-channel"
1034+version = "0.5.17"
1035+source = "registry+https://github.com/rust-lang/crates.io-index"
1036+checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
1037+dependencies = [
1038+ "crossbeam-utils",
1039+]
1040+
1041+[[package]]
1042+name = "crossbeam-deque"
1043+version = "0.8.8"
1044+source = "registry+https://github.com/rust-lang/crates.io-index"
1045+checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
1046+dependencies = [
1047+ "crossbeam-epoch",
1048+ "crossbeam-utils",
1049+]
1050+
1051+[[package]]
1052+name = "crossbeam-epoch"
1053+version = "0.9.21"
1054+source = "registry+https://github.com/rust-lang/crates.io-index"
1055+checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
1056+dependencies = [
1057+ "crossbeam-utils",
1058+]
1059+
1060+[[package]]
1061+name = "crossbeam-utils"
1062+version = "0.8.23"
1063+source = "registry+https://github.com/rust-lang/crates.io-index"
1064+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
1065+
1066+[[package]]
1067+name = "crunchy"
1068+version = "0.2.4"
1069+source = "registry+https://github.com/rust-lang/crates.io-index"
1070+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
1071+
1072+[[package]]
1073+name = "crypto-bigint"
1074+version = "0.7.5"
1075+source = "registry+https://github.com/rust-lang/crates.io-index"
1076+checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271"
1077+dependencies = [
1078+ "cpubits",
1079+ "ctutils",
1080+ "getrandom 0.4.3",
1081+ "hybrid-array",
1082+ "num-traits",
1083+ "rand_core 0.10.1",
1084+ "serdect",
1085+ "subtle",
1086+ "zeroize",
1087+]
1088+
1089+[[package]]
1090+name = "crypto-common"
1091+version = "0.1.7"
1092+source = "registry+https://github.com/rust-lang/crates.io-index"
1093+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
1094+dependencies = [
1095+ "generic-array 0.14.7",
1096+ "rand_core 0.6.4",
1097+ "typenum",
1098+]
1099+
1100+[[package]]
1101+name = "crypto-common"
1102+version = "0.2.2"
1103+source = "registry+https://github.com/rust-lang/crates.io-index"
1104+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
1105+dependencies = [
1106+ "getrandom 0.4.3",
1107+ "hybrid-array",
1108+ "rand_core 0.10.1",
1109+]
1110+
1111+[[package]]
1112+name = "crypto-primes"
1113+version = "0.7.2"
1114+source = "registry+https://github.com/rust-lang/crates.io-index"
1115+checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f"
1116+dependencies = [
1117+ "crypto-bigint",
1118+ "rand_core 0.10.1",
1119+]
1120+
1121+[[package]]
1122+name = "cssparser"
1123+version = "0.37.0"
1124+source = "registry+https://github.com/rust-lang/crates.io-index"
1125+checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
1126+dependencies = [
1127+ "cssparser-macros",
1128+ "dtoa-short",
1129+ "itoa",
1130+ "phf 0.13.1",
1131+ "smallvec",
1132+]
1133+
1134+[[package]]
1135+name = "cssparser"
1136+version = "0.38.0"
1137+source = "registry+https://github.com/rust-lang/crates.io-index"
1138+checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59"
1139+dependencies = [
1140+ "dtoa-short",
1141+ "itoa",
1142+ "smallvec",
1143+]
1144+
1145+[[package]]
1146+name = "cssparser-macros"
1147+version = "0.7.1"
1148+source = "registry+https://github.com/rust-lang/crates.io-index"
1149+checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
1150+dependencies = [
1151+ "quote",
1152+ "syn 3.0.9",
1153+]
1154+
1155+[[package]]
1156+name = "ctr"
1157+version = "0.9.2"
1158+source = "registry+https://github.com/rust-lang/crates.io-index"
1159+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
1160+dependencies = [
1161+ "cipher 0.4.4",
1162+]
1163+
1164+[[package]]
1165+name = "ctr"
1166+version = "0.10.1"
1167+source = "registry+https://github.com/rust-lang/crates.io-index"
1168+checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
1169+dependencies = [
1170+ "cipher 0.5.2",
1171+]
1172+
1173+[[package]]
1174+name = "ctutils"
1175+version = "0.4.3"
1176+source = "registry+https://github.com/rust-lang/crates.io-index"
1177+checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
1178+dependencies = [
1179+ "cmov",
1180+ "subtle",
1181+]
1182+
1183+[[package]]
1184+name = "curve25519-dalek"
1185+version = "5.0.0"
1186+source = "registry+https://github.com/rust-lang/crates.io-index"
1187+checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
1188+dependencies = [
1189+ "cfg-if",
1190+ "cpufeatures 0.3.1",
1191+ "curve25519-dalek-derive",
1192+ "digest 0.11.3",
1193+ "fiat-crypto",
1194+ "rand_core 0.10.1",
1195+ "rustc_version",
1196+ "subtle",
1197+ "zeroize",
1198+]
1199+
1200+[[package]]
1201+name = "curve25519-dalek-derive"
1202+version = "0.1.1"
1203+source = "registry+https://github.com/rust-lang/crates.io-index"
1204+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
1205+dependencies = [
1206+ "proc-macro2",
1207+ "quote",
1208+ "syn 2.0.119",
1209+]
1210+
1211+[[package]]
1212+name = "darling"
1213+version = "0.20.11"
1214+source = "registry+https://github.com/rust-lang/crates.io-index"
1215+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
1216+dependencies = [
1217+ "darling_core 0.20.11",
1218+ "darling_macro 0.20.11",
1219+]
1220+
1221+[[package]]
1222+name = "darling"
1223+version = "0.24.1"
1224+source = "registry+https://github.com/rust-lang/crates.io-index"
1225+checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
1226+dependencies = [
1227+ "darling_core 0.24.1",
1228+ "darling_macro 0.24.1",
1229+]
1230+
1231+[[package]]
1232+name = "darling_core"
1233+version = "0.20.11"
1234+source = "registry+https://github.com/rust-lang/crates.io-index"
1235+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
1236+dependencies = [
1237+ "fnv",
1238+ "ident_case",
1239+ "proc-macro2",
1240+ "quote",
1241+ "strsim",
1242+ "syn 2.0.119",
1243+]
1244+
1245+[[package]]
1246+name = "darling_core"
1247+version = "0.24.1"
1248+source = "registry+https://github.com/rust-lang/crates.io-index"
1249+checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
1250+dependencies = [
1251+ "ident_case",
1252+ "proc-macro2",
1253+ "quote",
1254+ "strsim",
1255+ "syn 3.0.9",
1256+]
1257+
1258+[[package]]
1259+name = "darling_macro"
1260+version = "0.20.11"
1261+source = "registry+https://github.com/rust-lang/crates.io-index"
1262+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
1263+dependencies = [
1264+ "darling_core 0.20.11",
1265+ "quote",
1266+ "syn 2.0.119",
1267+]
1268+
1269+[[package]]
1270+name = "darling_macro"
1271+version = "0.24.1"
1272+source = "registry+https://github.com/rust-lang/crates.io-index"
1273+checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
1274+dependencies = [
1275+ "darling_core 0.24.1",
1276+ "quote",
1277+ "syn 3.0.9",
1278+]
1279+
1280+[[package]]
1281+name = "dashmap"
1282+version = "6.2.1"
1283+source = "registry+https://github.com/rust-lang/crates.io-index"
1284+checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
1285+dependencies = [
1286+ "cfg-if",
1287+ "crossbeam-utils",
1288+ "hashbrown 0.14.5",
1289+ "lock_api",
1290+ "once_cell",
1291+ "parking_lot_core",
1292+]
1293+
1294+[[package]]
1295+name = "data-encoding"
1296+version = "2.11.1"
1297+source = "registry+https://github.com/rust-lang/crates.io-index"
1298+checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
1299+
1300+[[package]]
1301+name = "datasketches"
1302+version = "0.2.0"
1303+source = "registry+https://github.com/rust-lang/crates.io-index"
1304+checksum = "c286de4e81ea2590afc24d754e0f83810c566f50a1388fa75ebd57928c0d9745"
1305+
1306+[[package]]
1307+name = "defmt"
1308+version = "0.3.100"
1309+source = "registry+https://github.com/rust-lang/crates.io-index"
1310+checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad"
1311+dependencies = [
1312+ "defmt 1.1.1",
1313+]
1314+
1315+[[package]]
1316+name = "defmt"
1317+version = "1.1.1"
1318+source = "registry+https://github.com/rust-lang/crates.io-index"
1319+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
1320+dependencies = [
1321+ "bitflags 1.3.2",
1322+ "defmt-macros",
1323+]
1324+
1325+[[package]]
1326+name = "defmt-macros"
1327+version = "1.1.1"
1328+source = "registry+https://github.com/rust-lang/crates.io-index"
1329+checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
1330+dependencies = [
1331+ "defmt-parser",
1332+ "proc-macro2",
1333+ "quote",
1334+ "syn 2.0.119",
1335+]
1336+
1337+[[package]]
1338+name = "defmt-parser"
1339+version = "1.0.0"
1340+source = "registry+https://github.com/rust-lang/crates.io-index"
1341+checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
1342+dependencies = [
1343+ "thiserror 2.0.21",
1344+]
1345+
1346+[[package]]
1347+name = "delegate"
1348+version = "0.13.5"
1349+source = "registry+https://github.com/rust-lang/crates.io-index"
1350+checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370"
1351+dependencies = [
1352+ "proc-macro2",
1353+ "quote",
1354+ "syn 2.0.119",
1355+]
1356+
1357+[[package]]
1358+name = "der"
1359+version = "0.8.2"
1360+source = "registry+https://github.com/rust-lang/crates.io-index"
1361+checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a"
1362+dependencies = [
1363+ "const-oid",
1364+ "pem-rfc7468",
1365+ "zeroize",
1366+]
1367+
1368+[[package]]
1369+name = "deranged"
1370+version = "0.5.9"
1371+source = "registry+https://github.com/rust-lang/crates.io-index"
1372+checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
1373+dependencies = [
1374+ "serde_core",
1375+]
1376+
1377+[[package]]
1378+name = "derive_builder"
1379+version = "0.20.2"
1380+source = "registry+https://github.com/rust-lang/crates.io-index"
1381+checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
1382+dependencies = [
1383+ "derive_builder_macro",
1384+]
1385+
1386+[[package]]
1387+name = "derive_builder_core"
1388+version = "0.20.2"
1389+source = "registry+https://github.com/rust-lang/crates.io-index"
1390+checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
1391+dependencies = [
1392+ "darling 0.20.11",
1393+ "proc-macro2",
1394+ "quote",
1395+ "syn 2.0.119",
1396+]
1397+
1398+[[package]]
1399+name = "derive_builder_macro"
1400+version = "0.20.2"
1401+source = "registry+https://github.com/rust-lang/crates.io-index"
1402+checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
1403+dependencies = [
1404+ "derive_builder_core",
1405+ "syn 2.0.119",
1406+]
1407+
1408+[[package]]
1409+name = "derive_more"
1410+version = "2.1.1"
1411+source = "registry+https://github.com/rust-lang/crates.io-index"
1412+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
1413+dependencies = [
1414+ "derive_more-impl",
1415+]
1416+
1417+[[package]]
1418+name = "derive_more-impl"
1419+version = "2.1.1"
1420+source = "registry+https://github.com/rust-lang/crates.io-index"
1421+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
1422+dependencies = [
1423+ "proc-macro2",
1424+ "quote",
1425+ "rustc_version",
1426+ "syn 2.0.119",
1427+]
1428+
1429+[[package]]
1430+name = "des"
1431+version = "0.9.0"
1432+source = "registry+https://github.com/rust-lang/crates.io-index"
1433+checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a"
1434+dependencies = [
1435+ "cipher 0.5.2",
1436+]
1437+
1438+[[package]]
1439+name = "digest"
1440+version = "0.10.7"
1441+source = "registry+https://github.com/rust-lang/crates.io-index"
1442+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
1443+dependencies = [
1444+ "block-buffer 0.10.4",
1445+ "crypto-common 0.1.7",
1446+ "subtle",
1447+]
1448+
1449+[[package]]
1450+name = "digest"
1451+version = "0.11.3"
1452+source = "registry+https://github.com/rust-lang/crates.io-index"
1453+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
1454+dependencies = [
1455+ "block-buffer 0.12.1",
1456+ "const-oid",
1457+ "crypto-common 0.2.2",
1458+ "ctutils",
1459+]
1460+
1461+[[package]]
1462+name = "displaydoc"
1463+version = "0.2.7"
1464+source = "registry+https://github.com/rust-lang/crates.io-index"
1465+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
1466+dependencies = [
1467+ "proc-macro2",
1468+ "quote",
1469+ "syn 3.0.9",
1470+]
1471+
1472+[[package]]
1473+name = "downcast-rs"
1474+version = "2.0.2"
1475+source = "registry+https://github.com/rust-lang/crates.io-index"
1476+checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
1477+
1478+[[package]]
1479+name = "dtoa"
1480+version = "1.0.11"
1481+source = "registry+https://github.com/rust-lang/crates.io-index"
1482+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
1483+
1484+[[package]]
1485+name = "dtoa-short"
1486+version = "0.3.5"
1487+source = "registry+https://github.com/rust-lang/crates.io-index"
1488+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
1489+dependencies = [
1490+ "dtoa",
1491+]
1492+
1493+[[package]]
1494+name = "dunce"
1495+version = "1.0.5"
1496+source = "registry+https://github.com/rust-lang/crates.io-index"
1497+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
1498+
1499+[[package]]
1500+name = "ecdsa"
1501+version = "0.17.0"
1502+source = "registry+https://github.com/rust-lang/crates.io-index"
1503+checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0"
1504+dependencies = [
1505+ "der",
1506+ "digest 0.11.3",
1507+ "elliptic-curve",
1508+ "rfc6979",
1509+ "signature",
1510+ "spki",
1511+ "zeroize",
1512+]
1513+
1514+[[package]]
1515+name = "ed25519"
1516+version = "3.0.0"
1517+source = "registry+https://github.com/rust-lang/crates.io-index"
1518+checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
1519+dependencies = [
1520+ "pkcs8",
1521+ "signature",
1522+]
1523+
1524+[[package]]
1525+name = "ed25519-dalek"
1526+version = "3.0.0"
1527+source = "registry+https://github.com/rust-lang/crates.io-index"
1528+checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
1529+dependencies = [
1530+ "curve25519-dalek",
1531+ "ed25519",
1532+ "rand_core 0.10.1",
1533+ "serde",
1534+ "sha2 0.11.1",
1535+ "signature",
1536+ "subtle",
1537+ "zeroize",
1538+]
1539+
1540+[[package]]
1541+name = "ego-tree"
1542+version = "0.11.0"
1543+source = "registry+https://github.com/rust-lang/crates.io-index"
1544+checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3"
1545+
1546+[[package]]
1547+name = "either"
1548+version = "1.19.0"
1549+source = "registry+https://github.com/rust-lang/crates.io-index"
1550+checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
1551+
1552+[[package]]
1553+name = "elliptic-curve"
1554+version = "0.14.1"
1555+source = "registry+https://github.com/rust-lang/crates.io-index"
1556+checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65"
1557+dependencies = [
1558+ "base16ct",
1559+ "crypto-bigint",
1560+ "crypto-common 0.2.2",
1561+ "digest 0.11.3",
1562+ "ff",
1563+ "group",
1564+ "hkdf 0.13.0",
1565+ "hybrid-array",
1566+ "pem-rfc7468",
1567+ "pkcs8",
1568+ "rand_core 0.10.1",
1569+ "sec1",
1570+ "subtle",
1571+ "zeroize",
1572+]
1573+
1574+[[package]]
1575+name = "elsa"
1576+version = "1.11.2"
1577+source = "registry+https://github.com/rust-lang/crates.io-index"
1578+checksum = "9abf33c656a7256451ebb7d0082c5a471820c31269e49d807c538c252352186e"
1579+dependencies = [
1580+ "stable_deref_trait",
1581+]
1582+
1583+[[package]]
1584+name = "email-encoding"
1585+version = "0.4.2"
1586+source = "registry+https://github.com/rust-lang/crates.io-index"
1587+checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd"
1588+dependencies = [
1589+ "base64 0.23.1",
1590+ "memchr",
1591+]
1592+
1593+[[package]]
1594+name = "email_address"
1595+version = "0.2.9"
1596+source = "registry+https://github.com/rust-lang/crates.io-index"
1597+checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
1598+
1599+[[package]]
1600+name = "emojis"
1601+version = "0.9.0"
1602+source = "registry+https://github.com/rust-lang/crates.io-index"
1603+checksum = "0b1514ced566c94991ed9563258ecf61e311fd773bf0a3f20606830386bf66e3"
1604+dependencies = [
1605+ "phf 0.13.1",
1606+]
1607+
1608+[[package]]
1609+name = "encoding_rs"
1610+version = "0.8.42"
1611+source = "registry+https://github.com/rust-lang/crates.io-index"
1612+checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
1613+dependencies = [
1614+ "cfg-if",
1615+ "core_detect",
1616+ "multiversion_no_op",
1617+ "rustversion",
1618+ "scopeguard",
1619+ "simdutf8",
1620+]
1621+
1622+[[package]]
1623+name = "entities"
1624+version = "1.0.1"
1625+source = "registry+https://github.com/rust-lang/crates.io-index"
1626+checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"
1627+
1628+[[package]]
1629+name = "enum_dispatch"
1630+version = "0.3.13"
1631+source = "registry+https://github.com/rust-lang/crates.io-index"
1632+checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd"
1633+dependencies = [
1634+ "once_cell",
1635+ "proc-macro2",
1636+ "quote",
1637+ "syn 2.0.119",
1638+]
1639+
1640+[[package]]
1641+name = "env_filter"
1642+version = "2.0.0"
1643+source = "registry+https://github.com/rust-lang/crates.io-index"
1644+checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
1645+dependencies = [
1646+ "log",
1647+ "regex",
1648+]
1649+
1650+[[package]]
1651+name = "env_logger"
1652+version = "0.11.11"
1653+source = "registry+https://github.com/rust-lang/crates.io-index"
1654+checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
1655+dependencies = [
1656+ "env_filter",
1657+ "log",
1658+]
1659+
1660+[[package]]
1661+name = "equivalent"
1662+version = "1.0.3"
1663+source = "registry+https://github.com/rust-lang/crates.io-index"
1664+checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
1665+
1666+[[package]]
1667+name = "erased-serde"
1668+version = "0.4.10"
1669+source = "registry+https://github.com/rust-lang/crates.io-index"
1670+checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
1671+dependencies = [
1672+ "serde",
1673+ "serde_core",
1674+ "typeid",
1675+]
1676+
1677+[[package]]
1678+name = "errno"
1679+version = "0.3.14"
1680+source = "registry+https://github.com/rust-lang/crates.io-index"
1681+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
1682+dependencies = [
1683+ "libc",
1684+ "windows-sys 0.61.2",
1685+]
1686+
1687+[[package]]
1688+name = "fallible-iterator"
1689+version = "0.3.0"
1690+source = "registry+https://github.com/rust-lang/crates.io-index"
1691+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
1692+
1693+[[package]]
1694+name = "fancy-regex"
1695+version = "0.16.2"
1696+source = "registry+https://github.com/rust-lang/crates.io-index"
1697+checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"
1698+dependencies = [
1699+ "bit-set",
1700+ "regex-automata",
1701+ "regex-syntax",
1702+]
1703+
1704+[[package]]
1705+name = "fastbloom"
1706+version = "0.14.1"
1707+source = "registry+https://github.com/rust-lang/crates.io-index"
1708+checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4"
1709+dependencies = [
1710+ "getrandom 0.3.4",
1711+ "libm",
1712+ "siphasher",
1713+]
1714+
1715+[[package]]
1716+name = "fastdivide"
1717+version = "0.4.2"
1718+source = "registry+https://github.com/rust-lang/crates.io-index"
1719+checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471"
1720+
1721+[[package]]
1722+name = "faster-hex"
1723+version = "0.10.1"
1724+source = "registry+https://github.com/rust-lang/crates.io-index"
1725+checksum = "04839bdf9d8c10f66806fad16b852fc72aab80873aebc3cb69d85b4fa41543ed"
1726+dependencies = [
1727+ "autocfg",
1728+ "defmt 0.3.100",
1729+ "heapless",
1730+ "serde",
1731+]
1732+
1733+[[package]]
1734+name = "fastrand"
1735+version = "2.5.0"
1736+source = "registry+https://github.com/rust-lang/crates.io-index"
1737+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
1738+
1739+[[package]]
1740+name = "fdeflate"
1741+version = "0.3.7"
1742+source = "registry+https://github.com/rust-lang/crates.io-index"
1743+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
1744+dependencies = [
1745+ "simd-adler32",
1746+]
1747+
1748+[[package]]
1749+name = "fearless_simd"
1750+version = "1.1.0"
1751+source = "registry+https://github.com/rust-lang/crates.io-index"
1752+checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611"
1753+
1754+[[package]]
1755+name = "fearless_simd_macros"
1756+version = "0.1.0"
1757+source = "registry+https://github.com/rust-lang/crates.io-index"
1758+checksum = "3e91d94c6885390b81782d7ee60fd6583b33907333a8b340de25ccf468353315"
1759+dependencies = [
1760+ "proc-macro2",
1761+ "quote",
1762+ "syn 3.0.9",
1763+]
1764+
1765+[[package]]
1766+name = "ff"
1767+version = "0.14.0"
1768+source = "registry+https://github.com/rust-lang/crates.io-index"
1769+checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f"
1770+dependencies = [
1771+ "rand_core 0.10.1",
1772+ "subtle",
1773+]
1774+
1775+[[package]]
1776+name = "fiat-crypto"
1777+version = "0.3.0"
1778+source = "registry+https://github.com/rust-lang/crates.io-index"
1779+checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
1780+
1781+[[package]]
1782+name = "filetime"
1783+version = "0.2.29"
1784+source = "registry+https://github.com/rust-lang/crates.io-index"
1785+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
1786+dependencies = [
1787+ "cfg-if",
1788+ "libc",
1789+]
1790+
1791+[[package]]
1792+name = "find-msvc-tools"
1793+version = "0.1.14"
1794+source = "registry+https://github.com/rust-lang/crates.io-index"
1795+checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
1796+
1797+[[package]]
1798+name = "finl_unicode"
1799+version = "1.5.0"
1800+source = "registry+https://github.com/rust-lang/crates.io-index"
1801+checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2"
1802+
1803+[[package]]
1804+name = "fixedbitset"
1805+version = "0.5.7"
1806+source = "registry+https://github.com/rust-lang/crates.io-index"
1807+checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
1808+
1809+[[package]]
1810+name = "flate2"
1811+version = "1.1.10"
1812+source = "registry+https://github.com/rust-lang/crates.io-index"
1813+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
1814+dependencies = [
1815+ "crc32fast",
1816+ "miniz_oxide 0.9.1",
1817+ "zlib-rs",
1818+]
1819+
1820+[[package]]
1821+name = "fmt2io"
1822+version = "1.0.0"
1823+source = "registry+https://github.com/rust-lang/crates.io-index"
1824+checksum = "6b6129284da9f7e5296cc22183a63f24300e945e297705dcc0672f7df01d62c8"
1825+
1826+[[package]]
1827+name = "fnv"
1828+version = "1.0.7"
1829+source = "registry+https://github.com/rust-lang/crates.io-index"
1830+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
1831+
1832+[[package]]
1833+name = "foldhash"
1834+version = "0.2.0"
1835+source = "registry+https://github.com/rust-lang/crates.io-index"
1836+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
1837+
1838+[[package]]
1839+name = "forge-ci"
1840+version = "0.1.0"
1841+dependencies = [
1842+ "base64 0.23.1",
1843+ "globset",
1844+ "serde",
1845+ "serde_json",
1846+ "thiserror 2.0.21",
1847+ "toml",
1848+]
1849+
1850+[[package]]
1851+name = "forge-core"
1852+version = "0.1.0"
1853+dependencies = [
1854+ "argon2",
1855+ "base64 0.23.1",
1856+ "chacha20poly1305",
1857+ "hex",
1858+ "hmac 0.13.0",
1859+ "image",
1860+ "ipnet",
1861+ "jiff",
1862+ "rand 0.10.3",
1863+ "semver",
1864+ "serde",
1865+ "serde_json",
1866+ "sha1 0.11.0",
1867+ "sha2 0.11.1",
1868+ "subtle",
1869+ "tempfile",
1870+ "thiserror 2.0.21",
1871+ "time",
1872+ "tokio",
1873+ "toml",
1874+ "tracing",
1875+ "turso",
1876+ "ulid",
1877+ "unicode-normalization",
1878+ "zip",
1879+]
1880+
1881+[[package]]
1882+name = "forge-git"
1883+version = "0.1.0"
1884+dependencies = [
1885+ "base64 0.23.1",
1886+ "forge-core",
1887+ "gix",
1888+ "hex",
1889+ "sha2 0.11.1",
1890+ "tempfile",
1891+ "thiserror 2.0.21",
1892+ "tokio",
1893+]
1894+
1895+[[package]]
1896+name = "forge-runner"
1897+version = "0.1.0"
1898+dependencies = [
1899+ "base64 0.23.1",
1900+ "bytes",
1901+ "clap",
1902+ "flate2",
1903+ "forge-ci",
1904+ "forge-runner",
1905+ "globset",
1906+ "hex",
1907+ "http-body-util",
1908+ "hyper",
1909+ "hyper-util",
1910+ "libcontainer",
1911+ "nix 0.31.3",
1912+ "oci-client",
1913+ "oci-spec",
1914+ "reqwest",
1915+ "seccompiler",
1916+ "serde",
1917+ "serde_json",
1918+ "sha2 0.11.1",
1919+ "tar",
1920+ "tempfile",
1921+ "thiserror 2.0.21",
1922+ "tokio",
1923+ "tokio-util",
1924+ "toml",
1925+ "tracing",
1926+ "tracing-subscriber",
1927+ "zstd 0.14.2",
1928+]
1929+
1930+[[package]]
1931+name = "forge-ssh"
1932+version = "0.1.0"
1933+dependencies = [
1934+ "bytes",
1935+ "forge-git",
1936+ "getrandom 0.4.3",
1937+ "russh",
1938+ "tempfile",
1939+ "thiserror 2.0.21",
1940+ "tokio",
1941+ "tracing",
1942+]
1943+
1944+[[package]]
1945+name = "forge-web"
1946+version = "0.1.0"
1947+dependencies = [
1948+ "ammonia",
1949+ "async-compression",
1950+ "base64 0.23.1",
1951+ "bytes",
1952+ "comrak",
1953+ "emojis",
1954+ "forge-ci",
1955+ "forge-core",
1956+ "forge-git",
1957+ "forge-runner",
1958+ "forge-ssh",
1959+ "form_urlencoded",
1960+ "futures-util",
1961+ "grass",
1962+ "hex",
1963+ "hmac 0.13.0",
1964+ "http",
1965+ "http-body",
1966+ "http-body-util",
1967+ "image",
1968+ "ipnet",
1969+ "jiff",
1970+ "lettre",
1971+ "multer",
1972+ "object_store",
1973+ "rand 0.10.3",
1974+ "reqwest",
1975+ "rpassword",
1976+ "scraper",
1977+ "serde",
1978+ "serde_json",
1979+ "sha2 0.11.1",
1980+ "similar",
1981+ "subtle",
1982+ "syntect",
1983+ "tempfile",
1984+ "time",
1985+ "tokio",
1986+ "tokio-util",
1987+ "topcoat",
1988+ "tower",
1989+ "tower-http 0.7.1",
1990+ "tracing",
1991+ "tracing-subscriber",
1992+ "ulid",
1993+ "zip",
1994+]
1995+
1996+[[package]]
1997+name = "form_urlencoded"
1998+version = "1.2.2"
1999+source = "registry+https://github.com/rust-lang/crates.io-index"
2000+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
Cargo.tomladded+124 -0
@@ -0,0 +1,124 @@
1+[workspace]
2+resolver = "3"
3+members = [
4+ "crates/forge-core",
5+ "crates/forge-git",
6+ "crates/forge-ssh",
7+ "crates/forge-web",
8+ "crates/forge-ci",
9+ "crates/forge-runner",
10+]
11+
12+[workspace.package]
13+version = "0.1.0"
14+edition = "2024"
15+license = "AGPL-3.0-or-later"
16+
17+[workspace.dependencies]
18+tower = { version = "0.5.3", features = ["util"] }
19+tower-http = { version = "0.7.1", features = ["compression-br", "compression-gzip"] }
20+gix = { version = "0.89.0", default-features = false, features = ["revision", "blob-diff", "max-performance-safe", "sha1"] }
21+comrak = "0.56.0"
22+emojis = "0.9.0"
23+ammonia = "4.2.3"
24+syntect = { version = "5.3.0", default-features = false, features = ["default-fancy"] }
25+hmac = "0.13.0"
26+sha1 = "0.11.0"
27+image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
28+# internal crates
29+forge-core = { path = "crates/forge-core" }
30+forge-ci = { path = "crates/forge-ci" }
31+forge-git = { path = "crates/forge-git" }
32+forge-ssh = { path = "crates/forge-ssh" }
33+forge-runner = { path = "crates/forge-runner" }
34+
35+# async runtime and HTTP plumbing
36+tokio = { version = "1.53.3", features = ["full"] }
37+futures-util = "0.3.34"
38+bytes = "1.12.1"
39+http = "1.5.0"
40+http-body-util = "0.1.5"
41+async-compression = { version = "0.4.50", features = ["tokio", "gzip"] }
42+tokio-util = "0.7.20"
43+http-body = "1.1.0"
44+
45+# errors, serialization, data
46+thiserror = "2.0.21"
47+globset = "0.4.20"
48+ipnet = "2.12.2"
49+serde = { version = "1.0.229", features = ["derive"] }
50+serde_json = "1.0.154"
51+toml = "1.1.8"
52+ulid = "3.0.0"
53+time = { version = "0.3.55", features = ["formatting", "parsing", "macros"] }
54+unicode-normalization = "0.1.25"
55+form_urlencoded = "1.2.2"
56+jiff = { version = "0.2.38", default-features = false, features = ["std", "tzdb-bundle-always"] }
57+
58+# crypto
59+argon2 = "0.6.0"
60+rand = "0.10.3"
61+sha2 = "0.11.1"
62+hex = "0.4.3"
63+subtle = "2.6.1"
64+base64 = "0.23.1"
65+getrandom = "0.4.3"
66+rpassword = "7.5.4"
67+chacha20poly1305 = "0.11.0"
68+
69+# storage
70+turso = "0.8.3"
71+object_store = { version = "0.14.2", default-features = false, features = ["fs"] }
72+multer = "3.1.0"
73+
74+# outgoing mail (forge-web): SMTP over rustls, no native TLS
75+lettre = { version = "0.11.23", default-features = false, features = ["smtp-transport", "builder", "tokio1-rustls", "ring", "webpki-roots", "pool"] }
76+
77+# web framework
78+topcoat = { version = "0.10.0", default-features = false, features = ["router", "view", "runtime", "cookie", "asset", "serve", "sse", "discover", "multipart"] }
79+
80+# ssh server
81+russh = "0.64.1"
82+
83+# observability
84+tracing = "0.1.44"
85+tracing-subscriber = "0.3.23"
86+
87+# OCI image pulls and layer unpacking (forge-runner)
88+oci-client = "0.18.0"
89+tar = "0.4.46"
90+flate2 = "1.1.10"
91+zstd = "0.14.2"
92+
93+# CLI and test servers (forge-runner)
94+clap = { version = "4.6.7", features = ["derive"] }
95+hyper = { version = "1.12.0", features = ["server", "http1"] }
96+hyper-util = { version = "0.1.21", features = ["tokio"] }
97+
98+# sandbox (forge-runner): in-process containers, no systemd, no youki CLI, no C libraries.
99+# The seccomp profile is built with the pure-Rust seccompiler (json feature resolves syscall names)
100+# and installed by forge-runner's own executor, so libcontainer's libseccomp feature stays off.
101+libcontainer = { version = "0.7.0", default-features = false, features = ["v2"] }
102+seccompiler = { version = "0.5.0", features = ["json"] }
103+oci-spec = "0.10.0"
104+nix = { version = "0.31.3", features = ["signal", "process", "mount", "sched", "user", "resource", "fs"] }
105+
106+# build and test
107+grass = "0.13.4"
108+tempfile = "3.27.0"
109+reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "form", "stream"] }
110+scraper = "0.27.0"
111+similar = "3.2.0"
112+
113+# security advisories (forge-core): semver matching and OSV zip dumps
114+semver = "1.0.28"
115+zip = { version = "9.0.3", default-features = false, features = ["deflate"] }
116+
117+[profile.release]
118+lto = true
119+codegen-units = 1
120+
121+[profile.dist]
122+inherits = "release"
123+lto = "thin"
124+codegen-units = 16
LICENSEadded+661 -0
@@ -0,0 +1,661 @@
1+ GNU AFFERO GENERAL PUBLIC LICENSE
2+ Version 3, 19 November 2007
3+
4+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
5+ Everyone is permitted to copy and distribute verbatim copies
6+ of this license document, but changing it is not allowed.
7+
8+ Preamble
9+
10+ The GNU Affero General Public License is a free, copyleft license for
11+software and other kinds of works, specifically designed to ensure
12+cooperation with the community in the case of network server software.
13+
14+ The licenses for most software and other practical works are designed
15+to take away your freedom to share and change the works. By contrast,
16+our General Public Licenses are intended to guarantee your freedom to
17+share and change all versions of a program--to make sure it remains free
18+software for all its users.
19+
20+ When we speak of free software, we are referring to freedom, not
21+price. Our General Public Licenses are designed to make sure that you
22+have the freedom to distribute copies of free software (and charge for
23+them if you wish), that you receive source code or can get it if you
24+want it, that you can change the software or use pieces of it in new
25+free programs, and that you know you can do these things.
26+
27+ Developers that use our General Public Licenses protect your rights
28+with two steps: (1) assert copyright on the software, and (2) offer
29+you this License which gives you legal permission to copy, distribute
30+and/or modify the software.
31+
32+ A secondary benefit of defending all users' freedom is that
33+improvements made in alternate versions of the program, if they
34+receive widespread use, become available for other developers to
35+incorporate. Many developers of free software are heartened and
36+encouraged by the resulting cooperation. However, in the case of
37+software used on network servers, this result may fail to come about.
38+The GNU General Public License permits making a modified version and
39+letting the public access it on a server without ever releasing its
40+source code to the public.
41+
42+ The GNU Affero General Public License is designed specifically to
43+ensure that, in such cases, the modified source code becomes available
44+to the community. It requires the operator of a network server to
45+provide the source code of the modified version running there to the
46+users of that server. Therefore, public use of a modified version, on
47+a publicly accessible server, gives the public access to the source
48+code of the modified version.
49+
50+ An older license, called the Affero General Public License and
51+published by Affero, was designed to accomplish similar goals. This is
52+a different license, not a version of the Affero GPL, but Affero has
53+released a new version of the Affero GPL which permits relicensing under
54+this license.
55+
56+ The precise terms and conditions for copying, distribution and
57+modification follow.
58+
59+ TERMS AND CONDITIONS
60+
61+ 0. Definitions.
62+
63+ "This License" refers to version 3 of the GNU Affero General Public License.
64+
65+ "Copyright" also means copyright-like laws that apply to other kinds of
66+works, such as semiconductor masks.
67+
68+ "The Program" refers to any copyrightable work licensed under this
69+License. Each licensee is addressed as "you". "Licensees" and
70+"recipients" may be individuals or organizations.
71+
72+ To "modify" a work means to copy from or adapt all or part of the work
73+in a fashion requiring copyright permission, other than the making of an
74+exact copy. The resulting work is called a "modified version" of the
75+earlier work or a work "based on" the earlier work.
76+
77+ A "covered work" means either the unmodified Program or a work based
78+on the Program.
79+
80+ To "propagate" a work means to do anything with it that, without
81+permission, would make you directly or secondarily liable for
82+infringement under applicable copyright law, except executing it on a
83+computer or modifying a private copy. Propagation includes copying,
84+distribution (with or without modification), making available to the
85+public, and in some countries other activities as well.
86+
87+ To "convey" a work means any kind of propagation that enables other
88+parties to make or receive copies. Mere interaction with a user through
89+a computer network, with no transfer of a copy, is not conveying.
90+
91+ An interactive user interface displays "Appropriate Legal Notices"
92+to the extent that it includes a convenient and prominently visible
93+feature that (1) displays an appropriate copyright notice, and (2)
94+tells the user that there is no warranty for the work (except to the
95+extent that warranties are provided), that licensees may convey the
96+work under this License, and how to view a copy of this License. If
97+the interface presents a list of user commands or options, such as a
98+menu, a prominent item in the list meets this criterion.
99+
100+ 1. Source Code.
101+
102+ The "source code" for a work means the preferred form of the work
103+for making modifications to it. "Object code" means any non-source
104+form of a work.
105+
106+ A "Standard Interface" means an interface that either is an official
107+standard defined by a recognized standards body, or, in the case of
108+interfaces specified for a particular programming language, one that
109+is widely used among developers working in that language.
110+
111+ The "System Libraries" of an executable work include anything, other
112+than the work as a whole, that (a) is included in the normal form of
113+packaging a Major Component, but which is not part of that Major
114+Component, and (b) serves only to enable use of the work with that
115+Major Component, or to implement a Standard Interface for which an
116+implementation is available to the public in source code form. A
117+"Major Component", in this context, means a major essential component
118+(kernel, window system, and so on) of the specific operating system
119+(if any) on which the executable work runs, or a compiler used to
120+produce the work, or an object code interpreter used to run it.
121+
122+ The "Corresponding Source" for a work in object code form means all
123+the source code needed to generate, install, and (for an executable
124+work) run the object code and to modify the work, including scripts to
125+control those activities. However, it does not include the work's
126+System Libraries, or general-purpose tools or generally available free
127+programs which are used unmodified in performing those activities but
128+which are not part of the work. For example, Corresponding Source
129+includes interface definition files associated with source files for
130+the work, and the source code for shared libraries and dynamically
131+linked subprograms that the work is specifically designed to require,
132+such as by intimate data communication or control flow between those
133+subprograms and other parts of the work.
134+
135+ The Corresponding Source need not include anything that users
136+can regenerate automatically from other parts of the Corresponding
137+Source.
138+
139+ The Corresponding Source for a work in source code form is that
140+same work.
141+
142+ 2. Basic Permissions.
143+
144+ All rights granted under this License are granted for the term of
145+copyright on the Program, and are irrevocable provided the stated
146+conditions are met. This License explicitly affirms your unlimited
147+permission to run the unmodified Program. The output from running a
148+covered work is covered by this License only if the output, given its
149+content, constitutes a covered work. This License acknowledges your
150+rights of fair use or other equivalent, as provided by copyright law.
151+
152+ You may make, run and propagate covered works that you do not
153+convey, without conditions so long as your license otherwise remains
154+in force. You may convey covered works to others for the sole purpose
155+of having them make modifications exclusively for you, or provide you
156+with facilities for running those works, provided that you comply with
157+the terms of this License in conveying all material for which you do
158+not control copyright. Those thus making or running the covered works
159+for you must do so exclusively on your behalf, under your direction
160+and control, on terms that prohibit them from making any copies of
161+your copyrighted material outside their relationship with you.
162+
163+ Conveying under any other circumstances is permitted solely under
164+the conditions stated below. Sublicensing is not allowed; section 10
165+makes it unnecessary.
166+
167+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168+
169+ No covered work shall be deemed part of an effective technological
170+measure under any applicable law fulfilling obligations under article
171+11 of the WIPO copyright treaty adopted on 20 December 1996, or
172+similar laws prohibiting or restricting circumvention of such
173+measures.
174+
175+ When you convey a covered work, you waive any legal power to forbid
176+circumvention of technological measures to the extent such circumvention
177+is effected by exercising rights under this License with respect to
178+the covered work, and you disclaim any intention to limit operation or
179+modification of the work as a means of enforcing, against the work's
180+users, your or third parties' legal rights to forbid circumvention of
181+technological measures.
182+
183+ 4. Conveying Verbatim Copies.
184+
185+ You may convey verbatim copies of the Program's source code as you
186+receive it, in any medium, provided that you conspicuously and
187+appropriately publish on each copy an appropriate copyright notice;
188+keep intact all notices stating that this License and any
189+non-permissive terms added in accord with section 7 apply to the code;
190+keep intact all notices of the absence of any warranty; and give all
191+recipients a copy of this License along with the Program.
192+
193+ You may charge any price or no price for each copy that you convey,
194+and you may offer support or warranty protection for a fee.
195+
196+ 5. Conveying Modified Source Versions.
197+
198+ You may convey a work based on the Program, or the modifications to
199+produce it from the Program, in the form of source code under the
200+terms of section 4, provided that you also meet all of these conditions:
201+
202+ a) The work must carry prominent notices stating that you modified
203+ it, and giving a relevant date.
204+
205+ b) The work must carry prominent notices stating that it is
206+ released under this License and any conditions added under section
207+ 7. This requirement modifies the requirement in section 4 to
208+ "keep intact all notices".
209+
210+ c) You must license the entire work, as a whole, under this
211+ License to anyone who comes into possession of a copy. This
212+ License will therefore apply, along with any applicable section 7
213+ additional terms, to the whole of the work, and all its parts,
214+ regardless of how they are packaged. This License gives no
215+ permission to license the work in any other way, but it does not
216+ invalidate such permission if you have separately received it.
217+
218+ d) If the work has interactive user interfaces, each must display
219+ Appropriate Legal Notices; however, if the Program has interactive
220+ interfaces that do not display Appropriate Legal Notices, your
221+ work need not make them do so.
222+
223+ A compilation of a covered work with other separate and independent
224+works, which are not by their nature extensions of the covered work,
225+and which are not combined with it such as to form a larger program,
226+in or on a volume of a storage or distribution medium, is called an
227+"aggregate" if the compilation and its resulting copyright are not
228+used to limit the access or legal rights of the compilation's users
229+beyond what the individual works permit. Inclusion of a covered work
230+in an aggregate does not cause this License to apply to the other
231+parts of the aggregate.
232+
233+ 6. Conveying Non-Source Forms.
234+
235+ You may convey a covered work in object code form under the terms
236+of sections 4 and 5, provided that you also convey the
237+machine-readable Corresponding Source under the terms of this License,
238+in one of these ways:
239+
240+ a) Convey the object code in, or embodied in, a physical product
241+ (including a physical distribution medium), accompanied by the
242+ Corresponding Source fixed on a durable physical medium
243+ customarily used for software interchange.
244+
245+ b) Convey the object code in, or embodied in, a physical product
246+ (including a physical distribution medium), accompanied by a
247+ written offer, valid for at least three years and valid for as
248+ long as you offer spare parts or customer support for that product
249+ model, to give anyone who possesses the object code either (1) a
250+ copy of the Corresponding Source for all the software in the
251+ product that is covered by this License, on a durable physical
252+ medium customarily used for software interchange, for a price no
253+ more than your reasonable cost of physically performing this
254+ conveying of source, or (2) access to copy the
255+ Corresponding Source from a network server at no charge.
256+
257+ c) Convey individual copies of the object code with a copy of the
258+ written offer to provide the Corresponding Source. This
259+ alternative is allowed only occasionally and noncommercially, and
260+ only if you received the object code with such an offer, in accord
261+ with subsection 6b.
262+
263+ d) Convey the object code by offering access from a designated
264+ place (gratis or for a charge), and offer equivalent access to the
265+ Corresponding Source in the same way through the same place at no
266+ further charge. You need not require recipients to copy the
267+ Corresponding Source along with the object code. If the place to
268+ copy the object code is a network server, the Corresponding Source
269+ may be on a different server (operated by you or a third party)
270+ that supports equivalent copying facilities, provided you maintain
271+ clear directions next to the object code saying where to find the
272+ Corresponding Source. Regardless of what server hosts the
273+ Corresponding Source, you remain obligated to ensure that it is
274+ available for as long as needed to satisfy these requirements.
275+
276+ e) Convey the object code using peer-to-peer transmission, provided
277+ you inform other peers where the object code and Corresponding
278+ Source of the work are being offered to the general public at no
279+ charge under subsection 6d.
280+
281+ A separable portion of the object code, whose source code is excluded
282+from the Corresponding Source as a System Library, need not be
283+included in conveying the object code work.
284+
285+ A "User Product" is either (1) a "consumer product", which means any
286+tangible personal property which is normally used for personal, family,
287+or household purposes, or (2) anything designed or sold for incorporation
288+into a dwelling. In determining whether a product is a consumer product,
289+doubtful cases shall be resolved in favor of coverage. For a particular
290+product received by a particular user, "normally used" refers to a
291+typical or common use of that class of product, regardless of the status
292+of the particular user or of the way in which the particular user
293+actually uses, or expects or is expected to use, the product. A product
294+is a consumer product regardless of whether the product has substantial
295+commercial, industrial or non-consumer uses, unless such uses represent
296+the only significant mode of use of the product.
297+
298+ "Installation Information" for a User Product means any methods,
299+procedures, authorization keys, or other information required to install
300+and execute modified versions of a covered work in that User Product from
301+a modified version of its Corresponding Source. The information must
302+suffice to ensure that the continued functioning of the modified object
303+code is in no case prevented or interfered with solely because
304+modification has been made.
305+
306+ If you convey an object code work under this section in, or with, or
307+specifically for use in, a User Product, and the conveying occurs as
308+part of a transaction in which the right of possession and use of the
309+User Product is transferred to the recipient in perpetuity or for a
310+fixed term (regardless of how the transaction is characterized), the
311+Corresponding Source conveyed under this section must be accompanied
312+by the Installation Information. But this requirement does not apply
313+if neither you nor any third party retains the ability to install
314+modified object code on the User Product (for example, the work has
315+been installed in ROM).
316+
317+ The requirement to provide Installation Information does not include a
318+requirement to continue to provide support service, warranty, or updates
319+for a work that has been modified or installed by the recipient, or for
320+the User Product in which it has been modified or installed. Access to a
321+network may be denied when the modification itself materially and
322+adversely affects the operation of the network or violates the rules and
323+protocols for communication across the network.
324+
325+ Corresponding Source conveyed, and Installation Information provided,
326+in accord with this section must be in a format that is publicly
327+documented (and with an implementation available to the public in
328+source code form), and must require no special password or key for
329+unpacking, reading or copying.
330+
331+ 7. Additional Terms.
332+
333+ "Additional permissions" are terms that supplement the terms of this
334+License by making exceptions from one or more of its conditions.
335+Additional permissions that are applicable to the entire Program shall
336+be treated as though they were included in this License, to the extent
337+that they are valid under applicable law. If additional permissions
338+apply only to part of the Program, that part may be used separately
339+under those permissions, but the entire Program remains governed by
340+this License without regard to the additional permissions.
341+
342+ When you convey a copy of a covered work, you may at your option
343+remove any additional permissions from that copy, or from any part of
344+it. (Additional permissions may be written to require their own
345+removal in certain cases when you modify the work.) You may place
346+additional permissions on material, added by you to a covered work,
347+for which you have or can give appropriate copyright permission.
348+
349+ Notwithstanding any other provision of this License, for material you
350+add to a covered work, you may (if authorized by the copyright holders of
351+that material) supplement the terms of this License with terms:
352+
353+ a) Disclaiming warranty or limiting liability differently from the
354+ terms of sections 15 and 16 of this License; or
355+
356+ b) Requiring preservation of specified reasonable legal notices or
357+ author attributions in that material or in the Appropriate Legal
358+ Notices displayed by works containing it; or
359+
360+ c) Prohibiting misrepresentation of the origin of that material, or
361+ requiring that modified versions of such material be marked in
362+ reasonable ways as different from the original version; or
363+
364+ d) Limiting the use for publicity purposes of names of licensors or
365+ authors of the material; or
366+
367+ e) Declining to grant rights under trademark law for use of some
368+ trade names, trademarks, or service marks; or
369+
370+ f) Requiring indemnification of licensors and authors of that
371+ material by anyone who conveys the material (or modified versions of
372+ it) with contractual assumptions of liability to the recipient, for
373+ any liability that these contractual assumptions directly impose on
374+ those licensors and authors.
375+
376+ All other non-permissive additional terms are considered "further
377+restrictions" within the meaning of section 10. If the Program as you
378+received it, or any part of it, contains a notice stating that it is
379+governed by this License along with a term that is a further
380+restriction, you may remove that term. If a license document contains
381+a further restriction but permits relicensing or conveying under this
382+License, you may add to a covered work material governed by the terms
383+of that license document, provided that the further restriction does
384+not survive such relicensing or conveying.
385+
386+ If you add terms to a covered work in accord with this section, you
387+must place, in the relevant source files, a statement of the
388+additional terms that apply to those files, or a notice indicating
389+where to find the applicable terms.
390+
391+ Additional terms, permissive or non-permissive, may be stated in the
392+form of a separately written license, or stated as exceptions;
393+the above requirements apply either way.
394+
395+ 8. Termination.
396+
397+ You may not propagate or modify a covered work except as expressly
398+provided under this License. Any attempt otherwise to propagate or
399+modify it is void, and will automatically terminate your rights under
400+this License (including any patent licenses granted under the third
401+paragraph of section 11).
402+
403+ However, if you cease all violation of this License, then your
404+license from a particular copyright holder is reinstated (a)
405+provisionally, unless and until the copyright holder explicitly and
406+finally terminates your license, and (b) permanently, if the copyright
407+holder fails to notify you of the violation by some reasonable means
408+prior to 60 days after the cessation.
409+
410+ Moreover, your license from a particular copyright holder is
411+reinstated permanently if the copyright holder notifies you of the
412+violation by some reasonable means, this is the first time you have
413+received notice of violation of this License (for any work) from that
414+copyright holder, and you cure the violation prior to 30 days after
415+your receipt of the notice.
416+
417+ Termination of your rights under this section does not terminate the
418+licenses of parties who have received copies or rights from you under
419+this License. If your rights have been terminated and not permanently
420+reinstated, you do not qualify to receive new licenses for the same
421+material under section 10.
422+
423+ 9. Acceptance Not Required for Having Copies.
424+
425+ You are not required to accept this License in order to receive or
426+run a copy of the Program. Ancillary propagation of a covered work
427+occurring solely as a consequence of using peer-to-peer transmission
428+to receive a copy likewise does not require acceptance. However,
429+nothing other than this License grants you permission to propagate or
430+modify any covered work. These actions infringe copyright if you do
431+not accept this License. Therefore, by modifying or propagating a
432+covered work, you indicate your acceptance of this License to do so.
433+
434+ 10. Automatic Licensing of Downstream Recipients.
435+
436+ Each time you convey a covered work, the recipient automatically
437+receives a license from the original licensors, to run, modify and
438+propagate that work, subject to this License. You are not responsible
439+for enforcing compliance by third parties with this License.
440+
441+ An "entity transaction" is a transaction transferring control of an
442+organization, or substantially all assets of one, or subdividing an
443+organization, or merging organizations. If propagation of a covered
444+work results from an entity transaction, each party to that
445+transaction who receives a copy of the work also receives whatever
446+licenses to the work the party's predecessor in interest had or could
447+give under the previous paragraph, plus a right to possession of the
448+Corresponding Source of the work from the predecessor in interest, if
449+the predecessor has it or can get it with reasonable efforts.
450+
451+ You may not impose any further restrictions on the exercise of the
452+rights granted or affirmed under this License. For example, you may
453+not impose a license fee, royalty, or other charge for exercise of
454+rights granted under this License, and you may not initiate litigation
455+(including a cross-claim or counterclaim in a lawsuit) alleging that
456+any patent claim is infringed by making, using, selling, offering for
457+sale, or importing the Program or any portion of it.
458+
459+ 11. Patents.
460+
461+ A "contributor" is a copyright holder who authorizes use under this
462+License of the Program or a work on which the Program is based. The
463+work thus licensed is called the contributor's "contributor version".
464+
465+ A contributor's "essential patent claims" are all patent claims
466+owned or controlled by the contributor, whether already acquired or
467+hereafter acquired, that would be infringed by some manner, permitted
468+by this License, of making, using, or selling its contributor version,
469+but do not include claims that would be infringed only as a
470+consequence of further modification of the contributor version. For
471+purposes of this definition, "control" includes the right to grant
472+patent sublicenses in a manner consistent with the requirements of
473+this License.
474+
475+ Each contributor grants you a non-exclusive, worldwide, royalty-free
476+patent license under the contributor's essential patent claims, to
477+make, use, sell, offer for sale, import and otherwise run, modify and
478+propagate the contents of its contributor version.
479+
480+ In the following three paragraphs, a "patent license" is any express
481+agreement or commitment, however denominated, not to enforce a patent
482+(such as an express permission to practice a patent or covenant not to
483+sue for patent infringement). To "grant" such a patent license to a
484+party means to make such an agreement or commitment not to enforce a
485+patent against the party.
486+
487+ If you convey a covered work, knowingly relying on a patent license,
488+and the Corresponding Source of the work is not available for anyone
489+to copy, free of charge and under the terms of this License, through a
490+publicly available network server or other readily accessible means,
491+then you must either (1) cause the Corresponding Source to be so
492+available, or (2) arrange to deprive yourself of the benefit of the
493+patent license for this particular work, or (3) arrange, in a manner
494+consistent with the requirements of this License, to extend the patent
495+license to downstream recipients. "Knowingly relying" means you have
496+actual knowledge that, but for the patent license, your conveying the
497+covered work in a country, or your recipient's use of the covered work
498+in a country, would infringe one or more identifiable patents in that
499+country that you have reason to believe are valid.
500+
501+ If, pursuant to or in connection with a single transaction or
502+arrangement, you convey, or propagate by procuring conveyance of, a
503+covered work, and grant a patent license to some of the parties
504+receiving the covered work authorizing them to use, propagate, modify
505+or convey a specific copy of the covered work, then the patent license
506+you grant is automatically extended to all recipients of the covered
507+work and works based on it.
508+
509+ A patent license is "discriminatory" if it does not include within
510+the scope of its coverage, prohibits the exercise of, or is
511+conditioned on the non-exercise of one or more of the rights that are
512+specifically granted under this License. You may not convey a covered
513+work if you are a party to an arrangement with a third party that is
514+in the business of distributing software, under which you make payment
515+to the third party based on the extent of your activity of conveying
516+the work, and under which the third party grants, to any of the
517+parties who would receive the covered work from you, a discriminatory
518+patent license (a) in connection with copies of the covered work
519+conveyed by you (or copies made from those copies), or (b) primarily
520+for and in connection with specific products or compilations that
521+contain the covered work, unless you entered into that arrangement,
522+or that patent license was granted, prior to 28 March 2007.
523+
524+ Nothing in this License shall be construed as excluding or limiting
525+any implied license or other defenses to infringement that may
526+otherwise be available to you under applicable patent law.
527+
528+ 12. No Surrender of Others' Freedom.
529+
530+ If conditions are imposed on you (whether by court order, agreement or
531+otherwise) that contradict the conditions of this License, they do not
532+excuse you from the conditions of this License. If you cannot convey a
533+covered work so as to satisfy simultaneously your obligations under this
534+License and any other pertinent obligations, then as a consequence you may
535+not convey it at all. For example, if you agree to terms that obligate you
536+to collect a royalty for further conveying from those to whom you convey
537+the Program, the only way you could satisfy both those terms and this
538+License would be to refrain entirely from conveying the Program.
539+
540+ 13. Remote Network Interaction; Use with the GNU General Public License.
541+
542+ Notwithstanding any other provision of this License, if you modify the
543+Program, your modified version must prominently offer all users
544+interacting with it remotely through a computer network (if your version
545+supports such interaction) an opportunity to receive the Corresponding
546+Source of your version by providing access to the Corresponding Source
547+from a network server at no charge, through some standard or customary
548+means of facilitating copying of software. This Corresponding Source
549+shall include the Corresponding Source for any work covered by version 3
550+of the GNU General Public License that is incorporated pursuant to the
551+following paragraph.
552+
553+ Notwithstanding any other provision of this License, you have
554+permission to link or combine any covered work with a work licensed
555+under version 3 of the GNU General Public License into a single
556+combined work, and to convey the resulting work. The terms of this
557+License will continue to apply to the part which is the covered work,
558+but the work with which it is combined will remain governed by version
559+3 of the GNU General Public License.
560+
561+ 14. Revised Versions of this License.
562+
563+ The Free Software Foundation may publish revised and/or new versions of
564+the GNU Affero General Public License from time to time. Such new versions
565+will be similar in spirit to the present version, but may differ in detail to
566+address new problems or concerns.
567+
568+ Each version is given a distinguishing version number. If the
569+Program specifies that a certain numbered version of the GNU Affero General
570+Public License "or any later version" applies to it, you have the
571+option of following the terms and conditions either of that numbered
572+version or of any later version published by the Free Software
573+Foundation. If the Program does not specify a version number of the
574+GNU Affero General Public License, you may choose any version ever published
575+by the Free Software Foundation.
576+
577+ If the Program specifies that a proxy can decide which future
578+versions of the GNU Affero General Public License can be used, that proxy's
579+public statement of acceptance of a version permanently authorizes you
580+to choose that version for the Program.
581+
582+ Later license versions may give you additional or different
583+permissions. However, no additional obligations are imposed on any
584+author or copyright holder as a result of your choosing to follow a
585+later version.
586+
587+ 15. Disclaimer of Warranty.
588+
589+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597+
598+ 16. Limitation of Liability.
599+
600+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608+SUCH DAMAGES.
609+
610+ 17. Interpretation of Sections 15 and 16.
611+
612+ If the disclaimer of warranty and limitation of liability provided
613+above cannot be given local legal effect according to their terms,
614+reviewing courts shall apply local law that most closely approximates
615+an absolute waiver of all civil liability in connection with the
616+Program, unless a warranty or assumption of liability accompanies a
617+copy of the Program in return for a fee.
618+
619+ END OF TERMS AND CONDITIONS
620+
621+ How to Apply These Terms to Your New Programs
622+
623+ If you develop a new program, and you want it to be of the greatest
624+possible use to the public, the best way to achieve this is to make it
625+free software which everyone can redistribute and change under these terms.
626+
627+ To do so, attach the following notices to the program. It is safest
628+to attach them to the start of each source file to most effectively
629+state the exclusion of warranty; and each file should have at least
630+the "copyright" line and a pointer to where the full notice is found.
631+
632+ <one line to give the program's name and a brief idea of what it does.>
633+ Copyright (C) <year> <name of author>
634+
635+ This program is free software: you can redistribute it and/or modify
636+ it under the terms of the GNU Affero General Public License as published by
637+ the Free Software Foundation, either version 3 of the License, or
638+ (at your option) any later version.
639+
640+ This program is distributed in the hope that it will be useful,
641+ but WITHOUT ANY WARRANTY; without even the implied warranty of
642+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643+ GNU Affero General Public License for more details.
644+
645+ You should have received a copy of the GNU Affero General Public License
646+ along with this program. If not, see <https://www.gnu.org/licenses/>.
647+
648+Also add information on how to contact you by electronic and paper mail.
649+
650+ If your software can interact with users remotely through a computer
651+network, you should also make sure that it provides a way for users to
652+get its source. For example, if your program is a web application, its
653+interface could display a "Source" link that leads users to an archive
654+of the code. There are many ways you could offer source, and different
655+solutions will be better for different programs; see section 13 for the
656+specific requirements.
657+
658+ You should also get your employer (if you work as a programmer) or school,
659+if any, to sign a "copyright disclaimer" for the program, if necessary.
660+For more information on this, and how to apply and follow the GNU AGPL, see
661+<https://www.gnu.org/licenses/>.
README.mdadded+299 -0
@@ -0,0 +1,299 @@
1+# forge
2+
3+forge is a self-hosted git forge written in Rust. It is a working name, and the project is not yet released.
4+
5+It runs as a single process (`forge`) that serves the web UI, git over HTTPS (smart HTTP), and git over SSH. Repositories are plain bare git repositories on disk. Account and repo metadata live in a SQLite/Turso database.
6+
7+## Status
8+
9+forge is at **Milestone 2** ("repo browsing and raw files"). It currently provides:
10+
11+- local accounts (argon2id passwords), with the first user created as admin
12+- sessions, CSRF protection on every POST, and a `__Host-` session cookie
13+- SSH public keys and personal access tokens (`forge_...`)
14+- creating repos, with public or private visibility, collaborators, and an archive flag
15+- clone and push over HTTPS and SSH
16+- basic branch protection: the default branch cannot be deleted or force-pushed, and archived repos reject pushes
17+- repo browsing: the repo home page with the rendered README, directory listings with the last commit per entry, file views with syntax highlighting and `#L12` line anchors, Markdown rendering with a source toggle, commit history (paginated), commit views with diffs, branch and tag lists, and `.tar.gz` / `.zip` archives
18+- raw files served from a separate user-content origin (see [Raw user content](#raw-user-content))
19+
20+Not yet: issues, pull requests, webhooks, mirrors, CI. See [Roadmap](#roadmap).
21+
22+## Requirements
23+
24+- Rust stable, edition 2024
25+- git 2.38 or newer (boot refuses to start with an older git)
26+
27+## Quick start
28+
29+1. Copy the example config. forge reads `config/forge.toml` by default.
30+
31+ ```sh
32+ cp config/forge.example.toml config/forge.toml
33+ ```
34+
35+2. Start the server.
36+
37+ ```sh
38+ cargo run -p forge-web
39+ ```
40+
41+3. Create the admin account. Either open http://127.0.0.1:7660/setup (it works only while there are zero users), or use the CLI:
42+
43+ ```sh
44+ cargo run -p forge-web -- admin create-user <username> --admin
45+ ```
46+
47+ The CLI reads the password from `FORGE_PASSWORD` if set, or prompts for it twice. Passwords must be at least 12 characters. The first user is always an admin, even without `--admin`.
48+
49+4. Add an SSH key at `/settings/keys`. Use an ed25519 key:
50+
51+ ```sh
52+ ssh-keygen -t ed25519
53+ ```
54+
55+ Paste the contents of the `.pub` file. RSA keys are rejected with a message asking for ed25519. The reason is that `ssh-rsa` keys sign with SHA-1, and forge disables SHA-1 signatures. ECDSA and security-key (`sk-`) variants are also accepted by the form.
56+
57+5. To change only the capitalization of your username (for example `tahinli` to `Tahinli`), open `/settings/profile`. Any other change is refused. The instance operator can do the same from a shell with `cargo run -p forge-web -- admin set-username <current> <NewCase>`.
58+
59+6. Create an access token at `/settings/tokens`. The token starts with `forge_` and is shown once. Store it somewhere safe.
60+
61+7. Account security lives at `/settings/security`:
62+ - **Password:** change it with the current password. The new one needs 10 to 1024 characters. Your other sessions are signed out; the one you are using stays signed in. Wrong current passwords count toward the same per-user login limit as failed logins.
63+ - **Email:** change it with the current password. The address must contain one `@`, have no spaces, and be at most 254 characters.
64+ - **Two-factor authentication (TOTP):** click "Enable 2FA", add the secret or `otpauth://` link to an authenticator app, and confirm with a 6-digit code. Ten single-use recovery codes are then shown once, so save them. Turning 2FA off needs the password and a code. Regenerating recovery codes needs a code. Codes are TOTP (RFC 6238, SHA-1, 6 digits, 30-second steps), and a code from one step either side of the current time is accepted. Each code works only once. With 2FA on, a correct password leads to `/login/2fa`, which asks for a code or a recovery code. The pending login lasts 5 minutes. Each user gets 10 second-factor attempts per 15 minutes.
65+ - **Git over HTTPS is unaffected by 2FA.** It authenticates with access tokens (step 6), not with passwords or TOTP codes. Revoke a token at `/settings/tokens` if it leaks.
66+
67+7. Create a repo at `/new`, then push over either transport:
68+
69+ ```sh
70+ # SSH
71+ git remote add origin ssh://git@127.0.0.1:2222/<owner>/<repo>.git
72+ git push -u origin main
73+
74+ # HTTPS: username and token as the password
75+ git remote add origin http://127.0.0.1:7660/<owner>/<repo>.git
76+ git push -u origin main
77+ ```
78+
79+## Configuration
80+
81+The file is TOML. Every key in `config/forge.example.toml` is listed below. Paths are relative to the working directory unless absolute.
82+
83+| Key | Example value | Meaning |
84+|---|---|---|
85+| `database` | `"./data/forge.db"` | SQLite/Turso database file. `admin create-user` creates its parent directory. |
86+| `data_dir` | `"./data"` | Root for repos, instance key, gitconfig, hooks, and sockets. Created with mode 0700. |
87+| `listen` | `"127.0.0.1:7660"` | HTTP listen address for the web UI and smart HTTP. |
88+| `base_url` | `"http://127.0.0.1:7660"` | Public base URL used in links and clone URLs. Set this to your public `https://` URL in production. |
89+| `user_content_base_url` | `"http://127.0.0.1:7661"` | Separate origin for raw user content. Set it to a different registrable domain in production (see [Raw user content](#raw-user-content)). |
90+| `user_content_listen` | `"127.0.0.1:7661"` | Address of the listener that serves raw user content. Your reverse proxy forwards the user-content hostname to it. |
91+| `ssh_listen` | `"0.0.0.0:2222"` | SSH listen address for git over SSH. |
92+| `ssh_public_host` | `"127.0.0.1"` | Host shown in SSH clone URLs. |
93+| `ssh_public_port` | `2222` | Port shown in SSH clone URLs. |
94+| `registration` | `"closed"` | Self-registration policy: `closed`, `invite`, or `open`. Default is `closed`. M1 has no sign-up page, so accounts come from `/setup`, the CLI, or `/admin/users`. |
95+| `trusted_proxies` | `[]` | Addresses or CIDR networks (for example `["127.0.0.1"]`) whose `X-Forwarded-For` header is trusted. Set it to the address your reverse proxy connects from. Empty means the TCP peer is the client, so a spoofed header cannot change the client IP. Used for login and git throttling. |
96+
97+Production config normally lives at `/etc/forge/forge.toml` (see the systemd unit below).
98+
99+### Command line and environment
100+
101+```text
102+forge [--config <path>] [serve | hook <pre-receive|post-receive> | runner <register|run|check> ... | admin create-user <username> [--admin] | admin set-username <current> <NewCase> | admin reset-password <username> | admin trash-list | admin trash-purge --older-than-days <N> | admin audit-purge --older-than-days <N> | admin delete-user <username> [--yes] | admin backup --out <DIR> [--include-key] [--repos-only | --db-only] | admin backup-verify <PATH> | admin fsck [--full] | admin lfs-gc | admin restore --from <SET> --data-dir <NEW_DIR>]
103+```
104+
105+- `--config <path>` sets the config file. Precedence: `--config`, then `FORGE_CONFIG`, then `config/forge.toml`.
106+- `serve` (the default when no subcommand is given) starts the server.
107+- `hook <stage>` is called by git hook shims. You do not run it by hand.
108+- `admin create-user <username> [--admin]` creates a user from the CLI.
109+- `admin set-username <current> <NewCase>` changes only the capitalization of a user's username (`current` matches any case). Example: `forge admin set-username tahinli Tahinli`.
110+- `admin reset-password <username>` sets a random 20-character temporary password, prints it once, and signs the user out of every session. Give the password to the user, who should change it at `/settings/security`. Nothing else changes. Use it when a user is locked out of their account. Two-factor settings are not changed, so the user still needs a code.
111+- `admin trash-list` lists deleted repos in `data_dir/trash`, oldest first, as `<deleted_at>\t<repo id>\t<path>`.
112+- `admin trash-purge --older-than-days N` permanently removes trash entries deleted at least N days ago. Nothing else is removed, and this cannot be undone.
113+- `FORGE_PASSWORD` supplies the password for `admin create-user` without a prompt.
114+- `RUST_LOG` sets the log filter (default `info`). Logs go to stderr.
115+- `GET /healthz` returns `ok <build sha>`.
116+
117+## Data layout
118+
119+Everything under `data_dir`:
120+
121+| Path | Purpose |
122+|---|---|
123+| `forge.db`, `forge.db-wal`, `forge.db-shm` | Metadata database (users, sessions, keys, tokens, repos). Sits next to `database` in the example config. |
124+| `forge.key` | Instance key. Encrypts sealed values. Created on first boot. |
125+| `ssh_host_ed25519_key` | SSH host key. Created on first boot. |
126+| `gitconfig` | Managed gitconfig written at boot. Do not edit by hand, it is rewritten at boot. |
127+| `hooks/` | `pre-receive` and `post-receive` shims that run `forge hook <name>`. Rewritten at boot. |
128+| `repos/ab/cd/<id>.git` | Bare repositories. `<id>` is the lowercased ULID; the path does not depend on the repo name. |
129+| `internal.sock` | Unix socket used by git hooks to reach the server. Mode 0600. Removed on shutdown. |
130+| `trash/<id>-<unix_ts>.git` | Repos deleted from the settings page (`/{owner}/{repo}/settings`). Each is a plain bare repository, moved here by a rename. The database rows are gone, so the repo no longer appears in forge. |
131+
132+### Recovering a deleted repo
133+
134+A deleted repo is not erased at once. Its bare directory is moved to `data_dir/trash/`, and only `admin trash-purge` removes it. To recover it, list the trash with `forge admin trash-list` and clone from the directory, which works without the server:
135+
136+```sh
137+git clone --mirror /var/lib/forge/trash/<id>-<unix_ts>.git recovered.git
138+```
139+
140+The clone restores all branches and tags. The owner, visibility, description and collaborators are not restored, so re-create the repo under the same name and push the mirror to it with `git push --mirror`.
141+
142+### Backups
143+
144+See [`docs/backup.md`](docs/backup.md) for what a backup set contains, how to ship it off-site, how to restore, and the quarterly restore drill.
145+
146+- **`forge.key` must be backed up separately and stored safely, offline.** Without it, sealed values cannot be decrypted, and they are lost. Keep it out of the same backup set as the database if you can.
147+- **Never copy a live `forge.db`** (or its `-wal`/`-shm` files) with `cp` or `rsync`. Take a consistent snapshot with `VACUUM INTO`, for example from the `sqlite3` shell while forge is running:
148+
149+ ```sql
150+ VACUUM INTO '/var/backups/forge/forge-2026-10-15.db';
151+ ```
152+
153+- **Repositories**: do not copy bare repos while pushes are in progress. Use `git bundle create --all` or `git push --mirror` to a copy.
154+- **Unix socket path length**: `data_dir/internal.sock` must be shorter than 108 bytes, the unix socket path limit. Boot fails with an error otherwise. Choose a short `data_dir` (for example `/var/lib/forge`).
155+
156+## Deployment
157+
158+### systemd
159+
160+`deploy/forge.service` runs `/usr/local/bin/forge serve` as a dedicated `forge` user, with `FORGE_CONFIG=/etc/forge/forge.toml`, working directory `/var/lib/forge`, and a hardened sandbox (`ProtectSystem=strict`, `/var/lib/forge` and, if present, `/var/backups/forge` writable, no capabilities, `UMask=0077`).
161+
162+Typical setup:
163+
164+```sh
165+sudo useradd --system --home /var/lib/forge --shell /usr/sbin/nologin forge
166+sudo install -d -o forge -g forge /var/lib/forge /etc/forge
167+sudo install -m 0640 -g forge config/forge.example.toml /etc/forge/forge.toml
168+sudo install -m 0755 target/release/forge /usr/local/bin/forge
169+sudo install -m 0644 deploy/forge.service /etc/systemd/system/forge.service
170+sudo systemctl daemon-reload && sudo systemctl enable forge
171+```
172+
173+Edit `/etc/forge/forge.toml` before starting:
174+
175+- `base_url`: public `https://` URL.
176+- `user_content_base_url`: a separate registrable domain, not a subdomain of `base_url`.
177+- `ssh_public_host` and `ssh_public_port`: public hostname and port.
178+- `database` and `data_dir`: absolute paths under `/var/lib/forge`.
179+- `trusted_proxies = ["127.0.0.1"]` when the reverse proxy runs on this host. Otherwise every client shares the proxy's IP for login and git throttling.
180+- `[backup] dir`: under `/var/lib/forge` or `/var/backups/forge`. The unit makes only those writable. Create the latter with `sudo install -d -o forge -g forge -m 0700 /var/backups/forge`.
181+
182+Create the admin before starting. `/setup` lets the first visitor claim the admin account while there are no users, so create the admin before the reverse proxy exposes the site.
183+
184+```sh
185+sudo -u forge FORGE_CONFIG=/etc/forge/forge.toml /usr/local/bin/forge admin create-user <name> --admin
186+sudo systemctl start forge
187+```
188+
189+Open the SSH port (2222 by default) in the firewall.
190+
191+### Reverse proxy and TLS
192+
193+Run forge behind a TLS reverse proxy for any non-localhost use. The session cookie is named `__Host-forge_session` and is marked `Secure`, so browsers send it only over HTTPS. Logging in over plain HTTP outside localhost will not work. Set `base_url` to the public HTTPS URL.
194+
195+Git routes need a proxy that does not buffer or time out early. The example below targets [Ferron](https://ferron.sh) 3.0.0-rc.7, the newest release found (a pre-release; the latest stable, 2.8.1, was not checked against this config). Ferron uses its own `ferron.conf` format, not KDL.
196+
197+- Buffering: `buffer_request` and `buffer_response` are off by default. The example sets both to `0` so they stay off, because git smart HTTP streams packs in both directions.
198+- Timeouts: `connection_timeout` (default 2s) and `idle_timeout` (default 60s) are per upstream. The example raises `idle_timeout` to `3600s`, because `git upload-pack` can stay silent while it builds a pack. Ferron's proxy docs list no total-request timeout, so there is no separate read/send timeout to raise.
199+- Body size: Ferron's docs describe no request body size limit, so there is nothing to disable. Do not add one on git paths (`/<owner>/<repo>/git-receive-pack`, `git-upload-pack`).
200+- Forwarded headers: Ferron sets `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Real-IP` and `Forwarded` itself and keeps the incoming `Host`, so the example adds no header lines.
201+- SSH: port 2222 is not proxied. Ferron handles HTTP(S) only, so forge serves SSH directly on `ssh_listen`. Open that port on the host.
202+
203+### Brute-force throttling
204+
205+Login and git HTTP authentication are throttled in memory, per process, over a 15 minute sliding window:
206+
207+- Login: 10 failed attempts per username, and 30 per client IP. A throttled attempt gets HTTP 429 with the message "Too many attempts. Try again later.", and the password is not checked. A successful login clears that username's counter.
208+- Git HTTP (Basic auth with a token): 30 failed attempts per client IP. The next request gets HTTP 429 with `Retry-After`. Anonymous requests and successful logins are not counted.
209+- The client IP is the TCP peer, unless the peer is in `trusted_proxies`. Then it is the right-most `X-Forwarded-For` address that is not a trusted proxy. IPv6 addresses are counted per /64.
210+- Throttling events are logged at `warn` with the IP and the folded username. Counters are lost on restart and are not shared between instances.
211+
212+### Raw user content
213+
214+forge runs a third listener, `user_content_listen` (default `127.0.0.1:7661`). It serves only the raw files of repos, on the origin `user_content_base_url`. It has its own explicit routes and does not share the app's router.
215+
216+Put it on its own registrable domain in production, for example `usercontent.example.net` next to `forge.example.com`. Browsers treat that as a separate site, so a file a user uploads cannot run script against the app origin. The raw origin is also built to be inert:
217+
218+- No cookies are read or set on it, so app sessions never reach it.
219+- Every response has `Content-Security-Policy: default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox`, `X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer`.
220+- SVG, HTML, XML and other non-image binaries are sent with `Content-Disposition: attachment`.
221+
222+Private repos need a signed link. The app puts a `?token=` on raw links for private repos, and that token is valid for 10 minutes. Public repos need no token. A missing, hidden, or bad token gets 404.
223+
224+Example Ferron 3.0.0-rc.7 config (`ferron.conf`) with two hostnames. A bare host block already gets automatic ACME TLS; the `tls` block only sets the account contact:
225+
226+```
227+forge.example.com {
228+ tls {
229+ provider acme
230+ challenge http-01
231+ contact admin@example.com
232+ }
233+ buffer_request 0
234+ buffer_response 0
235+ proxy {
236+ upstream http://127.0.0.1:7660 {
237+ idle_timeout "3600s"
238+ }
239+ }
240+}
241+
242+usercontent.example.net {
243+ tls {
244+ provider acme
245+ challenge http-01
246+ contact admin@example.com
247+ }
248+ buffer_request 0
249+ buffer_response 0
250+ proxy {
251+ upstream http://127.0.0.1:7661
252+ }
253+}
254+```
255+
256+The two hostnames are different registrable domains, so the raw origin is a separate site with no app cookies.
257+
258+With that setup, set `base_url = "https://forge.example.com"` and `user_content_base_url = "https://usercontent.example.net"`.
259+
260+### Limits
261+
262+- Blob view: the first 1 MiB of a file is shown. Larger files are truncated, with a link to the raw file.
263+- Syntax highlighting: skipped for files over 512 KiB or 20,000 lines. Those are shown without highlighting.
264+- Raw files: up to 8 MiB are read into memory and sent. Larger files are streamed.
265+- Commit diffs: at most 300 files, 2,000 lines per file, and 20,000 lines in total. Anything past that is cut off with a notice.
266+
267+Compression is currently **off for every route**. Git smart-HTTP responses must not be compressed, and the server cannot yet switch compression off per route, so it is disabled globally (`TODO(M1)` in `boot.rs`). Let the proxy do no compression on git paths either.
268+
269+## Security notes
270+
271+From `docs/architecture.md`. These are the properties forge is designed for. Not all are fully implemented in M1.
272+
273+- Authorization goes through a single function, `authorize()`. A private repo that the actor cannot see returns 404, so its existence is not revealed.
274+- Passwords use argon2id. Tokens and session tokens are stored as SHA-256 hashes and compared in constant time. Tokens are shown once and kept out of URLs and logs.
275+- SSH keys must match exactly. `ssh-rsa` with SHA-1 is disabled, and the SSH server accepts only `GIT_PROTOCOL` from env requests. Handshake and inactivity timeouts apply.
276+- No shell is used. SSH exec commands are parsed against an allow-list, and git is started with explicit arguments.
277+- Git runs with `receive.fsckObjects=true`, and hooks are fail-closed: if the internal socket is unreachable, the push is rejected.
278+- Users cannot write hooks or repo-local config. Submodules are not recursed.
279+- Run as a dedicated service user, and keep the git binary patched.
280+- Keep `forge.key` and `data_dir` private. Back up `forge.key` separately.
281+
282+## Roadmap
283+
284+From `docs/architecture.md` (section 10).
285+
286+- **M1: clone and push work.** Accounts, SSH keys, tokens, repo create, smart HTTP and SSH, pre-receive policy, minimal UI.
287+- **M2 (current): repo browsing and raw domain.** Tree, blob, commit and diff views; README rendering; syntax highlighting; raw files on `user_content_base_url`.
288+- **M3: issues.** Issues, comments, labels, milestones, with permissions through `authorize()`.
289+- **M4: pull requests.** PR refs, mergeability via `merge-tree`, reviews, merge strategies.
290+- **M5: webhooks, push mirrors, replication, backups, export.** Including `VACUUM INTO` snapshots, integrity checks, and restore drills.
291+- **M6: custom CI runner and commit-status API.** Required checks enforced on push as well as PR merge.
292+
293+## License
294+
295+forge is licensed under the GNU Affero General Public License, version 3 or later (AGPL-3.0-or-later).
296+
297+### Fonts
298+
299+The web UI bundles two fonts, served from the forge origin under `/assets/fonts/`: Figtree (UI) and IBM Plex Mono (code). Both are licensed under the SIL Open Font License 1.1. The license texts are in `style/fonts/` and are also served at `/assets/fonts/Figtree-OFL.txt` and `/assets/fonts/IBMPlexMono-OFL.txt`. Sources, pinned versions and checksums are listed in `style/fonts/README.md`.
config/forge.example.tomladded+112 -0
@@ -0,0 +1,112 @@
1+# forge example configuration. Copy to forge.toml and adjust.
2+# Development defaults; production values belong in /etc/forge/forge.toml.
3+
4+# SQLite/Turso database file.
5+database = "./data/forge.db"
6+# Root for repos, instance key, gitconfig, hooks and sockets.
7+data_dir = "./data"
8+# HTTP listen address for the web UI and smart-HTTP.
9+listen = "127.0.0.1:7660"
10+# Public base URL used in links and clone URLs.
11+base_url = "http://127.0.0.1:7660"
12+# Separate origin for raw user content (Milestone 2).
13+user_content_base_url = "http://127.0.0.1:7661"
14+# Listener for raw user files. It is served on a separate origin, so in production
15+# user_content_base_url should use a separate registrable domain (not a subdomain of
16+# base_url), which keeps user-uploaded content from running with the main site's cookies.
17+user_content_listen = "127.0.0.1:7661"
18+# SSH listen address for git over SSH.
19+ssh_listen = "0.0.0.0:2222"
20+# Host and port shown in SSH clone URLs.
21+ssh_public_host = "127.0.0.1"
22+ssh_public_port = 2222
23+# Self-registration: "closed" | "invite" | "open".
24+registration = "closed"
25+# Proxies whose X-Forwarded-For is trusted for client IPs (login and git throttling). Addresses or CIDRs.
26+# Set to ["127.0.0.1"] when running behind a local reverse proxy. Empty: the TCP peer is the client.
27+trusted_proxies = []
28+
29+# Outbound webhooks. Internal destinations (loopback, private, link-local, CGNAT, ...) are refused.
30+# [webhooks]
31+# Allow http:// hook URLs (https only by default).
32+# allow_http = false
33+# Exact hostnames or CIDR networks that may be reached even if they resolve to internal addresses.
34+# allowed_hosts = ["hooks.internal.example", "10.20.0.0/16"]
35+
36+# Scheduled backups (see docs/backup.md). Empty dir disables the scheduler.
37+# [backup]
38+# Directory that receives the backup sets. Ship it off-site with restic or rclone.
39+# dir = "/var/backups/forge"
40+# Hours between full backup sets.
41+# interval_hours = 24
42+# Newest complete sets kept in dir. Older complete sets are deleted after a good run.
43+# keep = 7
44+# Optional healthchecks.io style URL: pinged with /start, then the URL on success or /fail on error.
45+# The URL may hold a token, so the admin page shows only its host.
46+# healthcheck_url = ""
47+# Days between git fsck of every repo and the database integrity check.
48+# fsck_interval_days = 7
49+
50+# Release settings.
51+# [releases]
52+# Largest accepted release asset, in MiB.
53+# max_asset_mb = 512
54+
55+# Standby replication (see docs/backup.md). Empty target disables it. When target is set,
56+# identity_file and known_hosts_file are required.
57+# [replication]
58+# [user@]host:/absolute/base of the standby account. Empty disables replication.
59+# target = ""
60+# SSH port of the standby host.
61+# ssh_port = 22
62+# Private key of the standby account (mode 0600). Required when target is set.
63+# identity_file = ""
64+# Pinned host keys of the standby. Required when target is set. StrictHostKeyChecking is always yes.
65+# known_hosts_file = ""
66+# Minutes between database snapshots shipped to the standby. Must be at least 1.
67+# db_snapshot_minutes = 15
68+# Minutes between reconcile passes (refs checksums, deleted repos, healthcheck). Must be at least 1.
69+# reconcile_minutes = 60
70+# Optional URL pinged after each reconcile pass. Empty disables it.
71+# healthcheck_url = ""
72+
73+# Git LFS limits.
74+# [lfs]
75+# Largest accepted LFS object, in MiB.
76+# max_object_mb = 2048
77+
78+# CI limits and retention.
79+# [ci]
80+# Whether CI runs at all. When false, pushes and pull requests create no runs.
81+# enabled = true
82+# Largest job log, in MiB. Past it the log is truncated.
83+# max_log_mb = 16
84+# Largest artifact, in MiB.
85+# max_artifact_mb = 512
86+# Artifacts a single job may upload.
87+# max_artifacts_per_job = 50
88+# Days a job log is kept.
89+# log_retention_days = 90
90+# Days an artifact is kept.
91+# artifact_retention_days = 30
92+# Hours a runner registration token stays valid.
93+# registration_token_hours = 24
94+
95+# Security advisories (opt-in). When enabled, the forge downloads the OSV dumps for ecosystems and scans each repo's lockfiles.
96+# [advisories]
97+# Whether the advisory download and the lockfile scans run at all.
98+# enabled = false
99+# OSV ecosystems to download and scan: any of "crates.io", "npm", "Go".
100+# ecosystems = ["crates.io", "npm", "Go"]
101+# Hours before a downloaded dump is fetched again.
102+# refresh_hours = 24
103+
104+# Outgoing mail transport. The SMTP server, sender and password are set on /admin/mail and stored in the database.
105+# [mail]
106+# smtp sends through the configured server. log writes each message to <data_dir>/mail-log/ instead, for development and tests.
107+# transport = "smtp"
108+
109+# Organization rules.
110+# [orgs]
111+# Who may create organizations: "all" (any signed-in person) or "admin" (instance admins only).
112+# creation = "all"
crates/forge-ci/Cargo.tomladded+15 -0
@@ -0,0 +1,15 @@
1+[package]
2+name = "forge-ci"
3+description = "CI workflow config, runner protocol and log masking for forge"
4+version.workspace = true
5+edition.workspace = true
6+license.workspace = true
7+publish = false
8+
9+[dependencies]
10+base64 = { workspace = true }
11+globset = { workspace = true }
12+serde = { workspace = true }
13+serde_json = { workspace = true }
14+thiserror = { workspace = true }
15+toml = { workspace = true }
crates/forge-ci/src/config.rsadded+662 -0
@@ -0,0 +1,662 @@
1+//! Parser and validator for `.forge/ci.toml`.
2+//!
3+//! ```toml
4+//! [on]
5+//! push = ["main"]
6+//! pull_request = true
7+//!
8+//! [jobs.test]
9+//! image = "docker.io/rust:1"
10+//! run = "cargo test --workspace"
11+//! ```
12+//!
13+//! Globs (`push`, `tags`, `artifacts`) use git-like semantics: `*` does not match `/`, `**` does.
14+
15+use std::collections::{BTreeMap, BTreeSet};
16+
17+use globset::{Glob, GlobBuilder};
18+use serde::{Deserialize, Serialize};
19+
20+use crate::error::ConfigError;
21+
22+/// Largest accepted `ci.toml`, in bytes.
23+pub const MAX_CONFIG_BYTES: usize = 64 * 1024;
24+/// Largest number of jobs in one workflow.
25+pub const MAX_JOBS: usize = 20;
26+/// Default job timeout.
27+pub const DEFAULT_TIMEOUT_MINUTES: u32 = 60;
28+/// Smallest and largest allowed job timeout.
29+pub const TIMEOUT_RANGE_MINUTES: std::ops::RangeInclusive<u32> = 1..=360;
30+
31+/// A parsed and validated workflow.
32+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33+#[serde(deny_unknown_fields)]
34+pub struct CiConfig {
35+ /// The `[on]` table. Optional; defaults to manual runs only.
36+ #[serde(rename = "on", default)]
37+ pub on: Triggers,
38+ /// The `[jobs.<name>]` tables, keyed by job name.
39+ #[serde(default)]
40+ pub jobs: BTreeMap<String, JobSpec>,
41+}
42+
43+/// The `[on]` table.
44+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45+#[serde(deny_unknown_fields, default)]
46+pub struct Triggers {
47+ /// Branch globs, e.g. `"main"`, `"release/*"`.
48+ pub push: Vec<String>,
49+ /// Tag globs, e.g. `"v*"`.
50+ pub tags: Vec<String>,
51+ pub pull_request: bool,
52+ /// Defaults to `true`.
53+ pub manual: bool,
54+}
55+
56+impl Default for Triggers {
57+ fn default() -> Self {
58+ Self {
59+ push: Vec::new(),
60+ tags: Vec::new(),
61+ pull_request: false,
62+ manual: true,
63+ }
64+ }
65+}
66+
67+/// One `[jobs.<name>]` table.
68+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69+#[serde(deny_unknown_fields)]
70+pub struct JobSpec {
71+ pub image: String,
72+ pub run: String,
73+ #[serde(default)]
74+ pub needs: Vec<String>,
75+ /// Secret names (`^[A-Z_][A-Z0-9_]{0,63}$`) the job receives.
76+ #[serde(default)]
77+ pub secrets: Vec<String>,
78+ #[serde(default)]
79+ pub env: BTreeMap<String, String>,
80+ /// Relative globs of files to keep after a successful run.
81+ #[serde(default)]
82+ pub artifacts: Vec<String>,
83+ /// Runner labels; every label must be present on the runner.
84+ #[serde(default)]
85+ pub runs_on: Vec<String>,
86+ #[serde(default = "default_timeout_minutes")]
87+ pub timeout_minutes: u32,
88+ #[serde(default = "default_network")]
89+ pub network: bool,
90+}
91+
92+fn default_timeout_minutes() -> u32 {
93+ DEFAULT_TIMEOUT_MINUTES
94+}
95+
96+fn default_network() -> bool {
97+ true
98+}
99+
100+/// Something that can start a workflow run.
101+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102+pub enum Event<'a> {
103+ Push { branch: &'a str },
104+ Tag { tag: &'a str },
105+ PullRequest,
106+ Manual,
107+}
108+
109+/// Parses and validates a `ci.toml` document.
110+pub fn parse_config(text: &str) -> Result<CiConfig, ConfigError> {
111+ if text.len() > MAX_CONFIG_BYTES {
112+ return Err(ConfigError::TooLarge {
113+ size: text.len(),
114+ max: MAX_CONFIG_BYTES,
115+ });
116+ }
117+ let config: CiConfig = toml::from_str(text).map_err(|e| ConfigError::Toml(e.to_string()))?;
118+ config.validate()?;
119+ Ok(config)
120+}
121+
122+impl CiConfig {
123+ /// Whether an event starts this workflow.
124+ pub fn triggered_by(&self, ev: &Event) -> bool {
125+ match ev {
126+ Event::Push { branch } => matches_any(&self.on.push, branch),
127+ Event::Tag { tag } => matches_any(&self.on.tags, tag),
128+ Event::PullRequest => self.on.pull_request,
129+ Event::Manual => self.on.manual,
130+ }
131+ }
132+
133+ /// Job names in dependency order. Ties are broken by name, so the order is stable.
134+ /// Jobs in a cycle, or with unknown needs, are left out. [`parse_config`] rejects both.
135+ pub fn job_order(&self) -> Vec<&str> {
136+ let mut remaining: BTreeMap<&str, BTreeSet<&str>> = self
137+ .jobs
138+ .iter()
139+ .map(|(name, job)| {
140+ (
141+ name.as_str(),
142+ job.needs.iter().map(String::as_str).collect(),
143+ )
144+ })
145+ .collect();
146+ let mut done: BTreeSet<&str> = BTreeSet::new();
147+ let mut order = Vec::with_capacity(self.jobs.len());
148+ loop {
149+ let next = remaining
150+ .iter()
151+ .find(|(_, needs)| needs.iter().all(|n| done.contains(n)))
152+ .map(|(name, _)| *name);
153+ let Some(name) = next else { break };
154+ remaining.remove(name);
155+ done.insert(name);
156+ order.push(name);
157+ }
158+ order
159+ }
160+
161+ fn validate(&self) -> Result<(), ConfigError> {
162+ if self.jobs.is_empty() {
163+ return Err(ConfigError::NoJobs);
164+ }
165+ if self.jobs.len() > MAX_JOBS {
166+ return Err(ConfigError::TooManyJobs {
167+ count: self.jobs.len(),
168+ max: MAX_JOBS,
169+ });
170+ }
171+ for pattern in &self.on.push {
172+ validate_trigger_glob("branch", pattern)?;
173+ }
174+ for pattern in &self.on.tags {
175+ validate_trigger_glob("tag", pattern)?;
176+ }
177+ for (name, job) in &self.jobs {
178+ validate_job(name, job, &self.jobs)?;
179+ }
180+ let order = self.job_order();
181+ if order.len() != self.jobs.len() {
182+ let job = self
183+ .jobs
184+ .keys()
185+ .find(|name| !order.contains(&name.as_str()))
186+ .cloned()
187+ .unwrap_or_default();
188+ return Err(ConfigError::Cycle { job });
189+ }
190+ Ok(())
191+ }
192+}
193+
194+fn matches_any(globs: &[String], value: &str) -> bool {
195+ globs
196+ .iter()
197+ .any(|pattern| build_glob(pattern).is_ok_and(|g| g.compile_matcher().is_match(value)))
198+}
199+
200+fn build_glob(pattern: &str) -> Result<Glob, globset::Error> {
201+ GlobBuilder::new(pattern).literal_separator(true).build()
202+}
203+
204+fn validate_trigger_glob(kind: &'static str, pattern: &str) -> Result<(), ConfigError> {
205+ let invalid = |reason: String| ConfigError::InvalidTriggerGlob {
206+ kind,
207+ pattern: pattern.to_string(),
208+ reason,
209+ };
210+ if pattern.is_empty() {
211+ return Err(invalid("pattern is empty".into()));
212+ }
213+ build_glob(pattern)
214+ .map(|_| ())
215+ .map_err(|e| invalid(e.to_string()))
216+}
217+
218+/// `^[a-z0-9][a-z0-9_-]{0,39}$`
219+fn is_job_name(name: &str) -> bool {
220+ let Some((first, rest)) = name.as_bytes().split_first() else {
221+ return false;
222+ };
223+ rest.len() <= 39
224+ && (first.is_ascii_lowercase() || first.is_ascii_digit())
225+ && rest
226+ .iter()
227+ .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'_' || *b == b'-')
228+}
229+
230+/// `^[A-Z_][A-Z0-9_]{0,63}$`, used for secret and env names.
231+fn is_env_name(name: &str) -> bool {
232+ let Some((first, rest)) = name.as_bytes().split_first() else {
233+ return false;
234+ };
235+ rest.len() <= 63
236+ && (first.is_ascii_uppercase() || *first == b'_')
237+ && rest
238+ .iter()
239+ .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || *b == b'_')
240+}
241+
242+fn is_runner_label(label: &str) -> bool {
243+ (1..=64).contains(&label.len())
244+ && label
245+ .bytes()
246+ .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
247+}
248+
249+fn validate_artifact(job: &str, pattern: &str) -> Result<(), ConfigError> {
250+ let invalid = |reason: &str| ConfigError::InvalidArtifactGlob {
251+ job: job.to_string(),
252+ pattern: pattern.to_string(),
253+ reason: reason.to_string(),
254+ };
255+ if pattern.is_empty() {
256+ return Err(invalid("pattern is empty"));
257+ }
258+ if pattern.starts_with('/') || pattern.starts_with('\\') {
259+ return Err(invalid("must be relative"));
260+ }
261+ if pattern.split(['/', '\\']).any(|part| part == "..") {
262+ return Err(invalid("must not contain \"..\""));
263+ }
264+ build_glob(pattern)
265+ .map(|_| ())
266+ .map_err(|e| invalid(&e.to_string()))
267+}
268+
269+fn validate_job(
270+ name: &str,
271+ job: &JobSpec,
272+ all: &BTreeMap<String, JobSpec>,
273+) -> Result<(), ConfigError> {
274+ if !is_job_name(name) {
275+ return Err(ConfigError::InvalidJobName(name.to_string()));
276+ }
277+ if job.image.is_empty() || job.image.chars().any(char::is_whitespace) {
278+ return Err(ConfigError::InvalidImage {
279+ job: name.to_string(),
280+ });
281+ }
282+ if job.run.trim().is_empty() {
283+ return Err(ConfigError::EmptyRun {
284+ job: name.to_string(),
285+ });
286+ }
287+ for need in &job.needs {
288+ if !all.contains_key(need) {
289+ return Err(ConfigError::UnknownNeed {
290+ job: name.to_string(),
291+ need: need.clone(),
292+ });
293+ }
294+ }
295+ for secret in &job.secrets {
296+ if !is_env_name(secret) {
297+ return Err(ConfigError::InvalidSecretName {
298+ job: name.to_string(),
299+ name: secret.clone(),
300+ });
301+ }
302+ }
303+ for key in job.env.keys() {
304+ if !is_env_name(key) {
305+ return Err(ConfigError::InvalidEnvName {
306+ job: name.to_string(),
307+ name: key.clone(),
308+ });
309+ }
310+ }
311+ for pattern in &job.artifacts {
312+ validate_artifact(name, pattern)?;
313+ }
314+ for label in &job.runs_on {
315+ if !is_runner_label(label) {
316+ return Err(ConfigError::InvalidRunsOn {
317+ job: name.to_string(),
318+ label: label.clone(),
319+ });
320+ }
321+ }
322+ if !TIMEOUT_RANGE_MINUTES.contains(&job.timeout_minutes) {
323+ return Err(ConfigError::InvalidTimeout {
324+ job: name.to_string(),
325+ value: job.timeout_minutes,
326+ });
327+ }
328+ Ok(())
329+}
330+
331+#[cfg(test)]
332+mod tests {
333+ use super::*;
334+
335+ const EXAMPLE: &str = r#"
336+[on]
337+push = ["main"]
338+pull_request = true
339+
340+[jobs.test]
341+image = "docker.io/rust:1"
342+run = "cargo test --workspace"
343+
344+[jobs.lint]
345+image = "docker.io/rust:1"
346+needs = ["test"]
347+secrets = ["DEPLOY_KEY"]
348+run = '''
349+cargo clippy -- -D warnings
350+cargo fmt --check
351+'''
352+artifacts = ["target/release/forge"]
353+"#;
354+
355+ fn job_toml(body: &str) -> String {
356+ format!("[jobs.build]\nimage = \"docker.io/rust:1\"\nrun = \"cargo build\"\n{body}\n")
357+ }
358+
359+ fn err_of(text: &str) -> ConfigError {
360+ match parse_config(text) {
361+ Ok(_) => panic!("expected an error for:\n{text}"),
362+ Err(e) => e,
363+ }
364+ }
365+
366+ #[test]
367+ fn parses_the_contract_example() {
368+ let cfg = parse_config(EXAMPLE).unwrap();
369+ assert_eq!(cfg.on.push, vec!["main".to_string()]);
370+ assert!(cfg.on.pull_request);
371+ assert!(cfg.on.manual, "manual defaults to true");
372+ assert!(cfg.on.tags.is_empty());
373+ assert_eq!(cfg.jobs.len(), 2);
374+ let lint = &cfg.jobs["lint"];
375+ assert_eq!(lint.needs, vec!["test".to_string()]);
376+ assert_eq!(lint.secrets, vec!["DEPLOY_KEY".to_string()]);
377+ assert_eq!(lint.artifacts, vec!["target/release/forge".to_string()]);
378+ assert!(lint.run.contains("cargo clippy -- -D warnings"));
379+ assert_eq!(lint.timeout_minutes, 60);
380+ assert!(lint.network);
381+ assert!(lint.runs_on.is_empty());
382+ assert!(lint.env.is_empty());
383+ }
384+
385+ #[test]
386+ fn manual_defaults_to_true_and_on_is_optional() {
387+ let cfg = parse_config(&job_toml("")).unwrap();
388+ assert!(cfg.on.manual);
389+ assert!(!cfg.on.pull_request);
390+ assert!(cfg.on.push.is_empty());
391+ }
392+
393+ #[test]
394+ fn manual_can_be_disabled() {
395+ let cfg = parse_config(&format!("[on]\nmanual = false\n{}", job_toml(""))).unwrap();
396+ assert!(!cfg.triggered_by(&Event::Manual));
397+ }
398+
399+ #[test]
400+ fn unknown_keys_are_rejected() {
401+ assert!(matches!(
402+ err_of(&format!("[on]\nschedule = true\n{}", job_toml(""))),
403+ ConfigError::Toml(_)
404+ ));
405+ assert!(matches!(
406+ err_of(&job_toml("bogus = 1")),
407+ ConfigError::Toml(_)
408+ ));
409+ assert!(matches!(err_of("[other]\nx = 1\n"), ConfigError::Toml(_)));
410+ }
411+
412+ #[test]
413+ fn no_jobs_is_an_error() {
414+ assert_eq!(err_of("[on]\nmanual = true\n"), ConfigError::NoJobs);
415+ }
416+
417+ #[test]
418+ fn too_many_jobs_is_an_error() {
419+ let mut text = String::new();
420+ for i in 0..=MAX_JOBS {
421+ text.push_str(&format!("[jobs.j{i}]\nimage = \"img\"\nrun = \"true\"\n"));
422+ }
423+ assert_eq!(
424+ err_of(&text),
425+ ConfigError::TooManyJobs {
426+ count: MAX_JOBS + 1,
427+ max: MAX_JOBS
428+ }
429+ );
430+ let mut ok = String::new();
431+ for i in 0..MAX_JOBS {
432+ ok.push_str(&format!("[jobs.j{i}]\nimage = \"img\"\nrun = \"true\"\n"));
433+ }
434+ assert!(parse_config(&ok).is_ok());
435+ }
436+
437+ #[test]
438+ fn file_size_cap() {
439+ let mut text = job_toml("");
440+ text.push_str(&"#".repeat(MAX_CONFIG_BYTES));
441+ assert!(matches!(
442+ err_of(&text),
443+ ConfigError::TooLarge { max, .. } if max == MAX_CONFIG_BYTES
444+ ));
445+ // Exactly at the limit is accepted.
446+ let mut at_limit = job_toml("");
447+ let pad = MAX_CONFIG_BYTES - at_limit.len();
448+ at_limit.push_str(&"#".repeat(pad));
449+ assert_eq!(at_limit.len(), MAX_CONFIG_BYTES);
450+ assert!(parse_config(&at_limit).is_ok());
451+ }
452+
453+ #[test]
454+ fn invalid_job_names() {
455+ for name in ["Test", "-x", "_x", "a b", "a.b", ""] {
456+ let text = format!("[jobs.\"{name}\"]\nimage = \"img\"\nrun = \"true\"\n");
457+ assert!(
458+ matches!(err_of(&text), ConfigError::InvalidJobName(_)),
459+ "name {name:?} should be rejected"
460+ );
461+ }
462+ let long = "a".repeat(41);
463+ let text = format!("[jobs.{long}]\nimage = \"img\"\nrun = \"true\"\n");
464+ assert!(matches!(err_of(&text), ConfigError::InvalidJobName(_)));
465+ let max = "a".repeat(40);
466+ let text = format!("[jobs.{max}]\nimage = \"img\"\nrun = \"true\"\n");
467+ assert!(parse_config(&text).is_ok());
468+ for name in ["a", "0", "a-b_c9"] {
469+ let text = format!("[jobs.{name}]\nimage = \"img\"\nrun = \"true\"\n");
470+ assert!(
471+ parse_config(&text).is_ok(),
472+ "name {name:?} should be accepted"
473+ );
474+ }
475+ }
476+
477+ #[test]
478+ fn unknown_need() {
479+ let text = job_toml("needs = [\"ghost\"]");
480+ assert_eq!(
481+ err_of(&text),
482+ ConfigError::UnknownNeed {
483+ job: "build".into(),
484+ need: "ghost".into()
485+ }
486+ );
487+ }
488+
489+ #[test]
490+ fn cycles_are_detected() {
491+ let text = "[jobs.a]\nimage = \"i\"\nrun = \"r\"\nneeds = [\"b\"]\n\
492+ [jobs.b]\nimage = \"i\"\nrun = \"r\"\nneeds = [\"a\"]\n\
493+ [jobs.c]\nimage = \"i\"\nrun = \"r\"\n";
494+ assert_eq!(err_of(text), ConfigError::Cycle { job: "a".into() });
495+ let self_loop = job_toml("needs = [\"build\"]");
496+ assert_eq!(
497+ err_of(&self_loop),
498+ ConfigError::Cycle {
499+ job: "build".into()
500+ }
501+ );
502+ }
503+
504+ #[test]
505+ fn image_validation() {
506+ let empty = "[jobs.b]\nimage = \"\"\nrun = \"r\"\n";
507+ assert!(matches!(err_of(empty), ConfigError::InvalidImage { .. }));
508+ let space = "[jobs.b]\nimage = \"a b\"\nrun = \"r\"\n";
509+ assert!(matches!(err_of(space), ConfigError::InvalidImage { .. }));
510+ }
511+
512+ #[test]
513+ fn run_validation() {
514+ let blank = "[jobs.b]\nimage = \"i\"\nrun = \" \\n \"\n";
515+ assert!(matches!(err_of(blank), ConfigError::EmptyRun { .. }));
516+ }
517+
518+ #[test]
519+ fn secret_and_env_names() {
520+ let long = "A".repeat(65);
521+ for bad in ["deploy", "1KEY", "A-B", "", long.as_str()] {
522+ let text = job_toml(&format!("secrets = [{bad:?}]"));
523+ assert!(
524+ matches!(err_of(&text), ConfigError::InvalidSecretName { .. }),
525+ "secret {bad:?} should be rejected"
526+ );
527+ }
528+ let text = job_toml("secrets = [\"_OK_1\"]");
529+ assert!(parse_config(&text).is_ok());
530+ let text = job_toml("[jobs.build.env]\n\"lower\" = \"x\"");
531+ assert!(matches!(err_of(&text), ConfigError::InvalidEnvName { .. }));
532+ let text = job_toml("[jobs.build.env]\nRUST_LOG = \"info\"");
533+ assert!(parse_config(&text).is_ok());
534+ }
535+
536+ #[test]
537+ fn artifact_validation() {
538+ for bad in ["/etc/passwd", "..", "a/../b", "\\\\x", "", "a/[b"] {
539+ let text = job_toml(&format!("artifacts = [{bad:?}]"));
540+ assert!(
541+ matches!(err_of(&text), ConfigError::InvalidArtifactGlob { .. }),
542+ "artifact {bad:?} should be rejected"
543+ );
544+ }
545+ let text = job_toml("artifacts = [\"target/release/*\", \"dist/**/*.tar\"]");
546+ assert!(parse_config(&text).is_ok());
547+ }
548+
549+ #[test]
550+ fn runs_on_validation() {
551+ let text = job_toml("runs_on = [\"linux\", \"x86_64\"]");
552+ assert_eq!(
553+ parse_config(&text).unwrap().jobs["build"].runs_on,
554+ vec!["linux".to_string(), "x86_64".to_string()]
555+ );
556+ let text = job_toml("runs_on = [\"has space\"]");
557+ assert!(matches!(err_of(&text), ConfigError::InvalidRunsOn { .. }));
558+ let text = job_toml("runs_on = [\"\"]");
559+ assert!(matches!(err_of(&text), ConfigError::InvalidRunsOn { .. }));
560+ }
561+
562+ #[test]
563+ fn timeout_validation() {
564+ assert_eq!(
565+ parse_config(&job_toml("timeout_minutes = 1")).unwrap().jobs["build"].timeout_minutes,
566+ 1
567+ );
568+ assert_eq!(
569+ parse_config(&job_toml("timeout_minutes = 360"))
570+ .unwrap()
571+ .jobs["build"]
572+ .timeout_minutes,
573+ 360
574+ );
575+ for bad in [0, 361] {
576+ let text = job_toml(&format!("timeout_minutes = {bad}"));
577+ assert_eq!(
578+ err_of(&text),
579+ ConfigError::InvalidTimeout {
580+ job: "build".into(),
581+ value: bad
582+ }
583+ );
584+ }
585+ }
586+
587+ #[test]
588+ fn network_defaults_to_true() {
589+ assert!(parse_config(&job_toml("")).unwrap().jobs["build"].network);
590+ assert!(!parse_config(&job_toml("network = false")).unwrap().jobs["build"].network);
591+ }
592+
593+ #[test]
594+ fn branch_glob_triggers() {
595+ let text = format!("[on]\npush = [\"main\", \"release/*\"]\n{}", job_toml(""));
596+ let cfg = parse_config(&text).unwrap();
597+ assert!(cfg.triggered_by(&Event::Push { branch: "main" }));
598+ assert!(cfg.triggered_by(&Event::Push {
599+ branch: "release/1.0"
600+ }));
601+ assert!(!cfg.triggered_by(&Event::Push {
602+ branch: "release/1.0/x"
603+ }));
604+ assert!(!cfg.triggered_by(&Event::Push { branch: "mainx" }));
605+ assert!(!cfg.triggered_by(&Event::Push { branch: "feature" }));
606+ assert!(!cfg.triggered_by(&Event::Tag { tag: "v1" }));
607+ }
608+
609+ #[test]
610+ fn tag_glob_triggers() {
611+ let text = format!("[on]\ntags = [\"v*\"]\n{}", job_toml(""));
612+ let cfg = parse_config(&text).unwrap();
613+ assert!(cfg.triggered_by(&Event::Tag { tag: "v1.2.3" }));
614+ assert!(!cfg.triggered_by(&Event::Tag { tag: "release-1" }));
615+ assert!(!cfg.triggered_by(&Event::Push { branch: "v1" }));
616+ }
617+
618+ #[test]
619+ fn invalid_trigger_globs_are_rejected() {
620+ let text = format!("[on]\npush = [\"[\"]\n{}", job_toml(""));
621+ assert!(matches!(
622+ err_of(&text),
623+ ConfigError::InvalidTriggerGlob { kind: "branch", .. }
624+ ));
625+ let text = format!("[on]\ntags = [\"\"]\n{}", job_toml(""));
626+ assert!(matches!(
627+ err_of(&text),
628+ ConfigError::InvalidTriggerGlob { kind: "tag", .. }
629+ ));
630+ }
631+
632+ #[test]
633+ fn pull_request_and_manual_events() {
634+ let cfg = parse_config(EXAMPLE).unwrap();
635+ assert!(cfg.triggered_by(&Event::PullRequest));
636+ assert!(cfg.triggered_by(&Event::Manual));
637+ let cfg = parse_config(&job_toml("")).unwrap();
638+ assert!(!cfg.triggered_by(&Event::PullRequest));
639+ }
640+
641+ #[test]
642+ fn job_order_is_topological_and_stable() {
643+ let text = "[jobs.lint]\nimage = \"i\"\nrun = \"r\"\nneeds = [\"test\"]\n\
644+ [jobs.test]\nimage = \"i\"\nrun = \"r\"\n\
645+ [jobs.docs]\nimage = \"i\"\nrun = \"r\"\n\
646+ [jobs.deploy]\nimage = \"i\"\nrun = \"r\"\nneeds = [\"lint\", \"docs\"]\n";
647+ let cfg = parse_config(text).unwrap();
648+ assert_eq!(cfg.job_order(), vec!["docs", "test", "lint", "deploy"]);
649+ // Independent jobs come out in name order.
650+ let flat = parse_config(
651+ "[jobs.c]\nimage = \"i\"\nrun = \"r\"\n[jobs.a]\nimage = \"i\"\nrun = \"r\"\n[jobs.b]\nimage = \"i\"\nrun = \"r\"\n",
652+ )
653+ .unwrap();
654+ assert_eq!(flat.job_order(), vec!["a", "b", "c"]);
655+ }
656+
657+ #[test]
658+ fn example_order() {
659+ let cfg = parse_config(EXAMPLE).unwrap();
660+ assert_eq!(cfg.job_order(), vec!["test", "lint"]);
661+ }
662+}
crates/forge-ci/src/error.rsadded+60 -0
@@ -0,0 +1,60 @@
1+//! Errors returned by [`crate::config::parse_config`].
2+
3+use thiserror::Error;
4+
5+/// Why a `.forge/ci.toml` file was rejected.
6+#[derive(Debug, Clone, PartialEq, Eq, Error)]
7+pub enum ConfigError {
8+ #[error("ci config is {size} bytes, the limit is {max} bytes")]
9+ TooLarge { size: usize, max: usize },
10+
11+ #[error("invalid TOML: {0}")]
12+ Toml(String),
13+
14+ #[error("no jobs are defined")]
15+ NoJobs,
16+
17+ #[error("{count} jobs are defined, the limit is {max}")]
18+ TooManyJobs { count: usize, max: usize },
19+
20+ #[error("invalid job name {0:?}: must match ^[a-z0-9][a-z0-9_-]{{0,39}}$")]
21+ InvalidJobName(String),
22+
23+ #[error("job {job:?} needs unknown job {need:?}")]
24+ UnknownNeed { job: String, need: String },
25+
26+ #[error("jobs form a cycle, involving job {job:?}")]
27+ Cycle { job: String },
28+
29+ #[error("job {job:?}: image must be non-empty and contain no whitespace")]
30+ InvalidImage { job: String },
31+
32+ #[error("job {job:?}: run must not be empty")]
33+ EmptyRun { job: String },
34+
35+ #[error("job {job:?}: invalid secret name {name:?}: must match ^[A-Z_][A-Z0-9_]{{0,63}}$")]
36+ InvalidSecretName { job: String, name: String },
37+
38+ #[error("job {job:?}: invalid env name {name:?}: must match ^[A-Z_][A-Z0-9_]{{0,63}}$")]
39+ InvalidEnvName { job: String, name: String },
40+
41+ #[error("job {job:?}: invalid artifact pattern {pattern:?}: {reason}")]
42+ InvalidArtifactGlob {
43+ job: String,
44+ pattern: String,
45+ reason: String,
46+ },
47+
48+ #[error("job {job:?}: invalid runner label {label:?}")]
49+ InvalidRunsOn { job: String, label: String },
50+
51+ #[error("job {job:?}: timeout_minutes must be between 1 and 360, got {value}")]
52+ InvalidTimeout { job: String, value: u32 },
53+
54+ #[error("invalid {kind} glob {pattern:?}: {reason}")]
55+ InvalidTriggerGlob {
56+ kind: &'static str,
57+ pattern: String,
58+ reason: String,
59+ },
60+}
crates/forge-ci/src/lib.rsadded+9 -0
@@ -0,0 +1,9 @@
1+//! CI workflow config (`.forge/ci.toml`), the runner protocol, and log masking. Shared by
2+//! forge-web and forge-runner.
3+
4+pub mod config;
5+pub mod error;
6+pub mod mask;
7+pub mod proto;
8+
9+pub use error::ConfigError;
crates/forge-ci/src/mask.rsadded+256 -0
@@ -0,0 +1,256 @@
1+//! Secret masking for job logs. Replaces every secret value, and its standard base64 encoding,
2+//! with `***`. [`MaskStream`] does the same over chunks, so a secret split across two chunks is
3+//! still masked.
4+
5+use std::collections::BTreeSet;
6+use std::fmt;
7+
8+use base64::Engine as _;
9+use base64::engine::general_purpose::STANDARD;
10+
11+/// The text that replaces every match.
12+pub const MASK: &str = "***";
13+/// Values shorter than this (in chars) are not masked, since they would hide ordinary output.
14+pub const MIN_SECRET_CHARS: usize = 4;
15+
16+/// Masks a fixed set of secret values.
17+#[derive(Clone)]
18+pub struct Masker {
19+ /// Byte patterns (raw values and their base64), longest first, deduplicated.
20+ patterns: Vec<Vec<u8>>,
21+}
22+
23+impl fmt::Debug for Masker {
24+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25+ f.debug_struct("Masker")
26+ .field("patterns", &self.patterns.len())
27+ .finish()
28+ }
29+}
30+
31+impl Masker {
32+ /// Builds a masker. Values shorter than [`MIN_SECRET_CHARS`] are ignored.
33+ pub fn new(secrets: impl IntoIterator<Item = String>) -> Self {
34+ let mut set: BTreeSet<Vec<u8>> = BTreeSet::new();
35+ for value in secrets {
36+ if value.chars().count() < MIN_SECRET_CHARS {
37+ continue;
38+ }
39+ let encoded = STANDARD.encode(value.as_bytes());
40+ set.insert(value.into_bytes());
41+ set.insert(encoded.into_bytes());
42+ }
43+ let mut patterns: Vec<Vec<u8>> = set.into_iter().collect();
44+ patterns.sort_by_key(|p| std::cmp::Reverse(p.len()));
45+ Self { patterns }
46+ }
47+
48+ /// Masks every occurrence in `text`.
49+ pub fn mask(&self, text: &str) -> String {
50+ let (out, _) = scan(&self.patterns, text.as_bytes(), true);
51+ bytes_to_string(out)
52+ }
53+
54+ /// Starts a streaming masker that owns a copy of this masker.
55+ pub fn stream(&self) -> MaskStream {
56+ MaskStream::new(self.clone())
57+ }
58+}
59+
60+/// Masks a byte stream that arrives in chunks.
61+#[derive(Debug, Clone)]
62+pub struct MaskStream {
63+ masker: Masker,
64+ /// Bytes held back because they may be the start of a secret. Never more than the longest
65+ /// pattern minus one.
66+ held: Vec<u8>,
67+}
68+
69+impl MaskStream {
70+ pub fn new(masker: Masker) -> Self {
71+ Self {
72+ masker,
73+ held: Vec::new(),
74+ }
75+ }
76+
77+ /// Feeds one chunk. Returns the masked bytes that are now safe to emit.
78+ pub fn push(&mut self, chunk: &[u8]) -> Vec<u8> {
79+ self.held.extend_from_slice(chunk);
80+ let (out, rest) = scan(&self.masker.patterns, &self.held, false);
81+ self.held = rest;
82+ out
83+ }
84+
85+ /// Ends the stream and returns whatever is still held back, masked.
86+ pub fn finish(&mut self) -> Vec<u8> {
87+ let held = std::mem::take(&mut self.held);
88+ let (out, _) = scan(&self.masker.patterns, &held, true);
89+ out
90+ }
91+}
92+
93+/// Walks `input` and writes masked bytes. A match is replaced by [`MASK`]. When `last` is false,
94+/// the scan stops at the first position where a pattern could still complete in later input, and
95+/// returns that unprocessed tail. When `last` is true, the whole input is processed.
96+fn scan(patterns: &[Vec<u8>], input: &[u8], last: bool) -> (Vec<u8>, Vec<u8>) {
97+ let mut out = Vec::with_capacity(input.len());
98+ let mut i = 0;
99+ while i < input.len() {
100+ let tail = &input[i..];
101+ if let Some(p) = patterns.iter().find(|p| tail.starts_with(p)) {
102+ out.extend_from_slice(MASK.as_bytes());
103+ i += p.len();
104+ continue;
105+ }
106+ if !last
107+ && patterns
108+ .iter()
109+ .any(|p| p.len() > tail.len() && p.starts_with(tail))
110+ {
111+ return (out, tail.to_vec());
112+ }
113+ out.push(input[i]);
114+ i += 1;
115+ }
116+ (out, Vec::new())
117+}
118+
119+fn bytes_to_string(bytes: Vec<u8>) -> String {
120+ match String::from_utf8(bytes) {
121+ Ok(s) => s,
122+ Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
123+ }
124+}
125+
126+#[cfg(test)]
127+mod tests {
128+ use super::*;
129+
130+ fn masker(values: &[&str]) -> Masker {
131+ Masker::new(values.iter().map(|v| v.to_string()))
132+ }
133+
134+ fn stream_all(m: &Masker, chunks: &[&[u8]]) -> String {
135+ let mut s = m.stream();
136+ let mut out = Vec::new();
137+ for c in chunks {
138+ out.extend(s.push(c));
139+ }
140+ out.extend(s.finish());
141+ String::from_utf8(out).unwrap()
142+ }
143+
144+ #[test]
145+ fn masks_plain_values() {
146+ let m = masker(&["hunter2-secret"]);
147+ assert_eq!(m.mask("token=hunter2-secret ok"), "token=*** ok");
148+ assert_eq!(
149+ m.mask("two hunter2-secret and hunter2-secret"),
150+ "two *** and ***"
151+ );
152+ assert_eq!(m.mask("nothing here"), "nothing here");
153+ }
154+
155+ #[test]
156+ fn masks_base64_encoding() {
157+ let secret = "hunter2-secret";
158+ let encoded = STANDARD.encode(secret.as_bytes());
159+ let m = masker(&[secret]);
160+ assert_eq!(m.mask(&format!("leak: {encoded}")), "leak: ***");
161+ let other = masker(&["abcdefgh"]);
162+ let enc = STANDARD.encode("abcdefgh");
163+ assert_eq!(other.mask(&enc), "***");
164+ }
165+
166+ #[test]
167+ fn short_values_are_ignored() {
168+ let m = masker(&["abc", "", "ab"]);
169+ assert_eq!(m.mask("abc ab"), "abc ab");
170+ // 4 chars is the minimum that is masked.
171+ let m = masker(&["abcd"]);
172+ assert_eq!(m.mask("xabcdx"), "x***x");
173+ }
174+
175+ #[test]
176+ fn longest_match_wins() {
177+ let m = masker(&["secret-value", "secret"]);
178+ assert_eq!(m.mask("a secret-value b"), "a *** b");
179+ }
180+
181+ #[test]
182+ fn empty_secret_set_is_a_no_op() {
183+ let m = Masker::new(std::iter::empty());
184+ assert_eq!(m.mask("anything"), "anything");
185+ assert_eq!(stream_all(&m, &[b"anything"]), "anything");
186+ }
187+
188+ #[test]
189+ fn secret_split_across_two_chunks() {
190+ let m = masker(&["hunter2-secret"]);
191+ let out = stream_all(&m, &[b"pre hunter2-", b"secret post"]);
192+ assert_eq!(out, "pre *** post");
193+ }
194+
195+ #[test]
196+ fn secret_split_one_byte_at_a_time() {
197+ let m = masker(&["hunter2-secret"]);
198+ let text = b"a hunter2-secret b hunter2-secret c";
199+ let chunks: Vec<&[u8]> = text.chunks(1).collect();
200+ assert_eq!(stream_all(&m, &chunks), "a *** b *** c");
201+ }
202+
203+ #[test]
204+ fn base64_split_across_chunks() {
205+ let secret = "hunter2-secret";
206+ let encoded = STANDARD.encode(secret);
207+ let m = masker(&[secret]);
208+ let (a, b) = encoded.split_at(5);
209+ let out = stream_all(
210+ &m,
211+ &[format!("x{a}").as_bytes(), format!("{b}y").as_bytes()],
212+ );
213+ assert_eq!(out, "x***y");
214+ }
215+
216+ #[test]
217+ fn held_bytes_are_bounded() {
218+ let m = masker(&["0123456789"]);
219+ let mut s = m.stream();
220+ let out = s.push(b"0123456");
221+ assert!(out.is_empty());
222+ assert!(s.held.len() <= 9);
223+ let out = s.push(b"x");
224+ assert_eq!(out, b"0123456x".to_vec());
225+ }
226+
227+ #[test]
228+ fn partial_prefix_that_does_not_complete_is_emitted() {
229+ let m = masker(&["hunter2-secret"]);
230+ let out = stream_all(&m, &[b"hunt", b"er is here"]);
231+ assert_eq!(out, "hunter is here");
232+ }
233+
234+ #[test]
235+ fn finish_flushes_held_partial_match() {
236+ let m = masker(&["hunter2-secret"]);
237+ let mut s = m.stream();
238+ let mut out = s.push(b"end hunter2-se");
239+ out.extend(s.finish());
240+ assert_eq!(String::from_utf8(out).unwrap(), "end hunter2-se");
241+ }
242+
243+ #[test]
244+ fn multibyte_text_survives() {
245+ let m = masker(&["hunter2-secret"]);
246+ assert_eq!(m.mask("héllo 🙂 hunter2-secret ✓"), "héllo 🙂 *** ✓");
247+ let out = stream_all(&m, &["héllo 🙂 ".as_bytes(), "hunter2-secret ✓".as_bytes()]);
248+ assert_eq!(out, "héllo 🙂 *** ✓");
249+ }
250+
251+ #[test]
252+ fn debug_does_not_print_values() {
253+ let m = masker(&["hunter2-secret"]);
254+ assert!(!format!("{m:?}").contains("hunter2"));
255+ }
256+}
crates/forge-ci/src/proto.rsadded+173 -0
@@ -0,0 +1,173 @@
1+//! Runner <-> forge JSON protocol. All types use snake_case field names and are serialized as JSON.
2+//!
3+//! Types that carry secrets (`RegisterRequest`, `RegisterResponse`, `JobAssignment`) implement
4+//! `Debug` by hand and print `<redacted>` for their secret fields, so they never end up in logs.
5+
6+use std::collections::BTreeMap;
7+use std::fmt;
8+
9+use serde::{Deserialize, Serialize};
10+
11+/// `POST /api/v1/ci/runners/register`
12+#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
13+pub struct RegisterRequest {
14+ /// Single-use registration token, as shown once by the admin page.
15+ pub token: String,
16+ pub name: String,
17+ pub labels: Vec<String>,
18+}
19+
20+impl fmt::Debug for RegisterRequest {
21+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22+ f.debug_struct("RegisterRequest")
23+ .field("token", &"<redacted>")
24+ .field("name", &self.name)
25+ .field("labels", &self.labels)
26+ .finish()
27+ }
28+}
29+
30+/// Response to [`RegisterRequest`].
31+#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
32+pub struct RegisterResponse {
33+ pub runner_id: String,
34+ /// Bearer secret for all later runner calls. Shown to the runner once.
35+ pub runner_secret: String,
36+}
37+
38+impl fmt::Debug for RegisterResponse {
39+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40+ f.debug_struct("RegisterResponse")
41+ .field("runner_id", &self.runner_id)
42+ .field("runner_secret", &"<redacted>")
43+ .finish()
44+ }
45+}
46+
47+/// `POST /api/v1/ci/runners/claim`. Long-polls for up to 30s; 204 means nothing was claimed.
48+/// The labels are informational: the server uses the labels stored for the runner.
49+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
50+pub struct ClaimRequest {
51+ pub labels: Vec<String>,
52+}
53+
54+/// Returned with 200 by the claim endpoint.
55+#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
56+pub struct JobAssignment {
57+ pub job_id: String,
58+ /// Bearer token for `/api/v1/ci/jobs/{id}/...` and for read-only git access.
59+ pub job_token: String,
60+ /// `owner/name`.
61+ pub repo: String,
62+ /// HTTPS clone URL of the repo.
63+ pub clone_url: String,
64+ pub commit: String,
65+ pub git_ref: String,
66+ pub job_name: String,
67+ pub image: String,
68+ pub run: String,
69+ pub env: BTreeMap<String, String>,
70+ /// Secret values, by name. Never print these.
71+ pub secrets: BTreeMap<String, String>,
72+ pub artifacts: Vec<String>,
73+ pub timeout_minutes: u32,
74+ pub network: bool,
75+}
76+
77+impl fmt::Debug for JobAssignment {
78+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79+ f.debug_struct("JobAssignment")
80+ .field("job_id", &self.job_id)
81+ .field("job_token", &"<redacted>")
82+ .field("repo", &self.repo)
83+ .field("clone_url", &self.clone_url)
84+ .field("commit", &self.commit)
85+ .field("git_ref", &self.git_ref)
86+ .field("job_name", &self.job_name)
87+ .field("image", &self.image)
88+ .field("run", &self.run)
89+ .field("env", &self.env)
90+ .field("secrets", &self.secrets.keys().collect::<Vec<_>>())
91+ .field("artifacts", &self.artifacts)
92+ .field("timeout_minutes", &self.timeout_minutes)
93+ .field("network", &self.network)
94+ .finish()
95+ }
96+}
97+
98+/// Response to `POST /api/v1/ci/jobs/{id}/heartbeat`.
99+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
100+pub struct HeartbeatResponse {
101+ pub cancel: bool,
102+}
103+
104+/// `POST /api/v1/ci/jobs/{id}/finish`.
105+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
106+pub struct FinishRequest {
107+ pub outcome: Outcome,
108+ pub exit_code: Option<i32>,
109+ pub message: Option<String>,
110+}
111+
112+/// Final result of a job, reported by the runner.
113+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
114+#[serde(rename_all = "snake_case")]
115+pub enum Outcome {
116+ Success,
117+ Failure,
118+ Cancelled,
119+ Error,
120+}
121+
122+#[cfg(test)]
123+mod tests {
124+ use super::*;
125+
126+ #[test]
127+ fn outcome_is_snake_case() {
128+ let json = serde_json::to_string(&Outcome::Cancelled).unwrap();
129+ assert_eq!(json, "\"cancelled\"");
130+ }
131+
132+ #[test]
133+ fn finish_request_round_trips() {
134+ let req = FinishRequest {
135+ outcome: Outcome::Failure,
136+ exit_code: Some(2),
137+ message: None,
138+ };
139+ let json = serde_json::to_string(&req).unwrap();
140+ assert_eq!(
141+ json,
142+ r#"{"outcome":"failure","exit_code":2,"message":null}"#
143+ );
144+ let back: FinishRequest = serde_json::from_str(&json).unwrap();
145+ assert_eq!(back, req);
146+ }
147+
148+ #[test]
149+ fn debug_redacts_secrets() {
150+ let mut secrets = BTreeMap::new();
151+ secrets.insert("DEPLOY_KEY".to_string(), "hunter2-value".to_string());
152+ let job = JobAssignment {
153+ job_id: "j1".into(),
154+ job_token: "tok-secret".into(),
155+ repo: "o/r".into(),
156+ clone_url: "https://example.test/o/r.git".into(),
157+ commit: "abc".into(),
158+ git_ref: "refs/heads/main".into(),
159+ job_name: "lint".into(),
160+ image: "docker.io/rust:1".into(),
161+ run: "true".into(),
162+ env: BTreeMap::new(),
163+ secrets,
164+ artifacts: vec![],
165+ timeout_minutes: 60,
166+ network: true,
167+ };
168+ let dbg = format!("{job:?}");
169+ assert!(!dbg.contains("hunter2-value"));
170+ assert!(!dbg.contains("tok-secret"));
171+ assert!(dbg.contains("DEPLOY_KEY"));
172+ }
173+}
crates/forge-core/Cargo.tomladded+36 -0
@@ -0,0 +1,36 @@
1+[package]
2+name = "forge-core"
3+description = "Domain types, storage, config and security primitives for forge"
4+version.workspace = true
5+edition.workspace = true
6+license.workspace = true
7+publish = false
8+
9+[dependencies]
10+thiserror.workspace = true
11+ipnet.workspace = true
12+serde.workspace = true
13+toml.workspace = true
14+ulid.workspace = true
15+time.workspace = true
16+unicode-normalization.workspace = true
17+jiff = { workspace = true }
18+argon2.workspace = true
19+rand.workspace = true
20+sha2.workspace = true
21+hex.workspace = true
22+hmac = { workspace = true }
23+sha1.workspace = true
24+image = { workspace = true }
25+subtle.workspace = true
26+base64.workspace = true
27+chacha20poly1305.workspace = true
28+turso.workspace = true
29+tokio = { workspace = true, features = ["sync"] }
30+tracing.workspace = true
31+serde_json.workspace = true
32+semver = { workspace = true }
33+zip = { workspace = true }
34+
35+[dev-dependencies]
36+tempfile.workspace = true
crates/forge-core/migrations/0001_init.sqladded+92 -0
@@ -0,0 +1,92 @@
1+-- Milestone 1 schema. Applied by store/migrate.rs, which records the version in
2+-- schema_migrations. Timestamps are RFC 3339 UTC strings (see time::now_stamp).
3+
4+CREATE TABLE users (
5+ id TEXT PRIMARY KEY,
6+ username TEXT NOT NULL,
7+ username_folded TEXT NOT NULL UNIQUE,
8+ email TEXT,
9+ password_hash TEXT NOT NULL,
10+ is_admin INTEGER NOT NULL DEFAULT 0,
11+ created_at TEXT NOT NULL
12+);
13+
14+CREATE TABLE sessions (
15+ id TEXT PRIMARY KEY,
16+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
17+ token_hash TEXT NOT NULL UNIQUE,
18+ csrf_token TEXT NOT NULL,
19+ created_at TEXT NOT NULL,
20+ expires_at TEXT NOT NULL,
21+ last_seen TEXT NOT NULL,
22+ user_agent TEXT
23+);
24+CREATE INDEX sessions_user_id ON sessions(user_id);
25+CREATE INDEX sessions_expires_at ON sessions(expires_at);
26+
27+CREATE TABLE ssh_keys (
28+ id TEXT PRIMARY KEY,
29+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
30+ title TEXT NOT NULL,
31+ algorithm TEXT NOT NULL,
32+ key_blob BLOB NOT NULL UNIQUE,
33+ fingerprint TEXT NOT NULL,
34+ created_at TEXT NOT NULL,
35+ last_used_at TEXT
36+);
37+CREATE INDEX ssh_keys_user_id ON ssh_keys(user_id);
38+
39+CREATE TABLE access_tokens (
40+ id TEXT PRIMARY KEY,
41+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
42+ name TEXT NOT NULL,
43+ token_hash TEXT NOT NULL UNIQUE,
44+ scopes TEXT NOT NULL DEFAULT '[]',
45+ expires_at TEXT,
46+ created_at TEXT NOT NULL,
47+ last_used_at TEXT
48+);
49+CREATE INDEX access_tokens_user_id ON access_tokens(user_id);
50+
51+CREATE TABLE repos (
52+ id TEXT PRIMARY KEY,
53+ owner_id TEXT NOT NULL REFERENCES users(id),
54+ name TEXT NOT NULL,
55+ name_folded TEXT NOT NULL,
56+ description TEXT NOT NULL DEFAULT '',
57+ visibility TEXT NOT NULL,
58+ default_branch TEXT NOT NULL,
59+ archived INTEGER NOT NULL DEFAULT 0,
60+ created_at TEXT NOT NULL,
61+ UNIQUE(owner_id, name_folded)
62+);
63+
64+-- Old owner/name pairs (folded) that now point at a repo, e.g. after a rename.
65+CREATE TABLE repo_redirects (
66+ owner_folded TEXT NOT NULL,
67+ name_folded TEXT NOT NULL,
68+ repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
69+ created_at TEXT NOT NULL,
70+ PRIMARY KEY (owner_folded, name_folded)
71+);
72+CREATE INDEX repo_redirects_repo_id ON repo_redirects(repo_id);
73+
74+CREATE TABLE collaborators (
75+ repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
76+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
77+ role TEXT NOT NULL,
78+ PRIMARY KEY (repo_id, user_id)
79+);
80+CREATE INDEX collaborators_user_id ON collaborators(user_id);
81+
82+CREATE TABLE push_events (
83+ id TEXT PRIMARY KEY,
84+ repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
85+ actor_id TEXT NOT NULL REFERENCES users(id),
86+ ref_name TEXT NOT NULL,
87+ old_oid TEXT NOT NULL,
88+ new_oid TEXT NOT NULL,
89+ protocol TEXT NOT NULL,
90+ created_at TEXT NOT NULL
91+);
92+CREATE INDEX push_events_repo_created ON push_events(repo_id, created_at);
crates/forge-core/migrations/0002_profile.sqladded+12 -0
@@ -0,0 +1,12 @@
1+-- Profile: about text, IANA timezone, and avatars stored in the database. Applied by
2+-- store/migrate.rs. Existing users get about = '' and timezone = 'UTC'.
3+
4+ALTER TABLE users ADD COLUMN about TEXT NOT NULL DEFAULT '';
5+ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC';
6+
7+CREATE TABLE avatars (
8+ user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
9+ data BLOB NOT NULL, -- re-encoded PNG, 256x256
10+ hash TEXT NOT NULL, -- hex sha256 of data (first 16 chars), used as ETag and as ?v=
11+ updated_at TEXT NOT NULL
12+);
crates/forge-core/migrations/0003_branch_protection.sqladded+12 -0
@@ -0,0 +1,12 @@
1+-- Branch protection rules per repo. Applied by store/migrate.rs. Patterns are literal branch
2+-- names or globs (`*` within one path segment, `**` across segments); see protection.rs.
3+
4+CREATE TABLE branch_protections (
5+ id TEXT PRIMARY KEY, -- ULID
6+ repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
7+ pattern TEXT NOT NULL, -- "main", "release/*", "**"
8+ allow_force_push INTEGER NOT NULL DEFAULT 0,
9+ allow_deletion INTEGER NOT NULL DEFAULT 0,
10+ created_at TEXT NOT NULL,
11+ UNIQUE (repo_id, pattern)
12+);
crates/forge-core/migrations/0004_issues.sqladded+61 -0
@@ -0,0 +1,61 @@
1+-- Issues, comments, labels and milestones (milestone 3). Applied by store/migrate.rs.
2+-- Numbers come from repos.next_issue_number, allocated in the same transaction as the insert.
3+
4+ALTER TABLE repos ADD COLUMN next_issue_number INTEGER NOT NULL DEFAULT 1;
5+
6+CREATE TABLE milestones (
7+ id TEXT PRIMARY KEY, repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
8+ title TEXT NOT NULL, title_folded TEXT NOT NULL, description TEXT NOT NULL DEFAULT '',
9+ due_on TEXT, -- 'YYYY-MM-DD' or NULL
10+ state TEXT NOT NULL DEFAULT 'open',-- 'open' | 'closed'
11+ created_at TEXT NOT NULL, closed_at TEXT,
12+ UNIQUE (repo_id, title_folded));
13+
14+CREATE TABLE issues (
15+ id TEXT PRIMARY KEY, repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
16+ number INTEGER NOT NULL,
17+ kind TEXT NOT NULL DEFAULT 'issue', -- 'issue' now; 'pull' in M4 (shared numbering)
18+ title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '',
19+ state TEXT NOT NULL DEFAULT 'open', -- 'open' | 'closed'
20+ state_reason TEXT, -- NULL | 'completed' | 'not_planned'
21+ author_id TEXT NOT NULL REFERENCES users(id),
22+ milestone_id TEXT REFERENCES milestones(id) ON DELETE SET NULL,
23+ comment_count INTEGER NOT NULL DEFAULT 0,
24+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL, closed_at TEXT,
25+ UNIQUE (repo_id, number));
26+CREATE INDEX issues_repo_state ON issues(repo_id, kind, state, number);
27+
28+CREATE TABLE comments (
29+ id TEXT PRIMARY KEY, issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
30+ author_id TEXT NOT NULL REFERENCES users(id),
31+ body TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
32+ edited INTEGER NOT NULL DEFAULT 0);
33+CREATE INDEX comments_issue ON comments(issue_id, created_at);
34+
35+-- Edit history: the previous version is saved on every edit.
36+CREATE TABLE edit_history (
37+ id TEXT PRIMARY KEY, target_kind TEXT NOT NULL, -- 'issue' | 'comment'
38+ target_id TEXT NOT NULL, old_title TEXT, old_body TEXT NOT NULL,
39+ editor_id TEXT NOT NULL REFERENCES users(id), edited_at TEXT NOT NULL);
40+CREATE INDEX edit_history_target ON edit_history(target_kind, target_id, edited_at);
41+
42+-- Timeline events (everything except comments).
43+CREATE TABLE issue_events (
44+ id TEXT PRIMARY KEY, issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
45+ actor_id TEXT REFERENCES users(id), -- NULL = system
46+ kind TEXT NOT NULL, -- closed reopened labeled unlabeled assigned unassigned milestoned demilestoned renamed referenced
47+ data TEXT NOT NULL DEFAULT '{}', -- JSON, e.g. {"label":"bug"} {"from":"a","to":"b"} {"commit":"<oid>"} {"reason":"completed"}
48+ created_at TEXT NOT NULL);
49+CREATE INDEX issue_events_issue ON issue_events(issue_id, created_at);
50+
51+CREATE TABLE labels (
52+ id TEXT PRIMARY KEY, repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
53+ name TEXT NOT NULL, name_folded TEXT NOT NULL,
54+ color TEXT NOT NULL, -- '#rrggbb' lowercase
55+ description TEXT NOT NULL DEFAULT '',
56+ UNIQUE (repo_id, name_folded));
57+
58+CREATE TABLE issue_labels (issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
59+ label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE, PRIMARY KEY (issue_id, label_id));
60+CREATE TABLE issue_assignees (issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
61+ user_id TEXT NOT NULL REFERENCES users(id), PRIMARY KEY (issue_id, user_id));
crates/forge-core/migrations/0005_stars.sqladded+9 -0
@@ -0,0 +1,9 @@
1+-- Stars: a user's bookmark on a repo. A row goes with either the user or the repo.
2+-- The primary key serves "repos starred by a user"; the index serves the per-repo count.
3+
4+CREATE TABLE stars (
5+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
6+ repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
7+ created_at TEXT NOT NULL,
8+ PRIMARY KEY (user_id, repo_id));
9+CREATE INDEX stars_repo_id ON stars(repo_id);
crates/forge-core/migrations/0006_totp.sqladded+15 -0
@@ -0,0 +1,15 @@
1+-- Two-factor authentication (TOTP) and single-use recovery codes. Applied by store/migrate.rs.
2+-- totp_secret_sealed holds the instance-key sealed secret (secret::seal output, UTF-8 bytes).
3+-- totp_enabled stays 0 until the user confirms a code. totp_last_step is the replay guard.
4+
5+ALTER TABLE users ADD COLUMN totp_secret_sealed BLOB NULL;
6+ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0;
7+ALTER TABLE users ADD COLUMN totp_last_step INTEGER NOT NULL DEFAULT 0;
8+
9+CREATE TABLE recovery_codes (
10+ id TEXT PRIMARY KEY,
11+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
12+ code_hash TEXT NOT NULL, -- hex sha256 of the normalized code
13+ used_at TEXT NULL
14+);
15+CREATE INDEX recovery_codes_user_id ON recovery_codes(user_id);
crates/forge-core/migrations/0007_pulls.sqladded+21 -0
@@ -0,0 +1,21 @@
1+-- Forks and pull requests. Applied by store/migrate.rs.
2+-- A pull request is an issues row with kind = 'pull' (shared numbering); this table holds the
3+-- branch, head and mergeability state. head_repo_id is NULL once the head repo is deleted.
4+
5+ALTER TABLE repos ADD COLUMN fork_of TEXT REFERENCES repos(id);
6+
7+CREATE TABLE pull_requests (
8+ issue_id TEXT PRIMARY KEY REFERENCES issues(id) ON DELETE CASCADE,
9+ base_branch TEXT NOT NULL,
10+ head_repo_id TEXT REFERENCES repos(id), -- NULL if the head repo was deleted
11+ head_branch TEXT NOT NULL,
12+ head_oid TEXT NOT NULL, -- last known head commit (also refs/pull/N/head)
13+ base_oid TEXT NOT NULL, -- base branch tip when mergeability was last computed
14+ merge_base TEXT,
15+ mergeable TEXT NOT NULL DEFAULT 'unknown', -- 'unknown' | 'clean' | 'conflict' | 'merged' | 'up_to_date'
16+ conflict_files TEXT NOT NULL DEFAULT '[]', -- JSON array of paths
17+ merged_at TEXT,
18+ merged_by TEXT REFERENCES users(id),
19+ merge_commit TEXT,
20+ allow_maintainer_edits INTEGER NOT NULL DEFAULT 1);
21+CREATE INDEX pull_requests_head ON pull_requests(head_repo_id, head_branch);
crates/forge-core/migrations/0008_reviews.sqladded+24 -0
@@ -0,0 +1,24 @@
1+-- Pull request reviews and line comments. Applied by store/migrate.rs.
2+-- A review belongs to a pull request (an issues row with kind = 'pull'). Review comments belong
3+-- to a review; a comment with reply_to set is a reply in the root comment's thread.
4+
5+CREATE TABLE pull_reviews (
6+ id TEXT PRIMARY KEY, issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
7+ author_id TEXT NOT NULL REFERENCES users(id),
8+ state TEXT NOT NULL, -- pending | commented | approved | changes_requested | dismissed
9+ body TEXT NOT NULL DEFAULT '',
10+ commit_oid TEXT NOT NULL, -- head at submit (or at creation while pending)
11+ dismissed_reason TEXT,
12+ created_at TEXT NOT NULL, submitted_at TEXT);
13+CREATE INDEX pull_reviews_issue ON pull_reviews(issue_id, submitted_at);
14+CREATE TABLE review_comments (
15+ id TEXT PRIMARY KEY, issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
16+ review_id TEXT NOT NULL REFERENCES pull_reviews(id) ON DELETE CASCADE,
17+ author_id TEXT NOT NULL REFERENCES users(id),
18+ path TEXT NOT NULL, side TEXT NOT NULL, line INTEGER NOT NULL,
19+ commit_oid TEXT NOT NULL, diff_hunk TEXT NOT NULL DEFAULT '',
20+ reply_to TEXT REFERENCES review_comments(id) ON DELETE CASCADE,
21+ body TEXT NOT NULL, outdated INTEGER NOT NULL DEFAULT 0, resolved INTEGER NOT NULL DEFAULT 0,
22+ resolved_by TEXT REFERENCES users(id),
23+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
24+CREATE INDEX review_comments_issue ON review_comments(issue_id, path, created_at);
crates/forge-core/migrations/0009_merge_rules.sqladded+9 -0
@@ -0,0 +1,9 @@
1+-- Merge and push rules on branch protections. Applied by store/migrate.rs. See protection.rs.
2+-- required_approvals counts approvals from Write-or-higher reviewers on the base repo.
3+-- require_up_to_date: the pull request head must contain the base tip.
4+-- restrict_push: direct pushes to the branch are rejected for everyone but repo admins.
5+
6+ALTER TABLE branch_protections ADD COLUMN required_approvals INTEGER NOT NULL DEFAULT 0;
7+ALTER TABLE branch_protections ADD COLUMN block_on_changes_requested INTEGER NOT NULL DEFAULT 0;
8+ALTER TABLE branch_protections ADD COLUMN require_up_to_date INTEGER NOT NULL DEFAULT 0;
9+ALTER TABLE branch_protections ADD COLUMN restrict_push INTEGER NOT NULL DEFAULT 0;
crates/forge-core/migrations/0010_audit_log.sqladded+21 -0
@@ -0,0 +1,21 @@
1+-- Security audit log. Applied by store/migrate.rs. Append-only: the store never updates or
2+-- deletes rows except the retention purge (`purge_audit_before`).
3+-- `at` is an RFC 3339 UTC stamp with fixed width, so text order is time order.
4+-- actor_id is NULL for anonymous or CLI actors; actor_name keeps the name at the time.
5+-- target_* describe the object acted on (kind e.g. 'user', 'repo', 'token', 'ssh_key').
6+
7+CREATE TABLE audit_log (
8+ id TEXT PRIMARY KEY,
9+ at TEXT NOT NULL,
10+ actor_id TEXT NULL,
11+ actor_name TEXT NOT NULL DEFAULT '',
12+ action TEXT NOT NULL,
13+ target_kind TEXT NOT NULL DEFAULT '',
14+ target_id TEXT NOT NULL DEFAULT '',
15+ target_name TEXT NOT NULL DEFAULT '',
16+ ip TEXT NOT NULL DEFAULT '',
17+ data TEXT NOT NULL DEFAULT '{}'
18+);
19+CREATE INDEX audit_log_at ON audit_log(at);
20+CREATE INDEX audit_log_actor ON audit_log(actor_id, at);
21+CREATE INDEX audit_log_target ON audit_log(target_kind, target_id, at);
crates/forge-core/migrations/0011_webhooks.sqladded+44 -0
@@ -0,0 +1,44 @@
1+-- Repo webhooks and their deliveries. Applied by store/migrate.rs.
2+-- secret_sealed holds the secret::seal output (UTF-8 "v1:..." text stored as a BLOB, like
3+-- users.totp_secret_sealed). The plaintext secret never reaches the database.
4+-- events is a JSON array of WebhookEvent names. active is 0 or 1.
5+-- Timestamps are RFC 3339 UTC stamps from time.rs, so text comparison is time order.
6+
7+CREATE TABLE webhooks (
8+ id TEXT PRIMARY KEY,
9+ repo_id TEXT NOT NULL REFERENCES repos(id),
10+ url TEXT NOT NULL,
11+ secret_sealed BLOB NOT NULL,
12+ events TEXT NOT NULL,
13+ active INTEGER NOT NULL DEFAULT 1,
14+ created_by TEXT NULL REFERENCES users(id) ON DELETE SET NULL,
15+ created_at TEXT NOT NULL,
16+ updated_at TEXT NOT NULL
17+);
18+CREATE INDEX webhooks_repo_id ON webhooks(repo_id);
19+
20+-- state: pending (queued, not yet attempted), ok (2xx received), failed (an attempt failed and a
21+-- retry is scheduled at next_attempt_at), gave_up (no further attempts).
22+-- Claimable rows are state IN ('pending', 'failed') with next_attempt_at <= now.
23+-- guid is the X-Forge-Delivery value. The request columns are immutable once written.
24+CREATE TABLE webhook_deliveries (
25+ id TEXT PRIMARY KEY,
26+ hook_id TEXT NOT NULL REFERENCES webhooks(id),
27+ guid TEXT NOT NULL UNIQUE,
28+ event TEXT NOT NULL,
29+ action TEXT NOT NULL DEFAULT '',
30+ request_headers TEXT NOT NULL,
31+ request_body TEXT NOT NULL,
32+ state TEXT NOT NULL CHECK (state IN ('pending', 'ok', 'failed', 'gave_up')),
33+ attempt INTEGER NOT NULL DEFAULT 0,
34+ next_attempt_at TEXT NULL,
35+ response_status INTEGER NULL,
36+ response_headers TEXT NULL,
37+ response_body TEXT NULL, -- first 64 KiB of the body
38+ error TEXT NULL,
39+ duration_ms INTEGER NULL,
40+ created_at TEXT NOT NULL,
41+ delivered_at TEXT NULL
42+);
43+CREATE INDEX webhook_deliveries_state_next ON webhook_deliveries(state, next_attempt_at);
44+CREATE INDEX webhook_deliveries_hook_created ON webhook_deliveries(hook_id, created_at);
crates/forge-core/migrations/0012_notifications.sqladded+12 -0
@@ -0,0 +1,12 @@
1+-- Notifications: repo watch levels, issue subscriptions and per-user inbox rows.
2+-- Applied by store/migrate.rs. Timestamps are RFC 3339 UTC stamps from time.rs.
3+
4+CREATE TABLE repo_watches (user_id TEXT NOT NULL REFERENCES users(id), repo_id TEXT NOT NULL REFERENCES repos(id),
5+ level TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (user_id, repo_id));
6+CREATE TABLE issue_subscriptions (user_id TEXT NOT NULL REFERENCES users(id), issue_id TEXT NOT NULL REFERENCES issues(id),
7+ state TEXT NOT NULL, reason TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (user_id, issue_id));
8+CREATE TABLE notifications (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id),
9+ repo_id TEXT NOT NULL REFERENCES repos(id), issue_id TEXT NOT NULL REFERENCES issues(id),
10+ reason TEXT NOT NULL, last_activity TEXT NOT NULL, last_actor_id TEXT REFERENCES users(id),
11+ unread INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL, UNIQUE (user_id, issue_id));
12+CREATE INDEX notifications_inbox ON notifications(user_id, unread, updated_at);
crates/forge-core/migrations/0013_commit_status.sqladded+20 -0
@@ -0,0 +1,20 @@
1+-- Commit statuses reported by CI runners (and people) through the token API. Applied by
2+-- store/migrate.rs.
3+-- One row per (repo, commit, context): posting again for the same context replaces the row, so the
4+-- latest report wins. sha is the full lowercase hex object id (40 or 64 characters).
5+-- state is pending, success, failure or error. Timestamps are RFC 3339 UTC stamps from time.rs.
6+
7+CREATE TABLE commit_statuses (
8+ id TEXT PRIMARY KEY,
9+ repo_id TEXT NOT NULL REFERENCES repos(id),
10+ sha TEXT NOT NULL,
11+ context TEXT NOT NULL DEFAULT 'default',
12+ state TEXT NOT NULL CHECK (state IN ('pending', 'success', 'failure', 'error')),
13+ description TEXT NULL,
14+ target_url TEXT NULL,
15+ creator_id TEXT NOT NULL REFERENCES users(id),
16+ created_at TEXT NOT NULL,
17+ updated_at TEXT NOT NULL,
18+ UNIQUE (repo_id, sha, context)
19+);
20+CREATE INDEX commit_statuses_repo_sha ON commit_statuses(repo_id, sha);
crates/forge-core/migrations/0014_backup_runs.sqladded+19 -0
@@ -0,0 +1,19 @@
1+-- Scheduled and manual backup and fsck runs, shown on /admin/backups. Applied by store/migrate.rs.
2+-- kind: 'backup' (a backup set) or 'fsck' (git fsck of every repo plus the database check).
3+-- trigger: 'schedule' (the scheduler), 'manual' (the admin page) or 'cli' (forge-web admin backup).
4+-- finished_at and ok stay NULL while a run is in progress; a run that never finished is shown as failed.
5+-- path and bytes are set for a backup that finished; error holds the first failure.
6+-- Timestamps are RFC 3339 UTC stamps from time.rs.
7+
8+CREATE TABLE backup_runs (
9+ id TEXT PRIMARY KEY,
10+ kind TEXT NOT NULL CHECK (kind IN ('backup', 'fsck')),
11+ started_at TEXT NOT NULL,
12+ finished_at TEXT NULL,
13+ ok INTEGER NULL CHECK (ok IS NULL OR ok IN (0, 1)),
14+ path TEXT NULL,
15+ bytes INTEGER NULL,
16+ error TEXT NULL,
17+ "trigger" TEXT NOT NULL CHECK ("trigger" IN ('schedule', 'manual', 'cli'))
18+);
19+CREATE INDEX backup_runs_kind_started ON backup_runs(kind, started_at);
crates/forge-core/migrations/0015_reactions.sqladded+14 -0
@@ -0,0 +1,14 @@
1+-- Emoji reactions on issues (and pull requests, which are issue rows) and on their comments.
2+-- Applied by store/migrate.rs. target_kind is 'issue' or 'comment'. reaction is the short name of
3+-- a forge_core::Reaction (for example '+1' or 'heart'). One row per user, target and emoji, so a
4+-- second toggle removes the row again.
5+
6+CREATE TABLE reactions (
7+ target_kind TEXT NOT NULL,
8+ target_id TEXT NOT NULL,
9+ user_id TEXT NOT NULL REFERENCES users(id),
10+ emoji TEXT NOT NULL,
11+ created_at TEXT NOT NULL,
12+ PRIMARY KEY (target_kind, target_id, user_id, emoji)
13+);
14+CREATE INDEX reactions_target ON reactions(target_kind, target_id);
crates/forge-core/migrations/0016_review_remap.sqladded+3 -0
@@ -0,0 +1,3 @@
1+-- Review line remapping and dismissal history.
2+-- dismissed_from keeps the state a review had before it was dismissed (approved or changes_requested).
3+ALTER TABLE pull_reviews ADD COLUMN dismissed_from TEXT;
crates/forge-core/migrations/0017_mirrors.sqladded+26 -0
@@ -0,0 +1,26 @@
1+-- Push and pull mirrors of a repo. Applied by store/migrate.rs.
2+-- direction: 'pull' (the repo's refs come from url) or 'push' (our refs go to url).
3+-- At most one pull mirror per repo (partial unique index); at most 10 push mirrors (enforced
4+-- by the store).
5+-- secret_sealed holds the secret::seal output as bytes, as webhooks.secret_sealed does. NULL
6+-- means no password. The plaintext secret never reaches the database.
7+-- Timestamps are RFC 3339 UTC stamps from time.rs, so text comparison is time order.
8+
9+CREATE TABLE repo_mirrors (
10+ id TEXT PRIMARY KEY,
11+ repo_id TEXT NOT NULL REFERENCES repos(id),
12+ direction TEXT NOT NULL CHECK (direction IN ('pull', 'push')),
13+ url TEXT NOT NULL,
14+ username TEXT NOT NULL DEFAULT '',
15+ secret_sealed BLOB,
16+ interval_minutes INTEGER NOT NULL DEFAULT 480,
17+ enabled INTEGER NOT NULL DEFAULT 1,
18+ next_sync_at TEXT NOT NULL,
19+ last_sync_at TEXT,
20+ last_ok INTEGER,
21+ last_error TEXT,
22+ created_at TEXT NOT NULL
23+);
24+CREATE INDEX repo_mirrors_due ON repo_mirrors(enabled, next_sync_at);
25+CREATE UNIQUE INDEX repo_mirrors_one_pull ON repo_mirrors(repo_id) WHERE direction = 'pull';
26+CREATE INDEX repo_mirrors_repo ON repo_mirrors(repo_id);
crates/forge-core/migrations/0018_issue_lock.sqladded+6 -0
@@ -0,0 +1,6 @@
1+-- Conversation locking on issues and pull requests (they share the issues table). While locked,
2+-- only collaborators with Triage or higher may write to the conversation. lock_reason is one of
3+-- off_topic, too_heated, resolved or spam, or NULL when no reason was given.
4+
5+ALTER TABLE issues ADD COLUMN locked INTEGER NOT NULL DEFAULT 0;
6+ALTER TABLE issues ADD COLUMN lock_reason TEXT;
crates/forge-core/migrations/0019_pinned_issues.sqladded+11 -0
@@ -0,0 +1,11 @@
1+-- Pinned issues shown above the issue list of a repo. At most three per repo (enforced by
2+-- store/pinned.rs). position orders the cards; pinned_at is an RFC 3339 UTC stamp from time.rs.
3+-- Only issues are pinned, never pull requests.
4+
5+CREATE TABLE pinned_issues (
6+ repo_id TEXT NOT NULL REFERENCES repos(id),
7+ issue_id TEXT NOT NULL REFERENCES issues(id),
8+ position INTEGER NOT NULL,
9+ pinned_at TEXT NOT NULL,
10+ PRIMARY KEY (repo_id, issue_id)
11+);
crates/forge-core/migrations/0020_releases.sqladded+32 -0
@@ -0,0 +1,32 @@
1+-- Releases: a tag-anchored announcement with optional downloadable assets. A release points at
2+-- a tag (created as a lightweight tag on demand) and keeps its own title and body. Deleting a
3+-- release keeps the git tag. Drafts have published_at NULL and are visible only to writers.
4+-- Asset bytes live in the blob store under assets/<asset id>; only metadata is here.
5+
6+CREATE TABLE releases (
7+ id TEXT PRIMARY KEY,
8+ repo_id TEXT NOT NULL REFERENCES repos(id),
9+ tag TEXT NOT NULL,
10+ target_oid TEXT NOT NULL,
11+ title TEXT NOT NULL,
12+ body TEXT NOT NULL DEFAULT '',
13+ draft INTEGER NOT NULL DEFAULT 0,
14+ prerelease INTEGER NOT NULL DEFAULT 0,
15+ author_id TEXT NOT NULL REFERENCES users(id),
16+ created_at TEXT NOT NULL,
17+ published_at TEXT,
18+ UNIQUE (repo_id, tag)
19+);
20+
21+CREATE TABLE release_assets (
22+ id TEXT PRIMARY KEY,
23+ release_id TEXT NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
24+ name TEXT NOT NULL,
25+ size INTEGER NOT NULL,
26+ sha256 TEXT NOT NULL,
27+ content_type TEXT NOT NULL,
28+ download_count INTEGER NOT NULL DEFAULT 0,
29+ uploader_id TEXT NOT NULL REFERENCES users(id),
30+ created_at TEXT NOT NULL,
31+ UNIQUE (release_id, name)
32+);
crates/forge-core/migrations/0021_lfs.sqladded+12 -0
@@ -0,0 +1,12 @@
1+-- Git LFS: which repo may use which content-addressed object. The bytes live on disk under
2+-- data_dir/lfs (shared by every repo); only the per-repo links are stored here.
3+
4+CREATE TABLE lfs_objects (
5+ repo_id TEXT NOT NULL REFERENCES repos(id),
6+ oid TEXT NOT NULL,
7+ size INTEGER NOT NULL,
8+ created_at TEXT NOT NULL,
9+ PRIMARY KEY (repo_id, oid)
10+);
11+
12+CREATE INDEX lfs_objects_oid ON lfs_objects (oid);
crates/forge-core/migrations/0022_signatures.sqladded+31 -0
@@ -0,0 +1,31 @@
1+-- GPG keys users added for commit verification, and the cached verification result per commit.
2+-- Applied by store/migrate.rs. Timestamps are RFC 3339 UTC stamps from time.rs.
3+-- The armored public key is stored as given. `emails` is a JSON array of the key's uid emails.
4+-- Rows are deleted with the user (user_delete.rs) and with the repo (Store::delete_repo).
5+
6+CREATE TABLE gpg_keys (
7+ id TEXT PRIMARY KEY,
8+ user_id TEXT NOT NULL REFERENCES users(id),
9+ fingerprint TEXT NOT NULL UNIQUE,
10+ armored TEXT NOT NULL,
11+ emails TEXT NOT NULL,
12+ created_at TEXT NOT NULL
13+);
14+
15+CREATE INDEX gpg_keys_user ON gpg_keys (user_id);
16+
17+-- status: verified | unverified | unsigned | unknown_key | bad. kind: ssh | gpg, NULL when unsigned.
18+-- signer_user_id is set when the signing key is known to belong to a user. It has no foreign key
19+-- because user deletion clears these rows explicitly.
20+CREATE TABLE commit_signatures (
21+ repo_id TEXT NOT NULL REFERENCES repos(id),
22+ oid TEXT NOT NULL,
23+ status TEXT NOT NULL,
24+ kind TEXT,
25+ signer_user_id TEXT,
26+ key_fingerprint TEXT,
27+ checked_at TEXT NOT NULL,
28+ PRIMARY KEY (repo_id, oid)
29+);
30+
31+CREATE INDEX commit_signatures_signer ON commit_signatures (signer_user_id);
crates/forge-core/migrations/0023_replication.sqladded+13 -0
@@ -0,0 +1,13 @@
1+-- Standby replication status, shown on /admin/replication. Applied by store/migrate.rs.
2+-- subject: 'db' for the database snapshot, or a repo id for a replicated repo.
3+-- last_ok_at is set by the last successful attempt; last_error holds the latest failure and is
4+-- cleared by a success. last_attempt_at is set by every attempt. refs_sha256 is the refs checksum
5+-- of the last successful repo push. Timestamps are RFC 3339 UTC stamps from time.rs.
6+
7+CREATE TABLE replication_status (
8+ subject TEXT PRIMARY KEY,
9+ last_ok_at TEXT NULL,
10+ last_error TEXT NULL,
11+ last_attempt_at TEXT NULL,
12+ refs_sha256 TEXT NULL
13+);
crates/forge-core/migrations/0024_ci.sqladded+77 -0
@@ -0,0 +1,77 @@
1+-- CI: runners, registration tokens, runs, jobs, artifacts and sealed repo secrets.
2+-- Runner, registration and job tokens are stored as sha256 hex only. Timestamps are RFC 3339
3+-- UTC stamps from time.rs. See docs/ci-interfaces.md section 2.
4+
5+CREATE TABLE ci_runners (
6+ id TEXT PRIMARY KEY,
7+ name TEXT NOT NULL,
8+ labels TEXT NOT NULL,
9+ secret_hash TEXT NOT NULL UNIQUE,
10+ created_at TEXT NOT NULL,
11+ last_seen_at TEXT NULL,
12+ disabled INTEGER NOT NULL DEFAULT 0
13+);
14+
15+CREATE TABLE ci_registration_tokens (
16+ token_hash TEXT PRIMARY KEY,
17+ created_by TEXT NOT NULL REFERENCES users(id),
18+ created_at TEXT NOT NULL,
19+ expires_at TEXT NOT NULL,
20+ used_at TEXT NULL
21+);
22+
23+CREATE TABLE ci_runs (
24+ id TEXT PRIMARY KEY,
25+ repo_id TEXT NOT NULL REFERENCES repos(id),
26+ number INTEGER NOT NULL,
27+ trigger TEXT NOT NULL,
28+ git_ref TEXT NOT NULL,
29+ commit_oid TEXT NOT NULL,
30+ pull_number INTEGER NULL,
31+ actor_id TEXT NOT NULL REFERENCES users(id),
32+ state TEXT NOT NULL,
33+ config_error TEXT NULL,
34+ created_at TEXT NOT NULL,
35+ finished_at TEXT NULL,
36+ UNIQUE (repo_id, number)
37+);
38+
39+CREATE TABLE ci_jobs (
40+ id TEXT PRIMARY KEY,
41+ run_id TEXT NOT NULL REFERENCES ci_runs(id) ON DELETE CASCADE,
42+ name TEXT NOT NULL,
43+ spec TEXT NOT NULL,
44+ state TEXT NOT NULL,
45+ runner_id TEXT NULL,
46+ job_token_hash TEXT NULL,
47+ exit_code INTEGER NULL,
48+ message TEXT NULL,
49+ queued_at TEXT NULL,
50+ started_at TEXT NULL,
51+ finished_at TEXT NULL,
52+ heartbeat_at TEXT NULL,
53+ cancel_requested INTEGER NOT NULL DEFAULT 0,
54+ log_bytes INTEGER NOT NULL DEFAULT 0,
55+ UNIQUE (run_id, name)
56+);
57+
58+CREATE INDEX ci_jobs_queue ON ci_jobs(state, queued_at);
59+
60+CREATE TABLE ci_artifacts (
61+ id TEXT PRIMARY KEY,
62+ job_id TEXT NOT NULL REFERENCES ci_jobs(id) ON DELETE CASCADE,
63+ name TEXT NOT NULL,
64+ size INTEGER NOT NULL,
65+ sha256 TEXT NOT NULL,
66+ created_at TEXT NOT NULL,
67+ UNIQUE (job_id, name)
68+);
69+
70+CREATE TABLE ci_secrets (
71+ repo_id TEXT NOT NULL REFERENCES repos(id),
72+ name TEXT NOT NULL,
73+ sealed BLOB NOT NULL,
74+ created_at TEXT NOT NULL,
75+ updated_at TEXT NOT NULL,
76+ PRIMARY KEY (repo_id, name)
77+);
crates/forge-core/migrations/0025_orgs.sqladded+22 -0
@@ -0,0 +1,22 @@
1+-- Organizations: an org is a row in `users` with kind = 'org'. That keeps one namespace (/{name}
2+-- works for both), keeps repos.owner_id -> users(id), and leaves every owner_name join unchanged.
3+-- Org rows get the unusable password hash "!org" and can never authenticate (see store/orgs.rs).
4+
5+ALTER TABLE users ADD COLUMN kind TEXT NOT NULL DEFAULT 'user' CHECK (kind IN ('user','org'));
6+ALTER TABLE users ADD COLUMN description TEXT;
7+
8+CREATE TABLE org_settings (org_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
9+ default_repo_role TEXT NOT NULL DEFAULT 'read' CHECK (default_repo_role IN ('none','read','write')),
10+ members_can_create_repos INTEGER NOT NULL DEFAULT 1);
11+CREATE TABLE org_members (org_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
12+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
13+ role TEXT NOT NULL CHECK (role IN ('owner','member')), created_at TEXT NOT NULL,
14+ PRIMARY KEY (org_id, user_id));
15+CREATE TABLE teams (id TEXT PRIMARY KEY, org_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
16+ name TEXT NOT NULL, name_folded TEXT NOT NULL, description TEXT NOT NULL DEFAULT '',
17+ role TEXT NOT NULL CHECK (role IN ('read','triage','write','maintain','admin')),
18+ all_repos INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, UNIQUE (org_id, name_folded));
19+CREATE TABLE team_members (team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
20+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, PRIMARY KEY (team_id, user_id));
21+CREATE TABLE team_repos (team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
22+ repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, PRIMARY KEY (team_id, repo_id));
crates/forge-core/migrations/0026_mail.sqladded+36 -0
@@ -0,0 +1,36 @@
1+-- Outgoing mail (M1). The SMTP account lives here, not in forge.toml, so admins change it on
2+-- /admin/mail. The password is sealed with the instance key (secret::seal).
3+CREATE TABLE mail_settings (
4+ id INTEGER PRIMARY KEY CHECK (id = 1),
5+ host TEXT NOT NULL DEFAULT '',
6+ port INTEGER NOT NULL DEFAULT 587 CHECK (port BETWEEN 1 AND 65535),
7+ security TEXT NOT NULL DEFAULT 'starttls' CHECK (security IN ('starttls', 'tls', 'none')),
8+ username TEXT NOT NULL DEFAULT '',
9+ password_sealed BLOB,
10+ from_addr TEXT NOT NULL DEFAULT '',
11+ from_name TEXT NOT NULL DEFAULT '',
12+ enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)),
13+ updated_at TEXT
14+);
15+INSERT INTO mail_settings (id) VALUES (1);
16+
17+-- Durable outbox. `idem_key` makes enqueue idempotent. A row is due when `sent_at` is NULL and
18+-- `next_attempt_at` is set; `lease_until` keeps two passes from sending the same row. A row with
19+-- no `next_attempt_at` and no `sent_at` has given up.
20+CREATE TABLE mail_outbox (
21+ id TEXT PRIMARY KEY,
22+ idem_key TEXT NOT NULL UNIQUE,
23+ to_addr TEXT NOT NULL,
24+ subject TEXT NOT NULL,
25+ body_text TEXT NOT NULL,
26+ created_at TEXT NOT NULL,
27+ attempts INTEGER NOT NULL DEFAULT 0,
28+ next_attempt_at TEXT,
29+ lease_until TEXT,
30+ sent_at TEXT,
31+ last_error TEXT
32+);
33+CREATE INDEX mail_outbox_due ON mail_outbox (next_attempt_at);
34+CREATE INDEX mail_outbox_created ON mail_outbox (created_at);
35+
36+ALTER TABLE users ADD COLUMN email_verified_at TEXT;
crates/forge-core/migrations/0027_wiki.sqladded+4 -0
@@ -0,0 +1,4 @@
1+-- Wiki switch per repo. The wiki itself is a second bare repo on disk (see forge-git wiki.rs).
2+-- Applied by store/migrate.rs.
3+
4+ALTER TABLE repos ADD COLUMN wiki_enabled INTEGER NOT NULL DEFAULT 1;
crates/forge-core/migrations/0028_discussions.sqladded+43 -0
@@ -0,0 +1,43 @@
1+-- Discussions: a per-repo forum next to issues, opt-in per repo. Discussion numbers come from
2+-- repos.next_issue_number, the same counter as issues and pull requests, in the same transaction
3+-- as the insert (store/discussions.rs). Comments form one level of replies: a reply's parent_id is
4+-- a top-level comment of the same discussion. A deleted comment keeps its row (deleted_at is set,
5+-- the body is emptied) so its replies still make sense. answer_comment_id is only set in 'qa'.
6+
7+ALTER TABLE repos ADD COLUMN discussions_enabled INTEGER NOT NULL DEFAULT 0;
8+
9+CREATE TABLE discussions (
10+ id TEXT PRIMARY KEY, repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
11+ number INTEGER NOT NULL,
12+ category TEXT NOT NULL CHECK (category IN ('general','ideas','qa','announcements')),
13+ title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '',
14+ author_id TEXT NOT NULL REFERENCES users(id),
15+ answer_comment_id TEXT,
16+ locked INTEGER NOT NULL DEFAULT 0,
17+ comment_count INTEGER NOT NULL DEFAULT 0,
18+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
19+ UNIQUE (repo_id, number));
20+CREATE INDEX discussions_repo_category ON discussions(repo_id, category, number);
21+
22+CREATE TABLE discussion_comments (
23+ id TEXT PRIMARY KEY,
24+ discussion_id TEXT NOT NULL REFERENCES discussions(id) ON DELETE CASCADE,
25+ parent_id TEXT REFERENCES discussion_comments(id) ON DELETE CASCADE,
26+ author_id TEXT NOT NULL REFERENCES users(id),
27+ body TEXT NOT NULL DEFAULT '',
28+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
29+ deleted_at TEXT);
30+CREATE INDEX discussion_comments_discussion ON discussion_comments(discussion_id, created_at);
31+
32+-- Inbox rows for @mentions in discussions. Issues use the notifications table, which points at
33+-- issues, so discussions get their own. One row per user and discussion.
34+CREATE TABLE discussion_notifications (
35+ user_id TEXT NOT NULL REFERENCES users(id),
36+ discussion_id TEXT NOT NULL REFERENCES discussions(id) ON DELETE CASCADE,
37+ repo_id TEXT NOT NULL REFERENCES repos(id),
38+ reason TEXT NOT NULL DEFAULT 'mention',
39+ last_actor_id TEXT REFERENCES users(id),
40+ unread INTEGER NOT NULL DEFAULT 1,
41+ updated_at TEXT NOT NULL,
42+ PRIMARY KEY (user_id, discussion_id));
43+CREATE INDEX discussion_notifications_inbox ON discussion_notifications(user_id, unread, updated_at);
crates/forge-core/migrations/0029_snippets.sqladded+29 -0
@@ -0,0 +1,29 @@
1+-- Snippets: small, shareable collections of text files owned by one user. Each save writes a
2+-- revision holding the whole set of files as JSON, so history never depends on later edits.
3+CREATE TABLE snippets (
4+ id TEXT PRIMARY KEY,
5+ owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
6+ title TEXT NOT NULL,
7+ description TEXT NOT NULL DEFAULT '',
8+ visibility TEXT NOT NULL CHECK (visibility IN ('public','unlisted','private')),
9+ created_at TEXT NOT NULL,
10+ updated_at TEXT NOT NULL
11+);
12+CREATE INDEX snippets_owner ON snippets (owner_id, updated_at);
13+
14+CREATE TABLE snippet_files (
15+ snippet_id TEXT NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
16+ name TEXT NOT NULL,
17+ content TEXT NOT NULL,
18+ position INTEGER NOT NULL,
19+ PRIMARY KEY (snippet_id, name)
20+);
21+
22+CREATE TABLE snippet_revisions (
23+ id TEXT PRIMARY KEY,
24+ snippet_id TEXT NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
25+ author_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
26+ created_at TEXT NOT NULL,
27+ files_json TEXT NOT NULL
28+);
29+CREATE INDEX snippet_revisions_snippet ON snippet_revisions (snippet_id, created_at);
crates/forge-core/migrations/0030_ui_prefs.sqladded+5 -0
@@ -0,0 +1,5 @@
1+-- UI preferences: colour theme and language per user. 'auto' follows the browser (theme) or
2+-- the instance default (language). Applied by store/migrate.rs.
3+
4+ALTER TABLE users ADD COLUMN theme TEXT NOT NULL DEFAULT 'auto' CHECK (theme IN ('auto','light','dark'));
5+ALTER TABLE users ADD COLUMN language TEXT NOT NULL DEFAULT 'auto';
crates/forge-core/migrations/0031_advisories.sqladded+35 -0
@@ -0,0 +1,35 @@
1+-- Security advisories (OSV dumps), the dependencies read from each repo's lockfiles, and the
2+-- alerts that match them. See docs/wave-c-interfaces.md (A1).
3+CREATE TABLE advisories (
4+ id TEXT NOT NULL,
5+ ecosystem TEXT NOT NULL,
6+ package TEXT NOT NULL,
7+ summary TEXT NOT NULL DEFAULT '',
8+ severity TEXT NOT NULL DEFAULT '',
9+ ranges_json TEXT NOT NULL DEFAULT '[]',
10+ versions_json TEXT NOT NULL DEFAULT '[]',
11+ modified TEXT NOT NULL DEFAULT '',
12+ PRIMARY KEY (id, ecosystem, package)
13+);
14+CREATE INDEX advisories_by_package ON advisories (ecosystem, package);
15+
16+CREATE TABLE repo_dependencies (
17+ repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
18+ ecosystem TEXT NOT NULL,
19+ package TEXT NOT NULL,
20+ version TEXT NOT NULL,
21+ manifest_path TEXT NOT NULL,
22+ PRIMARY KEY (repo_id, ecosystem, package, version, manifest_path)
23+);
24+
25+CREATE TABLE repo_alerts (
26+ repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
27+ advisory_id TEXT NOT NULL,
28+ ecosystem TEXT NOT NULL,
29+ package TEXT NOT NULL,
30+ version TEXT NOT NULL,
31+ state TEXT NOT NULL CHECK (state IN ('open','dismissed','fixed')),
32+ created_at TEXT NOT NULL,
33+ updated_at TEXT NOT NULL,
34+ PRIMARY KEY (repo_id, advisory_id, ecosystem, package, version)
35+);
crates/forge-core/migrations/0032_repo_transfers.sqladded+11 -0
@@ -0,0 +1,11 @@
1+-- Repo transfer acceptance (P1). A transfer started by a repo owner waits here until its target
2+-- accepts, declines, the initiator cancels, or it expires after 7 days. The target is a person, or
3+-- an org (any owner of the org may accept). Instance admin transfers skip this table.
4+-- Timestamps are RFC 3339 UTC stamps from time.rs.
5+
6+CREATE TABLE repo_transfers (repo_id TEXT PRIMARY KEY REFERENCES repos(id) ON DELETE CASCADE,
7+ from_owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
8+ to_owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
9+ initiated_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
10+ created_at TEXT NOT NULL, expires_at TEXT NOT NULL);
11+CREATE INDEX repo_transfers_to ON repo_transfers(to_owner_id);
crates/forge-core/migrations/0033_account_tokens.sqladded+33 -0
@@ -0,0 +1,33 @@
1+-- Account mail tokens: password reset, email verification and invites. A token is 32 random
2+-- bytes shown once in the mail link; only its sha256 is stored. `used_at` makes a link single
3+-- use, and `expires_at` bounds its life.
4+
5+CREATE TABLE password_reset_tokens (
6+ token_hash TEXT PRIMARY KEY,
7+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
8+ created_at TEXT NOT NULL,
9+ expires_at TEXT NOT NULL,
10+ used_at TEXT
11+);
12+CREATE INDEX password_reset_tokens_user ON password_reset_tokens(user_id);
13+
14+CREATE TABLE email_verify_tokens (
15+ token_hash TEXT PRIMARY KEY,
16+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
17+ email TEXT NOT NULL,
18+ created_at TEXT NOT NULL,
19+ expires_at TEXT NOT NULL,
20+ used_at TEXT
21+);
22+CREATE INDEX email_verify_tokens_user ON email_verify_tokens(user_id);
23+
24+CREATE TABLE invites (
25+ token_hash TEXT PRIMARY KEY,
26+ email TEXT NOT NULL,
27+ invited_by TEXT REFERENCES users(id) ON DELETE SET NULL,
28+ created_at TEXT NOT NULL,
29+ expires_at TEXT NOT NULL,
30+ accepted_at TEXT,
31+ accepted_by TEXT REFERENCES users(id) ON DELETE SET NULL
32+);
33+CREATE INDEX invites_email ON invites(email);
crates/forge-core/migrations/0034_advisory_notifications.sqladded+12 -0
@@ -0,0 +1,12 @@
1+-- One inbox row per admin and repo, for security alerts a lockfile scan opened (see
2+-- docs/wave-c-interfaces.md, F-A). Alerts are not issues, so they cannot use the `notifications`
3+-- table, which points at issues. `new_alerts` counts the alerts opened since the row was last read.
4+CREATE TABLE advisory_notifications (
5+ user_id TEXT NOT NULL REFERENCES users(id),
6+ repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
7+ new_alerts INTEGER NOT NULL DEFAULT 0,
8+ unread INTEGER NOT NULL DEFAULT 1,
9+ updated_at TEXT NOT NULL,
10+ PRIMARY KEY (user_id, repo_id)
11+);
12+CREATE INDEX advisory_notifications_inbox ON advisory_notifications (user_id, unread, updated_at);
crates/forge-core/migrations/0035_email_notifications.sqladded+6 -0
@@ -0,0 +1,6 @@
1+-- Email copies of notifications (M3). `email_notifications` is the per-user opt-out, on by
2+-- default. `message_id` and `in_reply_to` let mail clients thread the messages of one issue or
3+-- pull request: the first mail of a thread sets the root id, and later mails reference it.
4+ALTER TABLE users ADD COLUMN email_notifications INTEGER NOT NULL DEFAULT 1;
5+ALTER TABLE mail_outbox ADD COLUMN message_id TEXT;
6+ALTER TABLE mail_outbox ADD COLUMN in_reply_to TEXT;
crates/forge-core/src/advisories.rsadded+594 -0
@@ -0,0 +1,594 @@
1+//! Security advisories: the OSV records read from a dump, the version ranges they name, and the
2+//! lockfile parsers that turn a repo's `Cargo.lock`, `package-lock.json` and `go.sum` into
3+//! dependencies. Storage is in `store/advisories.rs`; the download and the scan are in forge-web.
4+//!
5+//! Only the OSV fields the forge needs are read: `id`, `summary`, `aliases`, `severity`,
6+//! `affected[].package`, `affected[].ranges` (SEMVER and ECOSYSTEM events) and
7+//! `affected[].versions`. Git ranges are skipped. Versions are compared as semver: a leading `v`
8+//! is dropped first, so Go versions and Go pseudo-versions compare as semver too.
9+
10+use std::collections::{BTreeMap, BTreeSet};
11+use std::io::{Read, Seek};
12+
13+use serde::{Deserialize, Serialize};
14+use serde_json::Value;
15+
16+use crate::error::{Error, Result};
17+
18+/// The OSV ecosystem names the forge knows how to scan. `[advisories] ecosystems` picks a subset.
19+pub const KNOWN_ECOSYSTEMS: [&str; 3] = ["crates.io", "npm", "Go"];
20+
21+/// Largest single OSV record read from a dump. Real records are a few KiB.
22+const MAX_RECORD_BYTES: u64 = 4 * 1024 * 1024;
23+
24+/// One event of a range, in the order it appears in the record.
25+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26+#[serde(rename_all = "snake_case")]
27+pub enum Event {
28+ Introduced(String),
29+ Fixed(String),
30+ LastAffected(String),
31+ Limit(String),
32+}
33+
34+/// One SEMVER or ECOSYSTEM range of an affected package.
35+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36+pub struct Range {
37+ pub events: Vec<Event>,
38+}
39+
40+/// An advisory as it applies to one package in one ecosystem.
41+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42+pub struct Advisory {
43+ pub id: String,
44+ pub ecosystem: String,
45+ pub package: String,
46+ pub summary: String,
47+ pub severity: String,
48+ pub modified: String,
49+ #[serde(default)]
50+ pub aliases: Vec<String>,
51+ pub ranges: Vec<Range>,
52+ pub versions: Vec<String>,
53+}
54+
55+/// A package version read from a lockfile.
56+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
57+pub struct Dependency {
58+ pub ecosystem: String,
59+ pub package: String,
60+ pub version: String,
61+ pub manifest_path: String,
62+}
63+
64+#[derive(Deserialize)]
65+struct Record {
66+ id: String,
67+ #[serde(default)]
68+ summary: String,
69+ #[serde(default)]
70+ aliases: Vec<String>,
71+ #[serde(default)]
72+ modified: String,
73+ #[serde(default)]
74+ severity: Vec<SeverityEntry>,
75+ #[serde(default)]
76+ database_specific: Value,
77+ #[serde(default)]
78+ affected: Vec<Affected>,
79+}
80+
81+#[derive(Deserialize)]
82+struct SeverityEntry {
83+ #[serde(default)]
84+ score: String,
85+}
86+
87+#[derive(Deserialize)]
88+struct Affected {
89+ #[serde(default)]
90+ package: Option<PackageRef>,
91+ #[serde(default)]
92+ ranges: Vec<RawRange>,
93+ #[serde(default)]
94+ versions: Vec<String>,
95+}
96+
97+#[derive(Deserialize)]
98+struct PackageRef {
99+ #[serde(default)]
100+ ecosystem: String,
101+ #[serde(default)]
102+ name: String,
103+}
104+
105+#[derive(Deserialize)]
106+struct RawRange {
107+ #[serde(rename = "type", default)]
108+ kind: String,
109+ #[serde(default)]
110+ events: Vec<RawEvent>,
111+}
112+
113+#[derive(Deserialize)]
114+struct RawEvent {
115+ introduced: Option<String>,
116+ fixed: Option<String>,
117+ last_affected: Option<String>,
118+ limit: Option<String>,
119+}
120+
121+/// Parses one OSV record into one [`Advisory`] per affected package of `ecosystem`. Several
122+/// `affected` entries for the same package are merged into one advisory.
123+pub fn parse_record(json: &[u8], ecosystem: &str) -> Result<Vec<Advisory>> {
124+ let record: Record = serde_json::from_slice(json)
125+ .map_err(|e| Error::Invalid(format!("advisory record is not valid OSV: {e}")))?;
126+ if record.id.is_empty() {
127+ return Err(Error::Invalid("advisory record has no id".into()));
128+ }
129+ let severity = record
130+ .database_specific
131+ .get("severity")
132+ .and_then(Value::as_str)
133+ .map(str::to_owned)
134+ .or_else(|| record.severity.first().map(|s| s.score.clone()))
135+ .unwrap_or_default();
136+ let mut merged: BTreeMap<String, Advisory> = BTreeMap::new();
137+ for affected in record.affected {
138+ let Some(package) = affected.package else {
139+ continue;
140+ };
141+ if package.ecosystem != ecosystem || package.name.is_empty() {
142+ continue;
143+ }
144+ let entry = merged
145+ .entry(package.name.clone())
146+ .or_insert_with(|| Advisory {
147+ id: record.id.clone(),
148+ ecosystem: ecosystem.to_owned(),
149+ package: package.name.clone(),
150+ summary: record.summary.clone(),
151+ severity: severity.clone(),
152+ modified: record.modified.clone(),
153+ aliases: record.aliases.clone(),
154+ ranges: Vec::new(),
155+ versions: Vec::new(),
156+ });
157+ for raw in affected.ranges {
158+ if !raw.kind.eq_ignore_ascii_case("SEMVER")
159+ && !raw.kind.eq_ignore_ascii_case("ECOSYSTEM")
160+ {
161+ continue;
162+ }
163+ let events = raw
164+ .events
165+ .into_iter()
166+ .filter_map(|e| {
167+ if let Some(v) = e.introduced {
168+ Some(Event::Introduced(v))
169+ } else if let Some(v) = e.fixed {
170+ Some(Event::Fixed(v))
171+ } else if let Some(v) = e.last_affected {
172+ Some(Event::LastAffected(v))
173+ } else {
174+ e.limit.map(Event::Limit)
175+ }
176+ })
177+ .collect();
178+ entry.ranges.push(Range { events });
179+ }
180+ entry.versions.extend(affected.versions);
181+ }
182+ Ok(merged.into_values().collect())
183+}
184+
185+/// Reads every `.json` record of an OSV zip dump for `ecosystem`. A record that does not parse,
186+/// or is too large, is skipped and counted in the second value.
187+pub fn read_dump<R: Read + Seek>(reader: R, ecosystem: &str) -> Result<(Vec<Advisory>, usize)> {
188+ let mut zip = zip::ZipArchive::new(reader)
189+ .map_err(|e| Error::Invalid(format!("advisory dump is not a zip file: {e}")))?;
190+ let mut advisories = Vec::new();
191+ let mut skipped = 0;
192+ for index in 0..zip.len() {
193+ let mut file = zip
194+ .by_index(index)
195+ .map_err(|e| Error::Invalid(format!("advisory dump entry unreadable: {e}")))?;
196+ if file.is_dir() || !file.name().is_ok_and(|name| name.ends_with(".json")) {
197+ continue;
198+ }
199+ let mut buf = Vec::new();
200+ file.by_ref()
201+ .take(MAX_RECORD_BYTES + 1)
202+ .read_to_end(&mut buf)?;
203+ if buf.len() as u64 > MAX_RECORD_BYTES {
204+ skipped += 1;
205+ continue;
206+ }
207+ match parse_record(&buf, ecosystem) {
208+ Ok(found) => advisories.extend(found),
209+ Err(_) => skipped += 1,
210+ }
211+ }
212+ Ok((advisories, skipped))
213+}
214+
215+/// Parses a version as semver, after dropping a leading `v` (Go style).
216+pub fn parse_version(raw: &str) -> Option<semver::Version> {
217+ semver::Version::parse(raw.trim().trim_start_matches('v')).ok()
218+}
219+
220+/// Whether `advisory` covers `version` of its package. A version listed in `versions` matches by
221+/// text. Otherwise the version must fall inside one of the ranges.
222+pub fn affects(advisory: &Advisory, version: &str) -> bool {
223+ let wanted = version.trim().trim_start_matches('v');
224+ if advisory
225+ .versions
226+ .iter()
227+ .any(|v| v.trim().trim_start_matches('v') == wanted)
228+ {
229+ return true;
230+ }
231+ let Some(parsed) = parse_version(version) else {
232+ return false;
233+ };
234+ advisory
235+ .ranges
236+ .iter()
237+ .any(|range| range_affects(range, &parsed))
238+}
239+
240+/// Walks the events of one range. An interval runs from an `introduced` event to the next
241+/// `fixed` (exclusive) or `last_affected` (inclusive). `introduced` of `0` starts at the beginning.
242+/// An interval still open at the end runs forever.
243+fn range_affects(range: &Range, version: &semver::Version) -> bool {
244+ // `Some(start)` while an interval is open; `start` is `None` when it began at `0`.
245+ let mut open: Option<Option<semver::Version>> = None;
246+ for event in &range.events {
247+ match event {
248+ Event::Introduced(raw) => {
249+ open = Some(if raw == "0" {
250+ None
251+ } else {
252+ // An unreadable start is treated as "from the beginning": more alerts, never fewer.
253+ parse_version(raw)
254+ });
255+ }
256+ Event::Fixed(raw) => {
257+ if let Some(start) = open.take()
258+ && starts_before(&start, version)
259+ && parse_version(raw).is_some_and(|fixed| *version < fixed)
260+ {
261+ return true;
262+ }
263+ }
264+ Event::LastAffected(raw) => {
265+ if let Some(start) = open.take()
266+ && starts_before(&start, version)
267+ && parse_version(raw).is_some_and(|last| *version <= last)
268+ {
269+ return true;
270+ }
271+ }
272+ Event::Limit(_) => {}
273+ }
274+ }
275+ open.is_some_and(|start| starts_before(&start, version))
276+}
277+
278+fn starts_before(start: &Option<semver::Version>, version: &semver::Version) -> bool {
279+ start.as_ref().is_none_or(|s| version >= s)
280+}
281+
282+/// Reads `Cargo.lock`. Only registry packages count: path and git packages have no `source`
283+/// from crates.io and are skipped.
284+pub fn parse_cargo_lock(text: &str, manifest_path: &str) -> Result<Vec<Dependency>> {
285+ #[derive(Deserialize)]
286+ struct Lock {
287+ #[serde(default)]
288+ package: Vec<Package>,
289+ }
290+ #[derive(Deserialize)]
291+ struct Package {
292+ name: String,
293+ version: String,
294+ #[serde(default)]
295+ source: Option<String>,
296+ }
297+ let lock: Lock = toml::from_str(text)
298+ .map_err(|e| Error::Invalid(format!("Cargo.lock is not valid TOML: {e}")))?;
299+ Ok(lock
300+ .package
301+ .into_iter()
302+ .filter(|p| {
303+ p.source
304+ .as_deref()
305+ .is_some_and(|s| s.starts_with("registry+"))
306+ })
307+ .map(|p| dependency("crates.io", p.name, p.version, manifest_path))
308+ .collect())
309+}
310+
311+/// Reads `package-lock.json` in the v2 and v3 `packages` layout. Entries without a version, the
312+/// root entry and linked workspace packages are skipped.
313+pub fn parse_package_lock(text: &str, manifest_path: &str) -> Result<Vec<Dependency>> {
314+ let doc: Value = serde_json::from_str(text)
315+ .map_err(|e| Error::Invalid(format!("package-lock.json is not valid JSON: {e}")))?;
316+ let Some(packages) = doc.get("packages").and_then(Value::as_object) else {
317+ return Ok(Vec::new());
318+ };
319+ let mut out = Vec::new();
320+ for (key, entry) in packages {
321+ if key.is_empty() || entry.get("link").and_then(Value::as_bool) == Some(true) {
322+ continue;
323+ }
324+ let Some(version) = entry.get("version").and_then(Value::as_str) else {
325+ continue;
326+ };
327+ // `node_modules/a/node_modules/@s/b` names `@s/b`: the part after the last `node_modules/`.
328+ let name = match key.rsplit_once("node_modules/") {
329+ Some((_, name)) => name,
330+ None => continue,
331+ };
332+ out.push(dependency(
333+ "npm",
334+ name.to_owned(),
335+ version.to_owned(),
336+ manifest_path,
337+ ));
338+ }
339+ Ok(out)
340+}
341+
342+/// Reads `go.sum`. Each module version is listed once, with or without its `/go.mod` hash line;
343+/// the `/go.mod` lines alone are not dependencies, so they are dropped.
344+pub fn parse_go_sum(text: &str, manifest_path: &str) -> Vec<Dependency> {
345+ let mut seen = BTreeSet::new();
346+ for line in text.lines() {
347+ let mut fields = line.split_whitespace();
348+ let (Some(module), Some(version)) = (fields.next(), fields.next()) else {
349+ continue;
350+ };
351+ if version.ends_with("/go.mod") {
352+ continue;
353+ }
354+ seen.insert((module.to_owned(), version.to_owned()));
355+ }
356+ seen.into_iter()
357+ .map(|(module, version)| dependency("Go", module, version, manifest_path))
358+ .collect()
359+}
360+
361+fn dependency(
362+ ecosystem: &str,
363+ package: String,
364+ version: String,
365+ manifest_path: &str,
366+) -> Dependency {
367+ Dependency {
368+ ecosystem: ecosystem.to_owned(),
369+ package,
370+ version,
371+ manifest_path: manifest_path.to_owned(),
372+ }
373+}
374+
375+#[cfg(test)]
376+mod tests {
377+ use super::*;
378+
379+ fn adv(ranges: Vec<Range>, versions: Vec<&str>) -> Advisory {
380+ Advisory {
381+ id: "GHSA-test".into(),
382+ ecosystem: "crates.io".into(),
383+ package: "demo".into(),
384+ summary: String::new(),
385+ severity: String::new(),
386+ modified: String::new(),
387+ aliases: Vec::new(),
388+ ranges,
389+ versions: versions.into_iter().map(str::to_owned).collect(),
390+ }
391+ }
392+
393+ fn range(events: Vec<Event>) -> Range {
394+ Range { events }
395+ }
396+
397+ #[test]
398+ fn introduced_and_fixed_is_half_open() {
399+ let a = adv(
400+ vec![range(vec![
401+ Event::Introduced("1.0.0".into()),
402+ Event::Fixed("1.2.0".into()),
403+ ])],
404+ vec![],
405+ );
406+ assert!(!affects(&a, "0.9.9"));
407+ assert!(affects(&a, "1.0.0"));
408+ assert!(affects(&a, "1.1.5"));
409+ assert!(!affects(&a, "1.2.0"));
410+ assert!(!affects(&a, "2.0.0"));
411+ }
412+
413+ #[test]
414+ fn introduced_zero_with_last_affected_is_inclusive() {
415+ let a = adv(
416+ vec![range(vec![
417+ Event::Introduced("0".into()),
418+ Event::LastAffected("0.4.7".into()),
419+ ])],
420+ vec![],
421+ );
422+ assert!(affects(&a, "0.0.1"));
423+ assert!(affects(&a, "0.4.7"));
424+ assert!(!affects(&a, "0.4.8"));
425+ }
426+
427+ #[test]
428+ fn open_interval_runs_forever_and_later_intervals_work() {
429+ let a = adv(
430+ vec![range(vec![
431+ Event::Introduced("1.0.0".into()),
432+ Event::Fixed("1.1.0".into()),
433+ Event::Introduced("2.0.0".into()),
434+ ])],
435+ vec![],
436+ );
437+ assert!(affects(&a, "1.0.5"));
438+ assert!(!affects(&a, "1.5.0"));
439+ assert!(affects(&a, "9.9.9"));
440+ }
441+
442+ #[test]
443+ fn go_v_prefix_and_pseudo_versions_compare_as_semver() {
444+ let a = adv(
445+ vec![range(vec![
446+ Event::Introduced("0".into()),
447+ Event::Fixed("1.9.1".into()),
448+ ])],
449+ vec![],
450+ );
451+ assert!(affects(&a, "v1.9.0"));
452+ assert!(!affects(&a, "v1.9.1"));
453+ assert!(affects(&a, "v0.0.0-20210101000000-abcdef123456"));
454+ }
455+
456+ #[test]
457+ fn listed_versions_match_by_text() {
458+ let a = adv(vec![], vec!["1.0.3", "v2.0.0"]);
459+ assert!(affects(&a, "1.0.3"));
460+ assert!(affects(&a, "2.0.0"));
461+ assert!(!affects(&a, "1.0.4"));
462+ }
463+
464+ #[test]
465+ fn unparsable_version_matches_nothing_by_range() {
466+ let a = adv(vec![range(vec![Event::Introduced("0".into())])], vec![]);
467+ assert!(!affects(&a, "not-a-version"));
468+ }
469+
470+ #[test]
471+ fn parses_an_osv_record_and_merges_entries_for_one_package() {
472+ let json = br#"{
473+ "id": "GHSA-xxxx",
474+ "summary": "bad things",
475+ "modified": "2026-01-01T00:00:00Z",
476+ "database_specific": {"severity": "HIGH"},
477+ "affected": [
478+ {"package": {"ecosystem": "crates.io", "name": "demo"},
479+ "ranges": [{"type": "SEMVER", "events": [{"introduced": "0"}, {"fixed": "1.0.1"}]}]},
480+ {"package": {"ecosystem": "crates.io", "name": "demo"},
481+ "ranges": [{"type": "GIT", "events": [{"introduced": "0"}]},
482+ {"type": "ECOSYSTEM", "events": [{"introduced": "3.0.0"}, {"last_affected": "3.0.5"}]}],
483+ "versions": ["0.9.0"]},
484+ {"package": {"ecosystem": "npm", "name": "other"},
485+ "ranges": []}
486+ ]
487+ }"#;
488+ let found = parse_record(json, "crates.io").expect("record parses");
489+ assert_eq!(found.len(), 1);
490+ let a = &found[0];
491+ assert_eq!(a.id, "GHSA-xxxx");
492+ assert_eq!(a.severity, "HIGH");
493+ assert_eq!(a.ranges.len(), 2, "the GIT range is skipped");
494+ assert!(affects(a, "0.5.0"));
495+ assert!(!affects(a, "1.0.1"));
496+ assert!(affects(a, "3.0.2"));
497+ assert!(affects(a, "0.9.0"));
498+ }
499+
500+ #[test]
501+ fn record_without_an_id_is_refused() {
502+ assert!(parse_record(br#"{"affected": []}"#, "npm").is_err());
503+ assert!(parse_record(b"not json", "npm").is_err());
504+ }
505+
506+ #[test]
507+ fn reads_a_zip_dump_and_skips_bad_records() {
508+ use std::io::{Cursor, Write};
509+ let mut buf = Cursor::new(Vec::new());
510+ {
511+ let mut zip = zip::ZipWriter::new(&mut buf);
512+ let opts = zip::write::SimpleFileOptions::default();
513+ zip.start_file("GHSA-a.json", opts).expect("start");
514+ zip.write_all(
515+ br#"{"id":"GHSA-a","affected":[{"package":{"ecosystem":"npm","name":"left"},
516+ "ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"2.0.0"}]}]}]}"#,
517+ )
518+ .expect("write");
519+ zip.start_file("broken.json", opts).expect("start");
520+ zip.write_all(b"{ nope").expect("write");
521+ zip.start_file("readme.txt", opts).expect("start");
522+ zip.write_all(b"ignored").expect("write");
523+ zip.finish().expect("finish");
524+ }
525+ buf.set_position(0);
526+ let (found, skipped) = read_dump(buf, "npm").expect("dump reads");
527+ assert_eq!(skipped, 1);
528+ assert_eq!(found.len(), 1);
529+ assert_eq!(found[0].package, "left");
530+ }
531+
532+ #[test]
533+ fn parses_cargo_lock_registry_packages_only() {
534+ let text = r#"
535+version = 4
536+
537+[[package]]
538+name = "serde"
539+version = "1.0.0"
540+source = "registry+https://github.com/rust-lang/crates.io-index"
541+checksum = "abc"
542+
543+[[package]]
544+name = "local"
545+version = "0.1.0"
546+"#;
547+ let deps = parse_cargo_lock(text, "Cargo.lock").expect("parses");
548+ assert_eq!(deps.len(), 1);
549+ assert_eq!(deps[0].ecosystem, "crates.io");
550+ assert_eq!(deps[0].package, "serde");
551+ assert_eq!(deps[0].version, "1.0.0");
552+ assert_eq!(deps[0].manifest_path, "Cargo.lock");
553+ }
554+
555+ #[test]
556+ fn parses_package_lock_v3_packages() {
557+ let text = r#"{
558+ "lockfileVersion": 3,
559+ "packages": {
560+ "": {"name": "app", "version": "1.0.0"},
561+ "node_modules/left-pad": {"version": "1.3.0"},
562+ "node_modules/a/node_modules/@scope/inner": {"version": "2.0.1"},
563+ "node_modules/linked": {"link": true},
564+ "packages/ws": {"version": "0.0.1"}
565+ }
566+ }"#;
567+ let mut deps = parse_package_lock(text, "web/package-lock.json").expect("parses");
568+ deps.sort();
569+ let names: Vec<(&str, &str)> = deps
570+ .iter()
571+ .map(|d| (d.package.as_str(), d.version.as_str()))
572+ .collect();
573+ assert_eq!(
574+ names,
575+ vec![("@scope/inner", "2.0.1"), ("left-pad", "1.3.0")]
576+ );
577+ assert!(deps.iter().all(|d| d.ecosystem == "npm"));
578+ }
579+
580+ #[test]
581+ fn parses_go_sum_and_drops_go_mod_lines() {
582+ let text = "\
583+github.com/a/b v1.2.3 h1:abc=
584+github.com/a/b v1.2.3/go.mod h1:def=
585+github.com/c/d v0.0.0-20210101000000-abcdef123456 h1:ghi=
586+github.com/c/d v0.0.0-20210101000000-abcdef123456/go.mod h1:jkl=
587+";
588+ let deps = parse_go_sum(text, "go.sum");
589+ assert_eq!(deps.len(), 2);
590+ assert_eq!(deps[0].ecosystem, "Go");
591+ assert_eq!(deps[0].package, "github.com/a/b");
592+ assert_eq!(deps[0].version, "v1.2.3");
593+ }
594+}
crates/forge-core/src/audit.rsadded+413 -0
@@ -0,0 +1,413 @@
1+//! Security audit log: the action names, the entry shape and the list filter. The stored shape is
2+//! in migrations/0010_audit_log.sql; the store functions live in store/audit.rs. Rows are never
3+//! edited; only the retention purge removes them.
4+
5+use ulid::Ulid;
6+
7+use crate::time::now_stamp;
8+
9+/// Every security-relevant action that is recorded. The `as_str` names are stored and shown.
10+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11+pub enum AuditAction {
12+ LoginSuccess,
13+ LoginFailed,
14+ Login2faFailed,
15+ Logout,
16+ PasswordChanged,
17+ EmailChanged,
18+ PasswordResetByAdmin,
19+ TotpEnabled,
20+ TotpDisabled,
21+ RecoveryCodesRegenerated,
22+ SshKeyAdded,
23+ SshKeyRemoved,
24+ GpgKeyAdded,
25+ GpgKeyDeleted,
26+ TokenCreated,
27+ TokenRevoked,
28+ SessionRevoked,
29+ UserCreated,
30+ UsernameChanged,
31+ UserDeleted,
32+ RepoCreated,
33+ RepoDeleted,
34+ RepoRenamed,
35+ RepoTransferred,
36+ RepoTransferInitiated,
37+ RepoTransferAccepted,
38+ RepoTransferDeclined,
39+ RepoTransferCancelled,
40+ RepoVisibilityChanged,
41+ CollaboratorAdded,
42+ CollaboratorRemoved,
43+ CollaboratorRoleChanged,
44+ BranchRuleCreated,
45+ BranchRuleUpdated,
46+ BranchRuleDeleted,
47+ RepoArchived,
48+ RepoUnarchived,
49+ WebhookCreated,
50+ WebhookUpdated,
51+ WebhookDeleted,
52+ MirrorCreated,
53+ MirrorUpdated,
54+ MirrorDeleted,
55+ MirrorSyncFailed,
56+ BackupStarted,
57+ FsckStarted,
58+ ReplicationReconcileStarted,
59+ CiSecretSet,
60+ CiSecretDeleted,
61+ CiRunnerRegistered,
62+ CiRunnerDeleted,
63+ CiRegistrationTokenCreated,
64+ AdvisoryAlertDismissed,
65+ AdvisoryAlertReopened,
66+ UiPrefsChanged,
67+ DiscussionsEnabled,
68+ DiscussionsDisabled,
69+ DiscussionCreated,
70+ DiscussionEdited,
71+ DiscussionDeleted,
72+ DiscussionLocked,
73+ DiscussionUnlocked,
74+ DiscussionAnswered,
75+ DiscussionCommentCreated,
76+ DiscussionCommentEdited,
77+ DiscussionCommentDeleted,
78+ MailSettingsUpdated,
79+ MailTestQueued,
80+ SnippetCreated,
81+ SnippetUpdated,
82+ SnippetDeleted,
83+ WikiPageSaved,
84+ WikiEnabled,
85+ WikiDisabled,
86+ WikiPushed,
87+ OrgCreated,
88+ OrgDeleted,
89+ OrgSettingsUpdated,
90+ OrgMemberAdded,
91+ OrgMemberRemoved,
92+ OrgMemberRoleChanged,
93+ TeamCreated,
94+ TeamUpdated,
95+ TeamDeleted,
96+ TeamMemberAdded,
97+ TeamMemberRemoved,
98+ TeamRepoAdded,
99+ TeamRepoRemoved,
100+ PasswordResetCompleted,
101+ EmailVerified,
102+ InviteSent,
103+ InviteAccepted,
104+}
105+
106+impl AuditAction {
107+ /// Every action, in declaration order. Used to parse filter values.
108+ pub const ALL: &'static [AuditAction] = &[
109+ AuditAction::LoginSuccess,
110+ AuditAction::LoginFailed,
111+ AuditAction::Login2faFailed,
112+ AuditAction::Logout,
113+ AuditAction::PasswordChanged,
114+ AuditAction::EmailChanged,
115+ AuditAction::PasswordResetByAdmin,
116+ AuditAction::TotpEnabled,
117+ AuditAction::TotpDisabled,
118+ AuditAction::RecoveryCodesRegenerated,
119+ AuditAction::SshKeyAdded,
120+ AuditAction::SshKeyRemoved,
121+ AuditAction::GpgKeyAdded,
122+ AuditAction::GpgKeyDeleted,
123+ AuditAction::TokenCreated,
124+ AuditAction::TokenRevoked,
125+ AuditAction::SessionRevoked,
126+ AuditAction::UserCreated,
127+ AuditAction::UsernameChanged,
128+ AuditAction::UserDeleted,
129+ AuditAction::RepoCreated,
130+ AuditAction::RepoDeleted,
131+ AuditAction::RepoRenamed,
132+ AuditAction::RepoTransferred,
133+ AuditAction::RepoTransferInitiated,
134+ AuditAction::RepoTransferAccepted,
135+ AuditAction::RepoTransferDeclined,
136+ AuditAction::RepoTransferCancelled,
137+ AuditAction::RepoVisibilityChanged,
138+ AuditAction::CollaboratorAdded,
139+ AuditAction::CollaboratorRemoved,
140+ AuditAction::CollaboratorRoleChanged,
141+ AuditAction::BranchRuleCreated,
142+ AuditAction::BranchRuleUpdated,
143+ AuditAction::BranchRuleDeleted,
144+ AuditAction::RepoArchived,
145+ AuditAction::RepoUnarchived,
146+ AuditAction::WebhookCreated,
147+ AuditAction::WebhookUpdated,
148+ AuditAction::WebhookDeleted,
149+ AuditAction::MirrorCreated,
150+ AuditAction::MirrorUpdated,
151+ AuditAction::MirrorDeleted,
152+ AuditAction::MirrorSyncFailed,
153+ AuditAction::BackupStarted,
154+ AuditAction::FsckStarted,
155+ AuditAction::ReplicationReconcileStarted,
156+ AuditAction::CiSecretSet,
157+ AuditAction::CiSecretDeleted,
158+ AuditAction::CiRunnerRegistered,
159+ AuditAction::CiRunnerDeleted,
160+ AuditAction::CiRegistrationTokenCreated,
161+ AuditAction::AdvisoryAlertDismissed,
162+ AuditAction::AdvisoryAlertReopened,
163+ AuditAction::UiPrefsChanged,
164+ AuditAction::DiscussionsEnabled,
165+ AuditAction::DiscussionsDisabled,
166+ AuditAction::DiscussionCreated,
167+ AuditAction::DiscussionEdited,
168+ AuditAction::DiscussionDeleted,
169+ AuditAction::DiscussionLocked,
170+ AuditAction::DiscussionUnlocked,
171+ AuditAction::DiscussionAnswered,
172+ AuditAction::DiscussionCommentCreated,
173+ AuditAction::DiscussionCommentEdited,
174+ AuditAction::DiscussionCommentDeleted,
175+ AuditAction::MailSettingsUpdated,
176+ AuditAction::MailTestQueued,
177+ AuditAction::SnippetCreated,
178+ AuditAction::SnippetUpdated,
179+ AuditAction::SnippetDeleted,
180+ AuditAction::WikiPageSaved,
181+ AuditAction::WikiEnabled,
182+ AuditAction::WikiDisabled,
183+ AuditAction::WikiPushed,
184+ AuditAction::OrgCreated,
185+ AuditAction::OrgDeleted,
186+ AuditAction::OrgSettingsUpdated,
187+ AuditAction::OrgMemberAdded,
188+ AuditAction::OrgMemberRemoved,
189+ AuditAction::OrgMemberRoleChanged,
190+ AuditAction::TeamCreated,
191+ AuditAction::TeamUpdated,
192+ AuditAction::TeamDeleted,
193+ AuditAction::TeamMemberAdded,
194+ AuditAction::TeamMemberRemoved,
195+ AuditAction::TeamRepoAdded,
196+ AuditAction::TeamRepoRemoved,
197+ AuditAction::PasswordResetCompleted,
198+ AuditAction::EmailVerified,
199+ AuditAction::InviteSent,
200+ AuditAction::InviteAccepted,
201+ ];
202+
203+ pub fn as_str(self) -> &'static str {
204+ match self {
205+ AuditAction::LoginSuccess => "login_success",
206+ AuditAction::LoginFailed => "login_failed",
207+ AuditAction::Login2faFailed => "login_2fa_failed",
208+ AuditAction::Logout => "logout",
209+ AuditAction::PasswordChanged => "password_changed",
210+ AuditAction::EmailChanged => "email_changed",
211+ AuditAction::PasswordResetByAdmin => "password_reset_by_admin",
212+ AuditAction::TotpEnabled => "totp_enabled",
213+ AuditAction::TotpDisabled => "totp_disabled",
214+ AuditAction::RecoveryCodesRegenerated => "recovery_codes_regenerated",
215+ AuditAction::SshKeyAdded => "ssh_key_added",
216+ AuditAction::SshKeyRemoved => "ssh_key_removed",
217+ AuditAction::GpgKeyAdded => "gpg_key_added",
218+ AuditAction::GpgKeyDeleted => "gpg_key_deleted",
219+ AuditAction::TokenCreated => "token_created",
220+ AuditAction::TokenRevoked => "token_revoked",
221+ AuditAction::SessionRevoked => "session_revoked",
222+ AuditAction::UserCreated => "user_created",
223+ AuditAction::UsernameChanged => "username_changed",
224+ AuditAction::UserDeleted => "user_deleted",
225+ AuditAction::RepoCreated => "repo_created",
226+ AuditAction::RepoDeleted => "repo_deleted",
227+ AuditAction::RepoRenamed => "repo_renamed",
228+ AuditAction::RepoTransferred => "repo_transferred",
229+ AuditAction::RepoTransferInitiated => "repo_transfer_initiated",
230+ AuditAction::RepoTransferAccepted => "repo_transfer_accepted",
231+ AuditAction::RepoTransferDeclined => "repo_transfer_declined",
232+ AuditAction::RepoTransferCancelled => "repo_transfer_cancelled",
233+ AuditAction::RepoVisibilityChanged => "repo_visibility_changed",
234+ AuditAction::CollaboratorAdded => "collaborator_added",
235+ AuditAction::CollaboratorRemoved => "collaborator_removed",
236+ AuditAction::CollaboratorRoleChanged => "collaborator_role_changed",
237+ AuditAction::BranchRuleCreated => "branch_rule_created",
238+ AuditAction::BranchRuleUpdated => "branch_rule_updated",
239+ AuditAction::BranchRuleDeleted => "branch_rule_deleted",
240+ AuditAction::RepoArchived => "repo_archived",
241+ AuditAction::RepoUnarchived => "repo_unarchived",
242+ AuditAction::WebhookCreated => "webhook_created",
243+ AuditAction::WebhookUpdated => "webhook_updated",
244+ AuditAction::WebhookDeleted => "webhook_deleted",
245+ AuditAction::MirrorCreated => "mirror_created",
246+ AuditAction::MirrorUpdated => "mirror_updated",
247+ AuditAction::MirrorDeleted => "mirror_deleted",
248+ AuditAction::MirrorSyncFailed => "mirror_sync_failed",
249+ AuditAction::BackupStarted => "backup_started",
250+ AuditAction::FsckStarted => "fsck_started",
251+ AuditAction::ReplicationReconcileStarted => "replication_reconcile_started",
252+ AuditAction::CiSecretSet => "ci_secret_set",
253+ AuditAction::CiSecretDeleted => "ci_secret_deleted",
254+ AuditAction::CiRunnerRegistered => "ci_runner_registered",
255+ AuditAction::CiRunnerDeleted => "ci_runner_deleted",
256+ AuditAction::CiRegistrationTokenCreated => "ci_registration_token_created",
257+ AuditAction::AdvisoryAlertDismissed => "advisory_alert_dismissed",
258+ AuditAction::AdvisoryAlertReopened => "advisory_alert_reopened",
259+ AuditAction::UiPrefsChanged => "ui_prefs_changed",
260+ AuditAction::DiscussionsEnabled => "discussions_enabled",
261+ AuditAction::DiscussionsDisabled => "discussions_disabled",
262+ AuditAction::DiscussionCreated => "discussion_created",
263+ AuditAction::DiscussionEdited => "discussion_edited",
264+ AuditAction::DiscussionDeleted => "discussion_deleted",
265+ AuditAction::DiscussionLocked => "discussion_locked",
266+ AuditAction::DiscussionUnlocked => "discussion_unlocked",
267+ AuditAction::DiscussionAnswered => "discussion_answered",
268+ AuditAction::DiscussionCommentCreated => "discussion_comment_created",
269+ AuditAction::DiscussionCommentEdited => "discussion_comment_edited",
270+ AuditAction::DiscussionCommentDeleted => "discussion_comment_deleted",
271+ AuditAction::MailSettingsUpdated => "mail_settings_updated",
272+ AuditAction::MailTestQueued => "mail_test_queued",
273+ AuditAction::SnippetCreated => "snippet_created",
274+ AuditAction::SnippetUpdated => "snippet_updated",
275+ AuditAction::SnippetDeleted => "snippet_deleted",
276+ AuditAction::WikiPageSaved => "wiki_page_saved",
277+ AuditAction::WikiEnabled => "wiki_enabled",
278+ AuditAction::WikiDisabled => "wiki_disabled",
279+ AuditAction::WikiPushed => "wiki_pushed",
280+ AuditAction::OrgCreated => "org_created",
281+ AuditAction::OrgDeleted => "org_deleted",
282+ AuditAction::OrgSettingsUpdated => "org_settings_updated",
283+ AuditAction::OrgMemberAdded => "org_member_added",
284+ AuditAction::OrgMemberRemoved => "org_member_removed",
285+ AuditAction::OrgMemberRoleChanged => "org_member_role_changed",
286+ AuditAction::TeamCreated => "team_created",
287+ AuditAction::TeamUpdated => "team_updated",
288+ AuditAction::TeamDeleted => "team_deleted",
289+ AuditAction::TeamMemberAdded => "team_member_added",
290+ AuditAction::TeamMemberRemoved => "team_member_removed",
291+ AuditAction::TeamRepoAdded => "team_repo_added",
292+ AuditAction::TeamRepoRemoved => "team_repo_removed",
293+ AuditAction::PasswordResetCompleted => "password_reset_completed",
294+ AuditAction::EmailVerified => "email_verified",
295+ AuditAction::InviteSent => "invite_sent",
296+ AuditAction::InviteAccepted => "invite_accepted",
297+ }
298+ }
299+
300+ /// The action with this stored name, if any.
301+ pub fn parse(name: &str) -> Option<AuditAction> {
302+ AuditAction::ALL
303+ .iter()
304+ .copied()
305+ .find(|action| action.as_str() == name)
306+ }
307+}
308+
309+/// One row of the audit log. `at` is an RFC 3339 UTC stamp. `data` is a JSON object as text.
310+#[derive(Debug, Clone, PartialEq, Eq)]
311+pub struct AuditEntry {
312+ pub id: String,
313+ pub at: String,
314+ pub actor_id: Option<String>,
315+ pub actor_name: String,
316+ pub action: AuditAction,
317+ pub target_kind: String,
318+ pub target_id: String,
319+ pub target_name: String,
320+ pub ip: String,
321+ pub data: String,
322+}
323+
324+impl AuditEntry {
325+ /// A new entry stamped with the current time and a fresh id. Everything else is empty.
326+ pub fn new(action: AuditAction) -> AuditEntry {
327+ AuditEntry {
328+ id: Ulid::generate().to_string(),
329+ at: now_stamp(),
330+ actor_id: None,
331+ actor_name: String::new(),
332+ action,
333+ target_kind: String::new(),
334+ target_id: String::new(),
335+ target_name: String::new(),
336+ ip: String::new(),
337+ data: "{}".to_string(),
338+ }
339+ }
340+}
341+
342+/// Optional filters for [`crate::store::Store::list_audit`]. Unset fields do not restrict.
343+#[derive(Debug, Clone, Default, PartialEq, Eq)]
344+pub struct AuditFilter {
345+ pub actor_id: Option<String>,
346+ pub action: Option<AuditAction>,
347+ pub target_kind: Option<String>,
348+ pub target_id: Option<String>,
349+ /// Matches entries where this user is the actor, or the `user` target.
350+ pub involving_user: Option<String>,
351+}
352+
353+/// Position in the newest-first listing: the last entry of the previous page.
354+#[derive(Debug, Clone, PartialEq, Eq)]
355+pub struct AuditCursor {
356+ pub at: String,
357+ pub id: String,
358+}
359+
360+impl AuditCursor {
361+ /// The cursor for the entry a page ended on.
362+ pub fn after(entry: &AuditEntry) -> AuditCursor {
363+ AuditCursor {
364+ at: entry.at.clone(),
365+ id: entry.id.clone(),
366+ }
367+ }
368+
369+ /// Text form for a URL: `<at>_<id>`. Neither part contains `_`.
370+ pub fn encode(&self) -> String {
371+ format!("{}_{}", self.at, self.id)
372+ }
373+
374+ /// Parses [`AuditCursor::encode`] output. Anything malformed is `None`.
375+ pub fn decode(text: &str) -> Option<AuditCursor> {
376+ let (at, id) = text.split_once('_')?;
377+ let stamp_ok = at.len() == 30 && at.ends_with('Z') && at.contains('T');
378+ let id_ok = !id.is_empty() && id.bytes().all(|b| b.is_ascii_alphanumeric());
379+ (stamp_ok && id_ok).then(|| AuditCursor {
380+ at: at.to_string(),
381+ id: id.to_string(),
382+ })
383+ }
384+}
385+
386+#[cfg(test)]
387+mod tests {
388+ use super::*;
389+
390+ #[test]
391+ fn names_are_unique_and_round_trip() {
392+ let mut names: Vec<&str> = AuditAction::ALL.iter().map(|a| a.as_str()).collect();
393+ assert_eq!(names.len(), 92);
394+ for action in AuditAction::ALL {
395+ assert_eq!(AuditAction::parse(action.as_str()), Some(*action));
396+ }
397+ names.sort();
398+ names.dedup();
399+ assert_eq!(names.len(), 92);
400+ assert_eq!(AuditAction::parse("nope"), None);
401+ }
402+
403+ #[test]
404+ fn cursor_round_trips_and_rejects_junk() {
405+ let entry = AuditEntry::new(AuditAction::Logout);
406+ let cursor = AuditCursor::after(&entry);
407+ assert_eq!(AuditCursor::decode(&cursor.encode()), Some(cursor));
408+ assert_eq!(AuditCursor::decode(""), None);
409+ assert_eq!(AuditCursor::decode("nounderscore"), None);
410+ assert_eq!(AuditCursor::decode("2026-10-15T00:00:00Z_"), None);
411+ assert_eq!(AuditCursor::decode("bogus_01ABC"), None);
412+ }
413+}
crates/forge-core/src/authz.rsadded+569 -0
@@ -0,0 +1,569 @@
1+//! Authorization rules. Pure, no IO.
2+
3+use crate::model::{Repo, Role, User, Visibility};
4+
5+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6+pub enum Action {
7+ Read,
8+ /// Edit anyone's issue or comment, close/reopen, labels, assignees, milestones.
9+ /// Not blocked by `archived` (the pages handle archived repos).
10+ Triage,
11+ Write,
12+ Admin,
13+ /// Delete the repo. Owner and instance admin only; a collaborator role never grants it.
14+ Delete,
15+}
16+
17+/// `NotFound` when the actor cannot even see the repo; `Forbidden` when they can see it but lack the right.
18+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19+pub enum Denied {
20+ NotFound,
21+ Forbidden,
22+}
23+
24+/// The higher of two optional roles. `Read < Triage < Write < Maintain < Admin`.
25+pub fn max_role(a: Option<Role>, b: Option<Role>) -> Option<Role> {
26+ fn rank(role: Role) -> u8 {
27+ match role {
28+ Role::Read => 1,
29+ Role::Triage => 2,
30+ Role::Write => 3,
31+ Role::Maintain => 4,
32+ Role::Admin => 5,
33+ }
34+ }
35+ match (a, b) {
36+ (Some(x), Some(y)) => Some(if rank(y) > rank(x) { y } else { x }),
37+ (x, None) => x,
38+ (None, y) => y,
39+ }
40+}
41+
42+/// Owner or instance admin: all actions. Public repo: Read for anyone.
43+/// Private repo with no role: NotFound. Read/Triage: Read. Write/Maintain: Read+Write. Admin: all.
44+/// Triage is granted to Triage, Write, Maintain and Admin.
45+/// Archived repos deny Write with Forbidden.
46+///
47+/// `org_role` is the role the actor gets through an organization that owns the repo (see
48+/// `Store::org_repo_role`). The effective role is `max_role(collaborator_role, org_role)`. A
49+/// collaborator Admin never deletes a repo; an org-derived Admin (org owner, or a team with
50+/// the admin role) does, because org-owned repos have no single owner person.
51+pub fn authorize(
52+ actor: Option<&User>,
53+ repo: &Repo,
54+ collaborator_role: Option<Role>,
55+ org_role: Option<Role>,
56+ action: Action,
57+) -> Result<(), Denied> {
58+ // A role only belongs to a signed-in actor.
59+ let role = if actor.is_some() {
60+ max_role(collaborator_role, org_role)
61+ } else {
62+ None
63+ };
64+
65+ if action == Action::Write && repo.archived {
66+ return Err(Denied::Forbidden);
67+ }
68+
69+ let privileged = actor.is_some_and(|u| u.is_admin || u.id == repo.owner_id);
70+ if privileged {
71+ return Ok(());
72+ }
73+
74+ let can_see = repo.visibility == Visibility::Public || role.is_some();
75+ if !can_see {
76+ return Err(Denied::NotFound);
77+ }
78+
79+ let granted = match action {
80+ Action::Read => true,
81+ Action::Triage => matches!(
82+ role,
83+ Some(Role::Triage | Role::Write | Role::Maintain | Role::Admin)
84+ ),
85+ Action::Write => matches!(role, Some(Role::Write | Role::Maintain | Role::Admin)),
86+ Action::Admin => matches!(role, Some(Role::Admin)),
87+ Action::Delete => actor.is_some() && org_role == Some(Role::Admin),
88+ };
89+ if granted {
90+ Ok(())
91+ } else {
92+ Err(Denied::Forbidden)
93+ }
94+}
95+
96+#[cfg(test)]
97+mod tests {
98+ use super::*;
99+ use crate::model::AccountKind;
100+
101+ const ACTIONS: [Action; 5] = [
102+ Action::Read,
103+ Action::Triage,
104+ Action::Write,
105+ Action::Admin,
106+ Action::Delete,
107+ ];
108+ const ROLES: [Option<Role>; 6] = [
109+ None,
110+ Some(Role::Read),
111+ Some(Role::Triage),
112+ Some(Role::Write),
113+ Some(Role::Maintain),
114+ Some(Role::Admin),
115+ ];
116+
117+ fn user(id: &str, is_admin: bool) -> User {
118+ User {
119+ id: id.into(),
120+ username: id.into(),
121+ email: None,
122+ password_hash: String::new(),
123+ is_admin,
124+ created_at: String::new(),
125+ about: String::new(),
126+ timezone: "UTC".into(),
127+ avatar_hash: None,
128+ theme: "auto".into(),
129+ language: "auto".into(),
130+ kind: AccountKind::User,
131+ email_notifications: true,
132+ }
133+ }
134+
135+ fn repo(visibility: Visibility, archived: bool) -> Repo {
136+ Repo {
137+ id: "repo1".into(),
138+ owner_id: "owner".into(),
139+ owner_name: "owner".into(),
140+ name: "r".into(),
141+ description: String::new(),
142+ visibility,
143+ default_branch: "main".into(),
144+ archived,
145+ created_at: String::new(),
146+ fork_of: None,
147+ }
148+ }
149+
150+ /// Expected result from the contract, written independently of the implementation.
151+ fn expected(
152+ actor: Option<&User>,
153+ r: &Repo,
154+ role: Option<Role>,
155+ action: Action,
156+ ) -> Result<(), Denied> {
157+ let role = if actor.is_some() { role } else { None };
158+ if action == Action::Write && r.archived {
159+ return Err(Denied::Forbidden);
160+ }
161+ if actor.is_some_and(|u| u.is_admin || u.id == r.owner_id) {
162+ return Ok(());
163+ }
164+ if action == Action::Delete {
165+ // Only owner or instance admin: hidden repos are 404, visible ones 403.
166+ return if r.visibility == Visibility::Public || role.is_some() {
167+ Err(Denied::Forbidden)
168+ } else {
169+ Err(Denied::NotFound)
170+ };
171+ }
172+ if r.visibility == Visibility::Public {
173+ let ok = match action {
174+ Action::Read => true,
175+ Action::Triage => matches!(
176+ role,
177+ Some(Role::Triage | Role::Write | Role::Maintain | Role::Admin)
178+ ),
179+ Action::Write => matches!(role, Some(Role::Write | Role::Maintain | Role::Admin)),
180+ Action::Admin => role == Some(Role::Admin),
181+ Action::Delete => false,
182+ };
183+ return if ok { Ok(()) } else { Err(Denied::Forbidden) };
184+ }
185+ match role {
186+ None => Err(Denied::NotFound),
187+ Some(r) => {
188+ let ok = match action {
189+ Action::Read => true,
190+ Action::Triage => {
191+ matches!(r, Role::Triage | Role::Write | Role::Maintain | Role::Admin)
192+ }
193+ Action::Write => matches!(r, Role::Write | Role::Maintain | Role::Admin),
194+ Action::Admin => r == Role::Admin,
195+ Action::Delete => false,
196+ };
197+ if ok { Ok(()) } else { Err(Denied::Forbidden) }
198+ }
199+ }
200+ }
201+
202+ #[test]
203+ fn exhaustive_matrix_matches_contract() {
204+ let stranger = user("stranger", false);
205+ let owner = user("owner", false);
206+ let admin = user("root", true);
207+ let actors: [Option<&User>; 4] = [None, Some(&stranger), Some(&owner), Some(&admin)];
208+ for visibility in [Visibility::Public, Visibility::Private] {
209+ for archived in [false, true] {
210+ let r = repo(visibility, archived);
211+ for actor in actors {
212+ for role in ROLES {
213+ for action in ACTIONS {
214+ assert_eq!(
215+ authorize(actor, &r, role, None, action),
216+ expected(actor, &r, role, action),
217+ "vis={visibility:?} archived={archived} actor={:?} role={role:?} action={action:?}",
218+ actor.map(|u| u.id.as_str()),
219+ );
220+ }
221+ }
222+ }
223+ }
224+ }
225+ }
226+
227+ #[test]
228+ fn anonymous_on_public_repo_can_read_only() {
229+ let r = repo(Visibility::Public, false);
230+ assert_eq!(authorize(None, &r, None, None, Action::Read), Ok(()));
231+ assert_eq!(
232+ authorize(None, &r, None, None, Action::Write),
233+ Err(Denied::Forbidden)
234+ );
235+ assert_eq!(
236+ authorize(None, &r, None, None, Action::Admin),
237+ Err(Denied::Forbidden)
238+ );
239+ }
240+
241+ #[test]
242+ fn anonymous_on_private_repo_gets_not_found() {
243+ let r = repo(Visibility::Private, false);
244+ for action in ACTIONS {
245+ assert_eq!(
246+ authorize(None, &r, None, None, action),
247+ Err(Denied::NotFound)
248+ );
249+ }
250+ }
251+
252+ #[test]
253+ fn owner_and_instance_admin_can_do_everything() {
254+ for visibility in [Visibility::Public, Visibility::Private] {
255+ let r = repo(visibility, false);
256+ let owner = user("owner", false);
257+ let admin = user("root", true);
258+ for action in ACTIONS {
259+ assert_eq!(authorize(Some(&owner), &r, None, None, action), Ok(()));
260+ assert_eq!(authorize(Some(&admin), &r, None, None, action), Ok(()));
261+ }
262+ }
263+ }
264+
265+ #[test]
266+ fn each_role_on_private_repo() {
267+ let r = repo(Visibility::Private, false);
268+ let u = user("collab", false);
269+ let cases = [
270+ (
271+ Role::Read,
272+ [
273+ Ok(()),
274+ Err(Denied::Forbidden),
275+ Err(Denied::Forbidden),
276+ Err(Denied::Forbidden),
277+ Err(Denied::Forbidden),
278+ ],
279+ ),
280+ (
281+ Role::Triage,
282+ [
283+ Ok(()),
284+ Ok(()),
285+ Err(Denied::Forbidden),
286+ Err(Denied::Forbidden),
287+ Err(Denied::Forbidden),
288+ ],
289+ ),
290+ (
291+ Role::Write,
292+ [
293+ Ok(()),
294+ Ok(()),
295+ Ok(()),
296+ Err(Denied::Forbidden),
297+ Err(Denied::Forbidden),
298+ ],
299+ ),
300+ (
301+ Role::Maintain,
302+ [
303+ Ok(()),
304+ Ok(()),
305+ Ok(()),
306+ Err(Denied::Forbidden),
307+ Err(Denied::Forbidden),
308+ ],
309+ ),
310+ (
311+ Role::Admin,
312+ [Ok(()), Ok(()), Ok(()), Ok(()), Err(Denied::Forbidden)],
313+ ),
314+ ];
315+ for (role, results) in cases {
316+ for (action, want) in ACTIONS.into_iter().zip(results) {
317+ assert_eq!(
318+ authorize(Some(&u), &r, Some(role), None, action),
319+ want,
320+ "role {role:?} {action:?}"
321+ );
322+ }
323+ }
324+ }
325+
326+ #[test]
327+ fn each_role_on_public_repo_keeps_read() {
328+ let r = repo(Visibility::Public, false);
329+ let u = user("collab", false);
330+ assert_eq!(authorize(Some(&u), &r, None, None, Action::Read), Ok(()));
331+ assert_eq!(
332+ authorize(Some(&u), &r, None, None, Action::Write),
333+ Err(Denied::Forbidden)
334+ );
335+ assert_eq!(
336+ authorize(Some(&u), &r, Some(Role::Read), None, Action::Read),
337+ Ok(())
338+ );
339+ assert_eq!(
340+ authorize(Some(&u), &r, Some(Role::Maintain), None, Action::Write),
341+ Ok(())
342+ );
343+ assert_eq!(
344+ authorize(Some(&u), &r, Some(Role::Maintain), None, Action::Admin),
345+ Err(Denied::Forbidden)
346+ );
347+ assert_eq!(
348+ authorize(Some(&u), &r, Some(Role::Admin), None, Action::Admin),
349+ Ok(())
350+ );
351+ }
352+
353+ #[test]
354+ fn stranger_on_private_repo_gets_not_found_not_forbidden() {
355+ let r = repo(Visibility::Private, false);
356+ let u = user("stranger", false);
357+ for action in ACTIONS {
358+ assert_eq!(
359+ authorize(Some(&u), &r, None, None, action),
360+ Err(Denied::NotFound)
361+ );
362+ }
363+ }
364+
365+ #[test]
366+ fn archived_denies_write_even_for_owner_and_admin_but_not_read_or_admin() {
367+ let r = repo(Visibility::Public, true);
368+ let owner = user("owner", false);
369+ let root = user("root", true);
370+ for actor in [&owner, &root] {
371+ assert_eq!(
372+ authorize(Some(actor), &r, None, None, Action::Write),
373+ Err(Denied::Forbidden)
374+ );
375+ assert_eq!(authorize(Some(actor), &r, None, None, Action::Read), Ok(()));
376+ assert_eq!(
377+ authorize(Some(actor), &r, None, None, Action::Admin),
378+ Ok(())
379+ );
380+ }
381+ let maintainer = user("m", false);
382+ assert_eq!(
383+ authorize(
384+ Some(&maintainer),
385+ &r,
386+ Some(Role::Maintain),
387+ None,
388+ Action::Write
389+ ),
390+ Err(Denied::Forbidden)
391+ );
392+ }
393+
394+ #[test]
395+ fn delete_is_owner_and_instance_admin_only() {
396+ let stranger = user("stranger", false);
397+ let owner = user("owner", false);
398+ let root = user("root", true);
399+ for visibility in [Visibility::Public, Visibility::Private] {
400+ let r = repo(visibility, false);
401+ assert_eq!(
402+ authorize(Some(&owner), &r, None, None, Action::Delete),
403+ Ok(())
404+ );
405+ assert_eq!(
406+ authorize(Some(&root), &r, None, None, Action::Delete),
407+ Ok(())
408+ );
409+ // A collaborator with the top role still cannot delete.
410+ assert_eq!(
411+ authorize(Some(&stranger), &r, Some(Role::Admin), None, Action::Delete),
412+ Err(Denied::Forbidden)
413+ );
414+ assert_eq!(
415+ authorize(None, &r, Some(Role::Admin), None, Action::Delete),
416+ if visibility == Visibility::Public {
417+ Err(Denied::Forbidden)
418+ } else {
419+ Err(Denied::NotFound)
420+ }
421+ );
422+ }
423+ // Visible to a stranger on a public repo: 403. Hidden: 404.
424+ let public = repo(Visibility::Public, false);
425+ assert_eq!(
426+ authorize(Some(&stranger), &public, None, None, Action::Delete),
427+ Err(Denied::Forbidden)
428+ );
429+ let private = repo(Visibility::Private, false);
430+ assert_eq!(
431+ authorize(Some(&stranger), &private, None, None, Action::Delete),
432+ Err(Denied::NotFound)
433+ );
434+ // Archiving does not stop an owner from deleting.
435+ let archived = repo(Visibility::Private, true);
436+ assert_eq!(
437+ authorize(Some(&owner), &archived, None, None, Action::Delete),
438+ Ok(())
439+ );
440+ }
441+
442+ #[test]
443+ fn triage_is_granted_to_owner_admin_and_triage_plus_write_roles() {
444+ let stranger = user("stranger", false);
445+ let owner = user("owner", false);
446+ let root = user("root", true);
447+ let private = repo(Visibility::Private, false);
448+ assert_eq!(
449+ authorize(Some(&owner), &private, None, None, Action::Triage),
450+ Ok(())
451+ );
452+ assert_eq!(
453+ authorize(Some(&root), &private, None, None, Action::Triage),
454+ Ok(())
455+ );
456+ assert_eq!(
457+ authorize(Some(&stranger), &private, None, None, Action::Triage),
458+ Err(Denied::NotFound)
459+ );
460+ let public = repo(Visibility::Public, false);
461+ assert_eq!(
462+ authorize(
463+ Some(&stranger),
464+ &public,
465+ Some(Role::Read),
466+ None,
467+ Action::Triage
468+ ),
469+ Err(Denied::Forbidden)
470+ );
471+ assert_eq!(
472+ authorize(None, &public, None, None, Action::Triage),
473+ Err(Denied::Forbidden)
474+ );
475+ assert_eq!(
476+ authorize(None, &private, None, None, Action::Triage),
477+ Err(Denied::NotFound)
478+ );
479+ }
480+
481+ #[test]
482+ fn triage_is_not_blocked_by_archived() {
483+ let archived = repo(Visibility::Public, true);
484+ let owner = user("owner", false);
485+ let triager = user("t", false);
486+ assert_eq!(
487+ authorize(Some(&owner), &archived, None, None, Action::Triage),
488+ Ok(())
489+ );
490+ assert_eq!(
491+ authorize(
492+ Some(&triager),
493+ &archived,
494+ Some(Role::Triage),
495+ None,
496+ Action::Triage
497+ ),
498+ Ok(())
499+ );
500+ }
501+
502+ #[test]
503+ fn role_is_ignored_without_an_actor() {
504+ let r = repo(Visibility::Private, false);
505+ assert_eq!(
506+ authorize(None, &r, Some(Role::Admin), None, Action::Read),
507+ Err(Denied::NotFound)
508+ );
509+ }
510+
511+ #[test]
512+ fn max_role_takes_the_higher_of_two() {
513+ assert_eq!(max_role(None, None), None);
514+ assert_eq!(max_role(Some(Role::Read), None), Some(Role::Read));
515+ assert_eq!(max_role(None, Some(Role::Write)), Some(Role::Write));
516+ assert_eq!(
517+ max_role(Some(Role::Maintain), Some(Role::Triage)),
518+ Some(Role::Maintain)
519+ );
520+ assert_eq!(
521+ max_role(Some(Role::Read), Some(Role::Admin)),
522+ Some(Role::Admin)
523+ );
524+ }
525+
526+ #[test]
527+ fn org_role_grants_what_the_collaborator_role_would() {
528+ let r = repo(Visibility::Private, false);
529+ let member = user("member", false);
530+ // Read-only collaborator, but the org gives Write.
531+ assert_eq!(
532+ authorize(
533+ Some(&member),
534+ &r,
535+ Some(Role::Read),
536+ Some(Role::Write),
537+ Action::Write
538+ ),
539+ Ok(())
540+ );
541+ // An org role alone makes a private repo visible.
542+ assert_eq!(
543+ authorize(Some(&member), &r, None, Some(Role::Read), Action::Read),
544+ Ok(())
545+ );
546+ assert_eq!(
547+ authorize(Some(&member), &r, None, None, Action::Read),
548+ Err(Denied::NotFound)
549+ );
550+ }
551+
552+ #[test]
553+ fn org_admin_may_delete_but_collaborator_admin_may_not() {
554+ let r = repo(Visibility::Private, false);
555+ let member = user("member", false);
556+ assert_eq!(
557+ authorize(Some(&member), &r, None, Some(Role::Admin), Action::Delete),
558+ Ok(())
559+ );
560+ assert_eq!(
561+ authorize(Some(&member), &r, Some(Role::Admin), None, Action::Delete),
562+ Err(Denied::Forbidden)
563+ );
564+ assert_eq!(
565+ authorize(None, &r, None, Some(Role::Admin), Action::Delete),
566+ Err(Denied::NotFound)
567+ );
568+ }
569+}
crates/forge-core/src/avatar.rsadded+182 -0
@@ -0,0 +1,182 @@
1+//! Avatar upload processing: validate, decode with limits, crop, resize and re-encode as PNG.
2+//!
3+//! Re-encoding to PNG drops all metadata (EXIF, GPS, comments) from the input.
4+
5+use std::io::Cursor;
6+
7+use image::{ImageFormat, ImageReader, Limits, imageops::FilterType};
8+use sha2::{Digest, Sha256};
9+
10+use crate::error::{Error, Result};
11+
12+/// Largest accepted upload, in bytes.
13+pub const MAX_UPLOAD_BYTES: usize = 5 * 1024 * 1024;
14+
15+/// Largest accepted width or height of the decoded source image, in pixels.
16+const MAX_SOURCE_DIMENSION: u32 = 4096;
17+
18+/// Upper bound on decoder allocations (128 MiB).
19+const MAX_ALLOC_BYTES: u64 = 128 * 1024 * 1024;
20+
21+/// Edge length of the stored avatar, in pixels.
22+const AVATAR_SIZE: u32 = 256;
23+
24+#[derive(Debug, Clone, PartialEq, Eq)]
25+pub struct ProcessedAvatar {
26+ pub png: Vec<u8>,
27+ pub hash: String,
28+}
29+
30+/// Decode with limits (max 4096x4096, image::Limits), center-crop to a square, resize to 256x256
31+/// (Lanczos3), re-encode as PNG. Metadata (EXIF/GPS) is dropped by re-encoding. SVG, unknown
32+/// formats, too-large input or a decode failure give Error::Invalid.
33+pub fn process_avatar(bytes: &[u8]) -> Result<ProcessedAvatar> {
34+ if bytes.len() > MAX_UPLOAD_BYTES {
35+ return Err(Error::Invalid(format!(
36+ "avatar is larger than {MAX_UPLOAD_BYTES} bytes"
37+ )));
38+ }
39+
40+ // Format is decided from magic bytes only; the client-supplied type is never consulted.
41+ let mut reader = ImageReader::new(Cursor::new(bytes))
42+ .with_guessed_format()
43+ .map_err(|_| Error::Invalid("unreadable image header".into()))?;
44+ match reader.format() {
45+ Some(ImageFormat::Png | ImageFormat::Jpeg | ImageFormat::WebP | ImageFormat::Gif) => {}
46+ _ => {
47+ return Err(Error::Invalid(
48+ "avatar must be a PNG, JPEG, WebP or GIF image".into(),
49+ ));
50+ }
51+ }
52+
53+ let mut limits = Limits::default();
54+ limits.max_image_width = Some(MAX_SOURCE_DIMENSION);
55+ limits.max_image_height = Some(MAX_SOURCE_DIMENSION);
56+ limits.max_alloc = Some(MAX_ALLOC_BYTES);
57+ reader.limits(limits);
58+
59+ let decoded = reader
60+ .decode()
61+ .map_err(|e| Error::Invalid(format!("could not decode avatar: {e}")))?;
62+
63+ // Center-crop to the largest square, then scale down to the avatar size.
64+ let (width, height) = (decoded.width(), decoded.height());
65+ let side = width.min(height);
66+ let x = (width - side) / 2;
67+ let y = (height - side) / 2;
68+ let square = decoded.crop_imm(x, y, side, side);
69+ let resized = square.resize_exact(AVATAR_SIZE, AVATAR_SIZE, FilterType::Lanczos3);
70+
71+ let mut png = Cursor::new(Vec::new());
72+ resized
73+ .write_to(&mut png, ImageFormat::Png)
74+ .map_err(|e| Error::Invalid(format!("could not encode avatar: {e}")))?;
75+ let png = png.into_inner();
76+
77+ let digest = Sha256::digest(&png);
78+ let hash = hex::encode(&digest[..8]);
79+
80+ Ok(ProcessedAvatar { png, hash })
81+}
82+
83+#[cfg(test)]
84+mod tests {
85+ use super::*;
86+ use image::{DynamicImage, RgbImage};
87+
88+ const PNG_MAGIC: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
89+
90+ fn encode(img: &DynamicImage, format: ImageFormat) -> Vec<u8> {
91+ let mut out = Cursor::new(Vec::new());
92+ img.write_to(&mut out, format).expect("encode test image");
93+ out.into_inner()
94+ }
95+
96+ fn sample(width: u32, height: u32) -> DynamicImage {
97+ DynamicImage::ImageRgb8(RgbImage::from_fn(width, height, |x, y| {
98+ image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
99+ }))
100+ }
101+
102+ fn crc32(data: &[u8]) -> u32 {
103+ let mut crc = 0xFFFF_FFFFu32;
104+ for &byte in data {
105+ crc ^= u32::from(byte);
106+ for _ in 0..8 {
107+ let mask = (crc & 1).wrapping_neg();
108+ crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
109+ }
110+ }
111+ !crc
112+ }
113+
114+ #[test]
115+ fn png_is_cropped_and_resized_to_256() {
116+ let input = encode(&sample(300, 200), ImageFormat::Png);
117+ let out = process_avatar(&input).expect("png accepted");
118+ assert_eq!(&out.png[..8], &PNG_MAGIC);
119+ let decoded = image::load_from_memory_with_format(&out.png, ImageFormat::Png).unwrap();
120+ assert_eq!((decoded.width(), decoded.height()), (256, 256));
121+ }
122+
123+ #[test]
124+ fn jpeg_is_accepted() {
125+ let input = encode(&sample(300, 200), ImageFormat::Jpeg);
126+ let out = process_avatar(&input).expect("jpeg accepted");
127+ assert_eq!(&out.png[..8], &PNG_MAGIC);
128+ }
129+
130+ #[test]
131+ fn svg_is_rejected() {
132+ let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>"#;
133+ assert!(matches!(process_avatar(svg), Err(Error::Invalid(_))));
134+ }
135+
136+ #[test]
137+ fn random_bytes_are_rejected() {
138+ let junk: Vec<u8> = (0..4096u32)
139+ .map(|i| (i.wrapping_mul(2654435761) >> 13) as u8)
140+ .collect();
141+ assert!(matches!(process_avatar(&junk), Err(Error::Invalid(_))));
142+ assert!(matches!(process_avatar(&[]), Err(Error::Invalid(_))));
143+ }
144+
145+ #[test]
146+ fn oversized_input_is_rejected() {
147+ let input = vec![0u8; MAX_UPLOAD_BYTES + 1];
148+ assert!(matches!(process_avatar(&input), Err(Error::Invalid(_))));
149+ }
150+
151+ #[test]
152+ fn huge_declared_dimensions_are_rejected() {
153+ // Take a valid tiny PNG and rewrite its IHDR to claim 50000x50000, fixing the CRC so
154+ // only the dimension limit can reject it.
155+ let mut png = encode(&sample(2, 2), ImageFormat::Png);
156+ // Layout: 8-byte signature, 4-byte length, 4-byte "IHDR", then width and height.
157+ assert_eq!(&png[12..16], b"IHDR");
158+ png[16..20].copy_from_slice(&50_000u32.to_be_bytes());
159+ png[20..24].copy_from_slice(&50_000u32.to_be_bytes());
160+ let crc = crc32(&png[12..29]);
161+ png[29..33].copy_from_slice(&crc.to_be_bytes());
162+ assert!(matches!(process_avatar(&png), Err(Error::Invalid(_))));
163+ }
164+
165+ #[test]
166+ fn output_starts_with_png_magic() {
167+ let input = encode(&sample(64, 64), ImageFormat::Png);
168+ let out = process_avatar(&input).unwrap();
169+ assert_eq!(&out.png[..8], &PNG_MAGIC);
170+ }
171+
172+ #[test]
173+ fn hash_is_stable_and_well_formed() {
174+ let input = encode(&sample(120, 90), ImageFormat::Png);
175+ let a = process_avatar(&input).unwrap();
176+ let b = process_avatar(&input).unwrap();
177+ assert_eq!(a.hash, b.hash);
178+ assert_eq!(a.hash.len(), 16);
179+ assert!(a.hash.bytes().all(|c| c.is_ascii_hexdigit()));
180+ assert_eq!(a.png, b.png);
181+ }
182+}
crates/forge-core/src/ci.rsadded+185 -0
@@ -0,0 +1,185 @@
1+//! CI models: run and job states, runners, runs, jobs and artifacts. The stored shape is in
2+//! migrations/0024_ci.sql; the store functions live in store/ci.rs.
3+//!
4+//! Job specs are kept as JSON text (`CiJob::spec_json`). The store reads only the `needs` and
5+//! `runs_on` arrays out of it, so this crate does not depend on forge-ci.
6+
7+use std::str::FromStr;
8+
9+use crate::error::{Error, Result};
10+
11+/// State of a run. Stored as the `as_str` name.
12+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13+pub enum RunState {
14+ WaitingApproval,
15+ Queued,
16+ Running,
17+ Success,
18+ Failure,
19+ Cancelled,
20+ Error,
21+}
22+
23+impl RunState {
24+ pub fn as_str(self) -> &'static str {
25+ match self {
26+ RunState::WaitingApproval => "waiting_approval",
27+ RunState::Queued => "queued",
28+ RunState::Running => "running",
29+ RunState::Success => "success",
30+ RunState::Failure => "failure",
31+ RunState::Cancelled => "cancelled",
32+ RunState::Error => "error",
33+ }
34+ }
35+
36+ /// True for states a run cannot leave.
37+ pub fn is_terminal(self) -> bool {
38+ matches!(
39+ self,
40+ RunState::Success | RunState::Failure | RunState::Cancelled | RunState::Error
41+ )
42+ }
43+}
44+
45+impl FromStr for RunState {
46+ type Err = Error;
47+
48+ fn from_str(s: &str) -> Result<Self> {
49+ Ok(match s {
50+ "waiting_approval" => RunState::WaitingApproval,
51+ "queued" => RunState::Queued,
52+ "running" => RunState::Running,
53+ "success" => RunState::Success,
54+ "failure" => RunState::Failure,
55+ "cancelled" => RunState::Cancelled,
56+ "error" => RunState::Error,
57+ other => return Err(Error::Database(format!("unknown CI run state {other:?}"))),
58+ })
59+ }
60+}
61+
62+/// State of a job. Stored as the `as_str` name.
63+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64+pub enum JobState {
65+ Blocked,
66+ Queued,
67+ Running,
68+ Success,
69+ Failure,
70+ Cancelled,
71+ Error,
72+ Skipped,
73+}
74+
75+impl JobState {
76+ pub fn as_str(self) -> &'static str {
77+ match self {
78+ JobState::Blocked => "blocked",
79+ JobState::Queued => "queued",
80+ JobState::Running => "running",
81+ JobState::Success => "success",
82+ JobState::Failure => "failure",
83+ JobState::Cancelled => "cancelled",
84+ JobState::Error => "error",
85+ JobState::Skipped => "skipped",
86+ }
87+ }
88+
89+ /// True for states a job cannot leave.
90+ pub fn is_terminal(self) -> bool {
91+ matches!(
92+ self,
93+ JobState::Success
94+ | JobState::Failure
95+ | JobState::Cancelled
96+ | JobState::Error
97+ | JobState::Skipped
98+ )
99+ }
100+}
101+
102+impl FromStr for JobState {
103+ type Err = Error;
104+
105+ fn from_str(s: &str) -> Result<Self> {
106+ Ok(match s {
107+ "blocked" => JobState::Blocked,
108+ "queued" => JobState::Queued,
109+ "running" => JobState::Running,
110+ "success" => JobState::Success,
111+ "failure" => JobState::Failure,
112+ "cancelled" => JobState::Cancelled,
113+ "error" => JobState::Error,
114+ "skipped" => JobState::Skipped,
115+ other => return Err(Error::Database(format!("unknown CI job state {other:?}"))),
116+ })
117+ }
118+}
119+
120+/// A registered runner. The secret and its hash are never part of this type.
121+#[derive(Debug, Clone, PartialEq, Eq)]
122+pub struct CiRunner {
123+ pub id: String,
124+ pub name: String,
125+ pub labels: Vec<String>,
126+ pub created_at: String,
127+ pub last_seen_at: Option<String>,
128+ pub disabled: bool,
129+}
130+
131+/// One run of the workflow for a commit.
132+#[derive(Debug, Clone, PartialEq, Eq)]
133+pub struct CiRun {
134+ pub id: String,
135+ pub repo_id: String,
136+ pub number: i64,
137+ /// `push`, `tag`, `pull_request` or `manual`.
138+ pub trigger: String,
139+ pub git_ref: String,
140+ pub commit_oid: String,
141+ pub pull_number: Option<i64>,
142+ pub actor_id: String,
143+ pub state: RunState,
144+ pub config_error: Option<String>,
145+ pub created_at: String,
146+ pub finished_at: Option<String>,
147+}
148+
149+/// One job of a run. `spec_json` is the JobSpec as JSON text.
150+#[derive(Debug, Clone, PartialEq, Eq)]
151+pub struct CiJob {
152+ pub id: String,
153+ pub run_id: String,
154+ pub name: String,
155+ pub spec_json: String,
156+ pub state: JobState,
157+ pub runner_id: Option<String>,
158+ pub exit_code: Option<i64>,
159+ pub message: Option<String>,
160+ pub queued_at: Option<String>,
161+ pub started_at: Option<String>,
162+ pub finished_at: Option<String>,
163+ pub heartbeat_at: Option<String>,
164+ pub cancel_requested: bool,
165+ pub log_bytes: i64,
166+}
167+
168+/// An artifact uploaded by a job. The file itself is stored outside the database.
169+#[derive(Debug, Clone, PartialEq, Eq)]
170+pub struct CiArtifact {
171+ pub id: String,
172+ pub job_id: String,
173+ pub name: String,
174+ pub size: i64,
175+ pub sha256: String,
176+ pub created_at: String,
177+}
178+
179+/// A job to create with a run. `needs` must match the `needs` in `spec_json`.
180+#[derive(Debug, Clone, Copy)]
181+pub struct NewJob<'a> {
182+ pub name: &'a str,
183+ pub spec_json: &'a str,
184+ pub needs: &'a [String],
185+}
crates/forge-core/src/config.rsadded+570 -0
@@ -0,0 +1,570 @@
1+//! Instance configuration, read from TOML.
2+
3+use std::net::{IpAddr, SocketAddr};
4+use std::path::{Path, PathBuf};
5+
6+use ipnet::IpNet;
7+use serde::{Deserialize, Deserializer};
8+
9+use crate::error::{Error, Result};
10+
11+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
12+#[serde(rename_all = "lowercase")]
13+pub enum Registration {
14+ Closed,
15+ Invite,
16+ Open,
17+}
18+
19+/// Parses each entry as a CIDR network or as a single address (a /32 or /128).
20+fn deserialize_trusted_proxies<'de, D: Deserializer<'de>>(
21+ deserializer: D,
22+) -> std::result::Result<Vec<IpNet>, D::Error> {
23+ let entries = Vec::<String>::deserialize(deserializer)?;
24+ entries
25+ .iter()
26+ .map(|entry| {
27+ if let Ok(net) = entry.parse::<IpNet>() {
28+ return Ok(net);
29+ }
30+ entry
31+ .parse::<IpAddr>()
32+ .map(IpNet::from)
33+ .map_err(|_| serde::de::Error::custom(format!("invalid trusted proxy `{entry}`")))
34+ })
35+ .collect()
36+}
37+
38+fn default_user_content_listen() -> SocketAddr {
39+ "127.0.0.1:7661"
40+ .parse()
41+ .expect("valid default socket address")
42+}
43+
44+/// Outbound webhook delivery policy. Internal destinations are refused unless listed in `allowed_hosts`.
45+#[derive(Debug, Clone, Default, Deserialize)]
46+#[serde(deny_unknown_fields)]
47+pub struct WebhookConfig {
48+ /// Permit `http://` hook URLs in addition to `https://`.
49+ #[serde(default)]
50+ pub allow_http: bool,
51+ /// Exact hostnames or CIDR networks that may be reached even when they resolve to internal addresses.
52+ #[serde(default)]
53+ pub allowed_hosts: Vec<String>,
54+}
55+
56+#[derive(Debug, Clone, Deserialize)]
57+#[serde(deny_unknown_fields)]
58+pub struct Config {
59+ pub database: PathBuf,
60+ pub data_dir: PathBuf,
61+ pub listen: SocketAddr,
62+ pub base_url: String,
63+ pub user_content_base_url: String,
64+ /// Listener that serves raw user files on the separate user-content origin.
65+ #[serde(default = "default_user_content_listen")]
66+ pub user_content_listen: SocketAddr,
67+ pub ssh_listen: SocketAddr,
68+ pub ssh_public_host: String,
69+ pub ssh_public_port: u16,
70+ pub registration: Registration,
71+ /// Peers whose `X-Forwarded-For` header is trusted, such as the reverse proxy in front of forge.
72+ /// Entries are addresses or CIDR networks. Empty by default, so the TCP peer is the client.
73+ #[serde(default, deserialize_with = "deserialize_trusted_proxies")]
74+ pub trusted_proxies: Vec<IpNet>,
75+ /// Outbound webhook policy, from the `[webhooks]` section.
76+ #[serde(default)]
77+ pub webhooks: WebhookConfig,
78+ /// Scheduled backups and fsck, from the `[backup]` section. An empty `dir` disables both.
79+ #[serde(default)]
80+ pub backup: BackupConfig,
81+ /// Release asset limits, from the `[releases]` section.
82+ #[serde(default)]
83+ pub releases: ReleasesConfig,
84+ /// Standby replication, from the `[replication]` section. An empty `target` disables it.
85+ #[serde(default)]
86+ pub replication: ReplicationConfig,
87+ /// Git LFS limits, from the `[lfs]` section.
88+ #[serde(default)]
89+ pub lfs: LfsConfig,
90+ /// CI limits and retention, from the `[ci]` section.
91+ #[serde(default)]
92+ pub ci: CiConfig,
93+ /// Security advisories (opt-in), from the `[advisories]` section.
94+ #[serde(default)]
95+ pub advisories: AdvisoriesConfig,
96+ /// Outgoing mail transport, from the `[mail]` section. The SMTP account is set in the database.
97+ #[serde(default)]
98+ pub mail: MailConfig,
99+ /// Organization rules, from the `[orgs]` section.
100+ #[serde(default)]
101+ pub orgs: OrgsConfig,
102+}
103+
104+/// Organization rules, from the `[orgs]` section.
105+#[derive(Debug, Clone, Default, Deserialize)]
106+#[serde(deny_unknown_fields, default)]
107+pub struct OrgsConfig {
108+ /// Who may create an organization: `all` (any signed-in person, the default) or `admin`
109+ /// (instance admins only).
110+ pub creation: OrgCreation,
111+}
112+
113+/// Who may create organizations. See `OrgsConfig::creation`.
114+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
115+#[serde(rename_all = "lowercase")]
116+pub enum OrgCreation {
117+ #[default]
118+ All,
119+ Admin,
120+}
121+
122+/// Outgoing mail transport, from the `[mail]` section. The SMTP server, sender and password are
123+/// set on `/admin/mail` and stored in the database; this section only picks the transport.
124+#[derive(Debug, Clone, Default, Deserialize)]
125+#[serde(deny_unknown_fields, default)]
126+pub struct MailConfig {
127+ pub transport: MailTransport,
128+}
129+
130+/// `smtp` sends through the configured server. `log` writes each message to `<data_dir>/mail-log/`
131+/// instead, for development and tests.
132+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
133+#[serde(rename_all = "lowercase")]
134+pub enum MailTransport {
135+ #[default]
136+ Smtp,
137+ Log,
138+}
139+
140+/// CI settings, from the `[ci]` section.
141+#[derive(Debug, Clone, Deserialize)]
142+#[serde(deny_unknown_fields, default)]
143+pub struct CiConfig {
144+ /// Whether CI runs at all. When false, pushes and pull requests create no runs.
145+ pub enabled: bool,
146+ /// Largest job log, in MiB. Past it the log is truncated.
147+ pub max_log_mb: u64,
148+ /// Largest artifact, in MiB.
149+ pub max_artifact_mb: u64,
150+ /// Artifacts a single job may upload.
151+ pub max_artifacts_per_job: u32,
152+ /// Days a job log is kept.
153+ pub log_retention_days: u64,
154+ /// Days an artifact is kept.
155+ pub artifact_retention_days: u64,
156+ /// Hours a runner registration token stays valid.
157+ pub registration_token_hours: u32,
158+}
159+
160+impl Default for CiConfig {
161+ fn default() -> Self {
162+ CiConfig {
163+ enabled: true,
164+ max_log_mb: 16,
165+ max_artifact_mb: 512,
166+ max_artifacts_per_job: 50,
167+ log_retention_days: 90,
168+ artifact_retention_days: 30,
169+ registration_token_hours: 24,
170+ }
171+ }
172+}
173+
174+/// Security advisories, from the `[advisories]` section. Off by default: when on, the forge
175+/// downloads the OSV dumps for `ecosystems` and scans each repo's lockfiles.
176+#[derive(Debug, Clone, Deserialize)]
177+#[serde(deny_unknown_fields, default)]
178+pub struct AdvisoriesConfig {
179+ /// Whether the advisory download and the lockfile scans run at all.
180+ pub enabled: bool,
181+ /// OSV ecosystems to download and scan: any of `crates.io`, `npm`, `Go`.
182+ pub ecosystems: Vec<String>,
183+ /// Hours before a downloaded dump is fetched again.
184+ pub refresh_hours: u64,
185+}
186+
187+impl Default for AdvisoriesConfig {
188+ fn default() -> Self {
189+ AdvisoriesConfig {
190+ enabled: false,
191+ ecosystems: vec!["crates.io".into(), "npm".into(), "Go".into()],
192+ refresh_hours: 24,
193+ }
194+ }
195+}
196+
197+/// Git LFS limits, from the `[lfs]` section.
198+#[derive(Debug, Clone, Deserialize)]
199+#[serde(deny_unknown_fields, default)]
200+pub struct LfsConfig {
201+ /// Largest accepted LFS object, in MiB.
202+ pub max_object_mb: u64,
203+}
204+
205+impl Default for LfsConfig {
206+ fn default() -> Self {
207+ LfsConfig {
208+ max_object_mb: 2048,
209+ }
210+ }
211+}
212+
213+impl LfsConfig {
214+ /// The object size limit in bytes.
215+ pub fn max_object_bytes(&self) -> u64 {
216+ self.max_object_mb.saturating_mul(1024 * 1024)
217+ }
218+}
219+
220+/// Standby replication, from the `[replication]` section. See `docs/backup.md`.
221+#[derive(Debug, Clone, Deserialize)]
222+#[serde(deny_unknown_fields, default)]
223+pub struct ReplicationConfig {
224+ /// `[user@]host:/absolute/base` of the standby account. Empty disables replication.
225+ pub target: String,
226+ /// SSH port of the standby host.
227+ pub ssh_port: u16,
228+ /// Private key of the standby account (mode 0600). Required when `target` is set.
229+ pub identity_file: PathBuf,
230+ /// Pinned host keys of the standby. Required when `target` is set; StrictHostKeyChecking is always `yes`.
231+ pub known_hosts_file: PathBuf,
232+ /// Minutes between database snapshots shipped to the standby.
233+ pub db_snapshot_minutes: u64,
234+ /// Minutes between reconcile passes (refs checksums, deleted repos, healthcheck).
235+ pub reconcile_minutes: u64,
236+ /// Optional URL pinged after each reconcile pass. Empty disables it.
237+ pub healthcheck_url: String,
238+}
239+
240+impl Default for ReplicationConfig {
241+ fn default() -> Self {
242+ ReplicationConfig {
243+ target: String::new(),
244+ ssh_port: 22,
245+ identity_file: PathBuf::new(),
246+ known_hosts_file: PathBuf::new(),
247+ db_snapshot_minutes: 15,
248+ reconcile_minutes: 60,
249+ healthcheck_url: String::new(),
250+ }
251+ }
252+}
253+
254+impl ReplicationConfig {
255+ /// Whether the replication worker runs at all.
256+ pub fn enabled(&self) -> bool {
257+ !self.target.is_empty()
258+ }
259+
260+ fn validate(&self) -> Result<()> {
261+ if !self.enabled() {
262+ return Ok(());
263+ }
264+ if self.identity_file.as_os_str().is_empty() {
265+ return Err(Error::Config(
266+ "[replication] identity_file is required when target is set".into(),
267+ ));
268+ }
269+ if self.known_hosts_file.as_os_str().is_empty() {
270+ return Err(Error::Config(
271+ "[replication] known_hosts_file is required when target is set".into(),
272+ ));
273+ }
274+ if self.db_snapshot_minutes == 0 {
275+ return Err(Error::Config(
276+ "[replication] db_snapshot_minutes must be at least 1".into(),
277+ ));
278+ }
279+ if self.reconcile_minutes == 0 {
280+ return Err(Error::Config(
281+ "[replication] reconcile_minutes must be at least 1".into(),
282+ ));
283+ }
284+ Ok(())
285+ }
286+}
287+
288+/// Release settings, from the `[releases]` section.
289+#[derive(Debug, Clone, Deserialize)]
290+#[serde(deny_unknown_fields, default)]
291+pub struct ReleasesConfig {
292+ /// Largest accepted release asset, in mebibytes.
293+ pub max_asset_mb: u64,
294+}
295+
296+impl Default for ReleasesConfig {
297+ fn default() -> Self {
298+ ReleasesConfig { max_asset_mb: 512 }
299+ }
300+}
301+
302+/// Scheduled backups, from the `[backup]` section. See `docs/backup-interfaces.md`.
303+#[derive(Debug, Clone, Deserialize)]
304+#[serde(deny_unknown_fields, default)]
305+pub struct BackupConfig {
306+ /// Directory that receives the backup sets. Empty disables the scheduler.
307+ pub dir: PathBuf,
308+ /// Hours between full backup sets.
309+ pub interval_hours: u64,
310+ /// Newest complete sets kept in `dir`. Older complete sets are deleted after a good run.
311+ pub keep: usize,
312+ /// Optional healthchecks.io style URL. Pinged with `/start`, then the URL itself or `/fail`.
313+ pub healthcheck_url: String,
314+ /// Days between `git fsck` and integrity checks of every repo and the database.
315+ pub fsck_interval_days: u64,
316+}
317+
318+impl Default for BackupConfig {
319+ fn default() -> Self {
320+ BackupConfig {
321+ dir: PathBuf::new(),
322+ interval_hours: 24,
323+ keep: 7,
324+ healthcheck_url: String::new(),
325+ fsck_interval_days: 7,
326+ }
327+ }
328+}
329+
330+impl BackupConfig {
331+ /// Whether the scheduler runs at all.
332+ pub fn enabled(&self) -> bool {
333+ !self.dir.as_os_str().is_empty()
334+ }
335+
336+ fn validate(&self) -> Result<()> {
337+ if self.interval_hours == 0 {
338+ return Err(Error::Config(
339+ "[backup] interval_hours must be at least 1".into(),
340+ ));
341+ }
342+ if self.keep == 0 {
343+ return Err(Error::Config("[backup] keep must be at least 1".into()));
344+ }
345+ if self.fsck_interval_days == 0 {
346+ return Err(Error::Config(
347+ "[backup] fsck_interval_days must be at least 1".into(),
348+ ));
349+ }
350+ Ok(())
351+ }
352+}
353+
354+/// Development defaults, written when the config file is missing. Keep in sync with `config/forge.example.toml`.
355+const DEV_DEFAULTS: &str = r#"# forge configuration (development defaults). Production values belong in /etc/forge/forge.toml.
356+
357+# SQLite/Turso database file.
358+database = "./data/forge.db"
359+# Root for repos, instance key, gitconfig, hooks and sockets.
360+data_dir = "./data"
361+# HTTP listen address for the web UI and smart-HTTP.
362+listen = "127.0.0.1:7660"
363+# Public base URL used in links and clone URLs.
364+base_url = "http://127.0.0.1:7660"
365+# Separate origin for raw user content (Milestone 2).
366+user_content_base_url = "http://127.0.0.1:7661"
367+# Listener for raw user content, served on a separate origin.
368+user_content_listen = "127.0.0.1:7661"
369+# SSH listen address for git over SSH.
370+ssh_listen = "0.0.0.0:2222"
371+# Host and port shown in SSH clone URLs.
372+ssh_public_host = "127.0.0.1"
373+ssh_public_port = 2222
374+# Self-registration: "closed" | "invite" | "open".
375+registration = "closed"
376+# Proxies whose X-Forwarded-For is trusted for client IPs (login and git throttling). Addresses or CIDRs.
377+trusted_proxies = []
378+"#;
379+
380+impl Config {
381+ /// Loads the config from `path`. Writes development defaults if the file is missing.
382+ pub fn load(path: &Path) -> Result<Config> {
383+ let text = match std::fs::read_to_string(path) {
384+ Ok(text) => text,
385+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
386+ if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
387+ std::fs::create_dir_all(parent)?;
388+ }
389+ std::fs::write(path, DEV_DEFAULTS)?;
390+ DEV_DEFAULTS.to_owned()
391+ }
392+ Err(e) => return Err(e.into()),
393+ };
394+ let config: Config =
395+ toml::from_str(&text).map_err(|e| Error::Config(format!("{}: {e}", path.display())))?;
396+ config.backup.validate()?;
397+ config.replication.validate()?;
398+ Ok(config)
399+ }
400+}
401+
402+#[cfg(test)]
403+mod tests {
404+ use super::*;
405+
406+ #[test]
407+ fn missing_file_writes_dev_defaults() {
408+ let dir = tempfile::tempdir().unwrap();
409+ let path = dir.path().join("nested").join("forge.toml");
410+ let cfg = Config::load(&path).unwrap();
411+ assert!(path.exists());
412+ assert_eq!(cfg.database, PathBuf::from("./data/forge.db"));
413+ assert_eq!(cfg.listen, "127.0.0.1:7660".parse().unwrap());
414+ assert_eq!(cfg.user_content_listen, "127.0.0.1:7661".parse().unwrap());
415+ assert_eq!(cfg.ssh_listen, "0.0.0.0:2222".parse().unwrap());
416+ assert_eq!(cfg.ssh_public_port, 2222);
417+ assert_eq!(cfg.registration, Registration::Closed);
418+ // Reloading the written file gives the same config.
419+ let again = Config::load(&path).unwrap();
420+ assert_eq!(again.base_url, cfg.base_url);
421+ }
422+
423+ #[test]
424+ fn missing_user_content_listen_uses_default() {
425+ let dir = tempfile::tempdir().unwrap();
426+ let path = dir.path().join("forge.toml");
427+ let text: String = DEV_DEFAULTS
428+ .lines()
429+ .filter(|line| !line.starts_with("user_content_listen"))
430+ .collect::<Vec<_>>()
431+ .join("\n");
432+ std::fs::write(&path, text).unwrap();
433+ let cfg = Config::load(&path).unwrap();
434+ assert_eq!(cfg.user_content_listen, "127.0.0.1:7661".parse().unwrap());
435+ }
436+
437+ #[test]
438+ fn user_content_listen_is_parsed() {
439+ let dir = tempfile::tempdir().unwrap();
440+ let path = dir.path().join("forge.toml");
441+ let text = DEV_DEFAULTS.replace("127.0.0.1:7661\"\n# SSH", "0.0.0.0:9000\"\n# SSH");
442+ std::fs::write(&path, text).unwrap();
443+ let cfg = Config::load(&path).unwrap();
444+ assert_eq!(cfg.user_content_listen, "0.0.0.0:9000".parse().unwrap());
445+ }
446+
447+ #[test]
448+ fn unknown_keys_are_rejected() {
449+ let dir = tempfile::tempdir().unwrap();
450+ let path = dir.path().join("forge.toml");
451+ let text = DEV_DEFAULTS.replace("registration", "bogus = 1\nregistration");
452+ std::fs::write(&path, text).unwrap();
453+ assert!(matches!(Config::load(&path), Err(Error::Config(_))));
454+ }
455+
456+ #[test]
457+ fn registration_is_lowercase() {
458+ let dir = tempfile::tempdir().unwrap();
459+ let path = dir.path().join("forge.toml");
460+ std::fs::write(&path, DEV_DEFAULTS.replace("\"closed\"", "\"invite\"")).unwrap();
461+ assert_eq!(
462+ Config::load(&path).unwrap().registration,
463+ Registration::Invite
464+ );
465+ std::fs::write(&path, DEV_DEFAULTS.replace("\"closed\"", "\"Invite\"")).unwrap();
466+ assert!(Config::load(&path).is_err());
467+ }
468+
469+ #[test]
470+ fn trusted_proxies_accept_addresses_and_networks() {
471+ let dir = tempfile::tempdir().unwrap();
472+ let path = dir.path().join("forge.toml");
473+ let text = DEV_DEFAULTS.replace(
474+ "trusted_proxies = []",
475+ "trusted_proxies = [\"127.0.0.1\", \"10.0.0.0/8\", \"2001:db8::/32\"]",
476+ );
477+ std::fs::write(&path, text).unwrap();
478+ let cfg = Config::load(&path).unwrap();
479+ assert_eq!(cfg.trusted_proxies.len(), 3);
480+ assert_eq!(
481+ cfg.trusted_proxies[0],
482+ "127.0.0.1/32".parse::<IpNet>().unwrap()
483+ );
484+ assert_eq!(
485+ cfg.trusted_proxies[1],
486+ "10.0.0.0/8".parse::<IpNet>().unwrap()
487+ );
488+ }
489+
490+ #[test]
491+ fn invalid_trusted_proxy_is_rejected() {
492+ let dir = tempfile::tempdir().unwrap();
493+ let path = dir.path().join("forge.toml");
494+ let text =
495+ DEV_DEFAULTS.replace("trusted_proxies = []", "trusted_proxies = [\"not-an-ip\"]");
496+ std::fs::write(&path, text).unwrap();
497+ assert!(matches!(Config::load(&path), Err(Error::Config(_))));
498+ }
499+
500+ #[test]
501+ fn backup_section_defaults_to_disabled() {
502+ let dir = tempfile::tempdir().unwrap();
503+ let path = dir.path().join("forge.toml");
504+ std::fs::write(&path, DEV_DEFAULTS).unwrap();
505+ let cfg = Config::load(&path).unwrap();
506+ assert!(!cfg.backup.enabled());
507+ assert_eq!(cfg.backup.interval_hours, 24);
508+ assert_eq!(cfg.backup.keep, 7);
509+ assert_eq!(cfg.backup.fsck_interval_days, 7);
510+ assert!(cfg.backup.healthcheck_url.is_empty());
511+ }
512+
513+ #[test]
514+ fn replication_section_defaults_to_disabled() {
515+ let dir = tempfile::tempdir().unwrap();
516+ let path = dir.path().join("forge.toml");
517+ std::fs::write(&path, DEV_DEFAULTS).unwrap();
518+ let cfg = Config::load(&path).unwrap();
519+ assert!(!cfg.replication.enabled());
520+ assert_eq!(cfg.replication.ssh_port, 22);
521+ assert_eq!(cfg.replication.db_snapshot_minutes, 15);
522+ assert_eq!(cfg.replication.reconcile_minutes, 60);
523+ assert!(cfg.replication.healthcheck_url.is_empty());
524+ }
525+
526+ #[test]
527+ fn replication_target_requires_keys_and_positive_intervals() {
528+ let dir = tempfile::tempdir().unwrap();
529+ let path = dir.path().join("forge.toml");
530+ let base = format!("{DEV_DEFAULTS}\n[replication]\ntarget = \"sb@host:/srv/standby\"\n");
531+ std::fs::write(&path, &base).unwrap();
532+ assert!(matches!(Config::load(&path), Err(Error::Config(_))));
533+ std::fs::write(
534+ &path,
535+ format!("{base}identity_file = \"/k\"\nknown_hosts_file = \"/kh\"\n"),
536+ )
537+ .unwrap();
538+ let cfg = Config::load(&path).unwrap();
539+ assert!(cfg.replication.enabled());
540+ assert_eq!(cfg.replication.identity_file, PathBuf::from("/k"));
541+ std::fs::write(
542+ &path,
543+ format!(
544+ "{base}identity_file = \"/k\"\nknown_hosts_file = \"/kh\"\nreconcile_minutes = 0\n"
545+ ),
546+ )
547+ .unwrap();
548+ assert!(matches!(Config::load(&path), Err(Error::Config(_))));
549+ }
550+
551+ #[test]
552+ fn backup_section_is_parsed_and_validated() {
553+ let dir = tempfile::tempdir().unwrap();
554+ let path = dir.path().join("forge.toml");
555+ let text = format!(
556+ "{DEV_DEFAULTS}\n[backup]\ndir = \"/srv/backups\"\nkeep = 3\ninterval_hours = 6\n"
557+ );
558+ std::fs::write(&path, &text).unwrap();
559+ let cfg = Config::load(&path).unwrap();
560+ assert!(cfg.backup.enabled());
561+ assert_eq!(cfg.backup.dir, PathBuf::from("/srv/backups"));
562+ assert_eq!(cfg.backup.keep, 3);
563+ assert_eq!(cfg.backup.interval_hours, 6);
564+
565+ std::fs::write(&path, format!("{DEV_DEFAULTS}\n[backup]\nkeep = 0\n")).unwrap();
566+ assert!(matches!(Config::load(&path), Err(Error::Config(_))));
567+ std::fs::write(&path, format!("{DEV_DEFAULTS}\n[backup]\nbogus = 1\n")).unwrap();
568+ assert!(matches!(Config::load(&path), Err(Error::Config(_))));
569+ }
570+}
crates/forge-core/src/discussions.rsadded+158 -0
@@ -0,0 +1,158 @@
1+//! Discussion types: a per-repo forum next to issues. Storage lives in `store/discussions.rs`.
2+//! Titles and bodies are checked with the issue validators, since the limits are the same.
3+
4+use crate::error::{Error, Result};
5+
6+/// Categories a discussion can be filed under. The `as_str` names are stored.
7+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8+pub enum DiscussionCategory {
9+ General,
10+ Ideas,
11+ Qa,
12+ /// Only Maintain or higher may post here.
13+ Announcements,
14+}
15+
16+/// Every category, in the order the chooser shows them.
17+pub const DISCUSSION_CATEGORIES: &[DiscussionCategory] = &[
18+ DiscussionCategory::General,
19+ DiscussionCategory::Ideas,
20+ DiscussionCategory::Qa,
21+ DiscussionCategory::Announcements,
22+];
23+
24+impl DiscussionCategory {
25+ /// The stored and URL name.
26+ pub fn as_str(self) -> &'static str {
27+ match self {
28+ DiscussionCategory::General => "general",
29+ DiscussionCategory::Ideas => "ideas",
30+ DiscussionCategory::Qa => "qa",
31+ DiscussionCategory::Announcements => "announcements",
32+ }
33+ }
34+
35+ /// The label people read.
36+ pub fn label(self) -> &'static str {
37+ match self {
38+ DiscussionCategory::General => "General",
39+ DiscussionCategory::Ideas => "Ideas",
40+ DiscussionCategory::Qa => "Q&A",
41+ DiscussionCategory::Announcements => "Announcements",
42+ }
43+ }
44+
45+ /// The category with this stored name, if any.
46+ pub fn parse(name: &str) -> Option<DiscussionCategory> {
47+ DISCUSSION_CATEGORIES
48+ .iter()
49+ .copied()
50+ .find(|c| c.as_str() == name)
51+ }
52+}
53+
54+/// One discussion. `answer_comment_id` is set only in Q&A, and names the accepted reply.
55+#[derive(Debug, Clone, PartialEq, Eq)]
56+pub struct Discussion {
57+ pub id: String,
58+ pub repo_id: String,
59+ pub number: i64,
60+ pub category: DiscussionCategory,
61+ pub title: String,
62+ pub body: String,
63+ pub author_id: String,
64+ pub answer_comment_id: Option<String>,
65+ pub locked: bool,
66+ pub comment_count: i64,
67+ pub created_at: String,
68+ pub updated_at: String,
69+}
70+
71+/// A discussion with its author's username, for the list.
72+#[derive(Debug, Clone, PartialEq, Eq)]
73+pub struct DiscussionListItem {
74+ pub discussion: Discussion,
75+ pub author: String,
76+}
77+
78+/// Why a discussion is in a user's inbox. The `as_str` names are stored in `discussion_notifications`.
79+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80+pub enum DiscussionReason {
81+ /// The user was @mentioned in the discussion or one of its comments.
82+ Mention,
83+ /// Someone commented on the user's discussion.
84+ Reply,
85+}
86+
87+impl DiscussionReason {
88+ /// The stored name.
89+ pub fn as_str(self) -> &'static str {
90+ match self {
91+ DiscussionReason::Mention => "mention",
92+ DiscussionReason::Reply => "reply",
93+ }
94+ }
95+
96+ /// The reason with this stored name, if any.
97+ pub fn parse(name: &str) -> Option<DiscussionReason> {
98+ match name {
99+ "mention" => Some(DiscussionReason::Mention),
100+ "reply" => Some(DiscussionReason::Reply),
101+ _ => None,
102+ }
103+ }
104+}
105+
106+/// One inbox row for a discussion, joined with its repo and title for the notifications page.
107+#[derive(Debug, Clone, PartialEq, Eq)]
108+pub struct DiscussionNotification {
109+ pub discussion_id: String,
110+ pub repo_id: String,
111+ pub repo_owner: String,
112+ pub repo_name: String,
113+ pub number: i64,
114+ pub title: String,
115+ pub reason: DiscussionReason,
116+ /// Username of the last person who acted, if the account still exists.
117+ pub last_actor_name: Option<String>,
118+ pub unread: bool,
119+ pub updated_at: String,
120+}
121+
122+/// One comment. A top-level comment has no `parent_id`; a reply names a top-level comment of the
123+/// same discussion. A deleted comment has `deleted` set and an empty body.
124+#[derive(Debug, Clone, PartialEq, Eq)]
125+pub struct DiscussionComment {
126+ pub id: String,
127+ pub discussion_id: String,
128+ pub parent_id: Option<String>,
129+ pub author_id: String,
130+ pub body: String,
131+ pub created_at: String,
132+ pub updated_at: String,
133+ pub deleted: bool,
134+}
135+
136+/// Filters and paging for the discussion list. `category: None` lists every category.
137+#[derive(Debug, Clone, PartialEq, Eq)]
138+pub struct DiscussionFilter {
139+ pub category: Option<DiscussionCategory>,
140+ pub page: u32,
141+ pub per_page: u32,
142+}
143+
144+/// Validates a discussion title (trimmed, returned) and body.
145+pub fn validate_discussion(title: &str, body: &str) -> Result<String> {
146+ let title = crate::issues::validate_title(title)?;
147+ crate::issues::validate_body(body)?;
148+ Ok(title)
149+}
150+
151+/// Validates a comment body: not blank, and within the body limit.
152+pub fn validate_comment(body: &str) -> Result<()> {
153+ crate::issues::validate_body(body)?;
154+ if body.trim().is_empty() {
155+ return Err(Error::Invalid("comment is empty".into()));
156+ }
157+ Ok(())
158+}
crates/forge-core/src/error.rsadded+29 -0
@@ -0,0 +1,29 @@
1+//! The error type shared across forge crates.
2+
3+#[derive(Debug, thiserror::Error)]
4+pub enum Error {
5+ #[error("io error: {0}")]
6+ Io(#[from] std::io::Error),
7+ #[error("database error: {0}")]
8+ Database(String),
9+ #[error("migration error: {0}")]
10+ Migration(String),
11+ #[error("config error: {0}")]
12+ Config(String),
13+ #[error("crypto error: {0}")]
14+ Crypto(String),
15+ #[error("invalid name: {0}")]
16+ InvalidName(String),
17+ #[error("git error: {0}")]
18+ Git(String),
19+ #[error("not found")]
20+ NotFound,
21+ #[error("conflict: {0}")]
22+ Conflict(String),
23+ #[error("forbidden: {0}")]
24+ Forbidden(String),
25+ #[error("invalid value: {0}")]
26+ Invalid(String),
27+}
28+
29+pub type Result<T> = std::result::Result<T, Error>;
crates/forge-core/src/issue_refs.rsadded+219 -0
@@ -0,0 +1,219 @@
1+//! Parsing of issue references (`#12`) and closing keywords (`fixes #12`) in free text.
2+//! Hand-written scanner, no regex. Pure, no IO.
3+
4+use std::ops::Range;
5+
6+/// Keywords that close an issue when followed by a `#N` reference. Compared case-insensitively.
7+const CLOSING_KEYWORDS: [&str; 9] = [
8+ "close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved",
9+];
10+
11+/// Maximum number of digits in an issue number.
12+const MAX_DIGITS: usize = 9;
13+
14+/// Byte range (including the `#`) and value of every valid `#N` reference in `text`, in order.
15+/// Duplicates are kept. Used to link references in rendered markdown with the same rules.
16+pub fn issue_ref_spans(text: &str) -> Vec<(Range<usize>, i64)> {
17+ scan_refs(text)
18+}
19+
20+/// Every `#N` reference in `text`, deduplicated, in order of first appearance.
21+///
22+/// A reference is `#` followed by 1 to 9 digits. It is not recognised when the `#` is preceded by
23+/// an alphanumeric char, `&` or `/`, or when the digits are followed by an alphanumeric char.
24+/// `#0` is not an issue number and is ignored.
25+pub fn find_issue_refs(text: &str) -> Vec<i64> {
26+ let mut out: Vec<i64> = Vec::new();
27+ for (_, n) in scan_refs(text) {
28+ if !out.contains(&n) {
29+ out.push(n);
30+ }
31+ }
32+ out
33+}
34+
35+/// Issue numbers closed by closing keywords in `text`, deduplicated, in order.
36+///
37+/// The keyword (case-insensitive, whole word) must directly precede the reference, optionally
38+/// separated by a `:`, with at least one whitespace char between the keyword (or colon) and `#`.
39+/// Example: `"Fixes: #3, closes #4"` gives `[3, 4]`; `"unfixes #2"` gives nothing.
40+pub fn find_closing_refs(text: &str) -> Vec<i64> {
41+ let mut out: Vec<i64> = Vec::new();
42+ for (range, n) in scan_refs(text) {
43+ if !preceded_by_closing_keyword(&text[..range.start]) {
44+ continue;
45+ }
46+ if !out.contains(&n) {
47+ out.push(n);
48+ }
49+ }
50+ out
51+}
52+
53+/// Byte range of each valid `#N` reference and its value, in order of appearance.
54+fn scan_refs(text: &str) -> Vec<(Range<usize>, i64)> {
55+ let bytes = text.as_bytes();
56+ let mut refs = Vec::new();
57+ let mut i = 0;
58+ while i < bytes.len() {
59+ if bytes[i] != b'#' {
60+ i += 1;
61+ continue;
62+ }
63+ let hash = i;
64+ i += 1;
65+ let digits_start = i;
66+ while i < bytes.len() && bytes[i].is_ascii_digit() {
67+ i += 1;
68+ }
69+ let digits = &bytes[digits_start..i];
70+ if digits.is_empty() || digits.len() > MAX_DIGITS {
71+ continue;
72+ }
73+ if let Some(next) = text[i..].chars().next()
74+ && next.is_alphanumeric()
75+ {
76+ continue;
77+ }
78+ if let Some(prev) = text[..hash].chars().next_back()
79+ && (prev.is_alphanumeric() || prev == '&' || prev == '/')
80+ {
81+ continue;
82+ }
83+ let n = digits
84+ .iter()
85+ .fold(0i64, |acc, d| acc * 10 + i64::from(d - b'0'));
86+ if n == 0 {
87+ continue;
88+ }
89+ refs.push((hash..i, n));
90+ }
91+ refs
92+}
93+
94+/// True when `before` (the text preceding a `#N` reference) ends with a closing keyword,
95+/// an optional `:`, and at least one whitespace char.
96+fn preceded_by_closing_keyword(before: &str) -> bool {
97+ let trimmed = before.trim_end();
98+ if trimmed.len() == before.len() {
99+ // No whitespace between the keyword and the reference.
100+ return false;
101+ }
102+ let word_part = trimmed.strip_suffix(':').unwrap_or(trimmed);
103+ let lower = word_part.to_ascii_lowercase();
104+ CLOSING_KEYWORDS.iter().any(|kw| {
105+ lower.ends_with(kw)
106+ && word_part[..word_part.len() - kw.len()]
107+ .chars()
108+ .next_back()
109+ .is_none_or(|c| !c.is_alphanumeric())
110+ })
111+}
112+
113+#[cfg(test)]
114+mod tests {
115+ use super::*;
116+
117+ #[test]
118+ fn plain_reference() {
119+ assert_eq!(find_issue_refs("fixes #12"), vec![12]);
120+ }
121+
122+ #[test]
123+ fn multiple_references_in_order() {
124+ assert_eq!(find_issue_refs("Fixes: #3, closes #4"), vec![3, 4]);
125+ }
126+
127+ #[test]
128+ fn prefix_before_hash_is_not_a_reference() {
129+ assert!(find_issue_refs("prefix#5").is_empty());
130+ }
131+
132+ #[test]
133+ fn html_entity_is_not_a_reference() {
134+ assert!(find_issue_refs("&#39;").is_empty());
135+ }
136+
137+ #[test]
138+ fn slash_before_hash_is_not_a_reference() {
139+ assert!(find_issue_refs("a/#7").is_empty());
140+ }
141+
142+ #[test]
143+ fn leading_zeros_are_parsed() {
144+ assert_eq!(find_issue_refs("#0012"), vec![12]);
145+ }
146+
147+ #[test]
148+ fn ten_digits_is_not_a_reference() {
149+ assert!(find_issue_refs("#1234567890").is_empty());
150+ }
151+
152+ #[test]
153+ fn nine_digits_is_a_reference() {
154+ assert_eq!(find_issue_refs("#123456789"), vec![123_456_789]);
155+ }
156+
157+ #[test]
158+ fn zero_is_not_a_reference() {
159+ assert!(find_issue_refs("#0").is_empty());
160+ }
161+
162+ #[test]
163+ fn parentheses_are_allowed_around_reference() {
164+ assert_eq!(find_issue_refs("(#8)"), vec![8]);
165+ }
166+
167+ #[test]
168+ fn trailing_punctuation_is_allowed() {
169+ assert_eq!(find_issue_refs("#9."), vec![9]);
170+ }
171+
172+ #[test]
173+ fn alphanumeric_suffix_is_not_a_reference() {
174+ assert!(find_issue_refs("#12abc").is_empty());
175+ }
176+
177+ #[test]
178+ fn duplicates_are_removed() {
179+ assert_eq!(find_issue_refs("#2 and #1 and #2"), vec![2, 1]);
180+ assert_eq!(find_closing_refs("fixes #2, closes #2"), vec![2]);
181+ }
182+
183+ #[test]
184+ fn unfixes_is_not_closing() {
185+ assert!(find_closing_refs("unfixes #2").is_empty());
186+ assert_eq!(find_issue_refs("unfixes #2"), vec![2]);
187+ }
188+
189+ #[test]
190+ fn fixed_is_closing() {
191+ assert_eq!(find_closing_refs("fixed #2"), vec![2]);
192+ }
193+
194+ #[test]
195+ fn all_keywords_are_case_insensitive() {
196+ for kw in [
197+ "close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved",
198+ ] {
199+ let text = format!("{}: #5", kw.to_uppercase());
200+ assert_eq!(find_closing_refs(&text), vec![5], "{kw}");
201+ }
202+ }
203+
204+ #[test]
205+ fn keyword_needs_whitespace_before_hash() {
206+ assert!(find_closing_refs("fixes#2").is_empty());
207+ assert!(find_closing_refs("fixes:#2").is_empty());
208+ }
209+
210+ #[test]
211+ fn plain_reference_is_not_closing() {
212+ assert!(find_closing_refs("see #2").is_empty());
213+ }
214+
215+ #[test]
216+ fn keyword_must_be_a_whole_word() {
217+ assert!(find_closing_refs("prefixes #2").is_empty());
218+ }
219+}
crates/forge-core/src/issues.rsadded+369 -0
@@ -0,0 +1,369 @@
1+//! Issue, comment, label and milestone types, and their validation. Stored shapes are in
2+//! migrations/0004_issues.sql; the store functions live in store/issues.rs and store/labels.rs.
3+
4+use crate::error::{Error, Result};
5+use crate::model::Id;
6+
7+/// Longest accepted issue title, in characters.
8+pub const MAX_TITLE: usize = 256;
9+/// Largest accepted issue, comment or edit body, in bytes.
10+pub const MAX_BODY: usize = 65536;
11+/// Longest accepted label name, in characters.
12+pub const MAX_LABEL_NAME: usize = 50;
13+/// Most assignees one issue can have.
14+pub const MAX_ASSIGNEES: usize = 10;
15+
16+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17+pub enum IssueState {
18+ Open,
19+ Closed,
20+}
21+
22+impl IssueState {
23+ pub fn as_str(&self) -> &'static str {
24+ match self {
25+ IssueState::Open => "open",
26+ IssueState::Closed => "closed",
27+ }
28+ }
29+}
30+
31+impl std::str::FromStr for IssueState {
32+ type Err = Error;
33+
34+ fn from_str(s: &str) -> Result<Self> {
35+ match s {
36+ "open" => Ok(IssueState::Open),
37+ "closed" => Ok(IssueState::Closed),
38+ other => Err(Error::Invalid(format!("issue state {other:?}"))),
39+ }
40+ }
41+}
42+
43+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44+pub enum StateReason {
45+ Completed,
46+ NotPlanned,
47+}
48+
49+impl StateReason {
50+ pub fn as_str(&self) -> &'static str {
51+ match self {
52+ StateReason::Completed => "completed",
53+ StateReason::NotPlanned => "not_planned",
54+ }
55+ }
56+}
57+
58+impl std::str::FromStr for StateReason {
59+ type Err = Error;
60+
61+ fn from_str(s: &str) -> Result<Self> {
62+ match s {
63+ "completed" => Ok(StateReason::Completed),
64+ "not_planned" => Ok(StateReason::NotPlanned),
65+ other => Err(Error::Invalid(format!("state reason {other:?}"))),
66+ }
67+ }
68+}
69+
70+#[derive(Debug, Clone, PartialEq)]
71+pub struct Issue {
72+ pub id: Id,
73+ pub repo_id: Id,
74+ pub number: i64,
75+ pub title: String,
76+ pub body: String,
77+ pub state: IssueState,
78+ pub state_reason: Option<StateReason>,
79+ pub author_id: Id,
80+ pub milestone_id: Option<Id>,
81+ pub comment_count: i64,
82+ pub created_at: String,
83+ pub updated_at: String,
84+ pub closed_at: Option<String>,
85+ /// Locked conversations accept writes only from collaborators with Triage or higher.
86+ pub locked: bool,
87+ /// One of `LOCK_REASONS`, or `None` when no reason was given.
88+ pub lock_reason: Option<String>,
89+}
90+
91+/// Reasons accepted when locking a conversation.
92+pub const LOCK_REASONS: &[&str] = &["off_topic", "too_heated", "resolved", "spam"];
93+
94+#[derive(Debug, Clone, PartialEq)]
95+pub struct Comment {
96+ pub id: Id,
97+ pub issue_id: Id,
98+ pub author_id: Id,
99+ pub body: String,
100+ pub created_at: String,
101+ pub updated_at: String,
102+ pub edited: bool,
103+}
104+
105+#[derive(Debug, Clone, PartialEq)]
106+pub struct IssueEvent {
107+ pub id: Id,
108+ pub issue_id: Id,
109+ /// `None` for system events.
110+ pub actor_id: Option<Id>,
111+ pub kind: String,
112+ pub data: serde_json::Value,
113+ pub created_at: String,
114+}
115+
116+#[derive(Debug, Clone, PartialEq)]
117+pub struct Label {
118+ pub id: Id,
119+ pub repo_id: Id,
120+ pub name: String,
121+ /// Lowercase `#rrggbb`.
122+ pub color: String,
123+ pub description: String,
124+}
125+
126+#[derive(Debug, Clone, PartialEq)]
127+pub struct Milestone {
128+ pub id: Id,
129+ pub repo_id: Id,
130+ pub title: String,
131+ pub description: String,
132+ /// `YYYY-MM-DD` or `None`.
133+ pub due_on: Option<String>,
134+ pub state: IssueState,
135+ pub created_at: String,
136+ pub closed_at: Option<String>,
137+ /// Counts filled by list/get queries.
138+ pub open_issues: i64,
139+ pub closed_issues: i64,
140+}
141+
142+#[derive(Debug, Clone, PartialEq)]
143+pub struct IssueFilter {
144+ pub state: Option<IssueState>,
145+ /// Label name.
146+ pub label: Option<String>,
147+ /// Milestone title.
148+ pub milestone: Option<String>,
149+ /// Author username.
150+ pub author: Option<String>,
151+ /// Assignee username.
152+ pub assignee: Option<String>,
153+ /// Substring of the title, case-insensitive.
154+ pub query: Option<String>,
155+ pub sort: IssueSort,
156+ /// 1-based.
157+ pub page: u32,
158+ pub per_page: u32,
159+}
160+
161+impl Default for IssueFilter {
162+ fn default() -> Self {
163+ IssueFilter {
164+ state: Some(IssueState::Open),
165+ label: None,
166+ milestone: None,
167+ author: None,
168+ assignee: None,
169+ query: None,
170+ sort: IssueSort::Newest,
171+ page: 1,
172+ per_page: 25,
173+ }
174+ }
175+}
176+
177+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
178+pub enum IssueSort {
179+ #[default]
180+ Newest,
181+ Oldest,
182+ RecentlyUpdated,
183+}
184+
185+impl IssueSort {
186+ /// The value used in the `sort` query parameter.
187+ pub fn as_str(&self) -> &'static str {
188+ match self {
189+ IssueSort::Newest => "newest",
190+ IssueSort::Oldest => "oldest",
191+ IssueSort::RecentlyUpdated => "updated",
192+ }
193+ }
194+}
195+
196+impl std::str::FromStr for IssueSort {
197+ type Err = Error;
198+
199+ fn from_str(s: &str) -> Result<Self> {
200+ match s {
201+ "newest" => Ok(IssueSort::Newest),
202+ "oldest" => Ok(IssueSort::Oldest),
203+ "updated" => Ok(IssueSort::RecentlyUpdated),
204+ other => Err(Error::Invalid(format!("sort {other:?}"))),
205+ }
206+ }
207+}
208+
209+#[derive(Debug, Clone, PartialEq)]
210+pub struct IssueListItem {
211+ pub issue: Issue,
212+ /// Display username of the author.
213+ pub author: String,
214+ pub labels: Vec<Label>,
215+ /// Display usernames of the assignees.
216+ pub assignees: Vec<String>,
217+}
218+
219+/// Trimmed title: non-empty, at most `MAX_TITLE` characters, no control characters.
220+pub fn validate_title(t: &str) -> Result<String> {
221+ let title = t.trim();
222+ if title.is_empty() {
223+ return Err(Error::Invalid("title is empty".into()));
224+ }
225+ if title.chars().count() > MAX_TITLE {
226+ return Err(Error::Invalid(format!(
227+ "title is longer than {MAX_TITLE} characters"
228+ )));
229+ }
230+ if title.chars().any(char::is_control) {
231+ return Err(Error::Invalid("title contains control characters".into()));
232+ }
233+ Ok(title.to_string())
234+}
235+
236+/// Body: at most `MAX_BODY` bytes. An empty body is allowed.
237+pub fn validate_body(b: &str) -> Result<()> {
238+ if b.len() > MAX_BODY {
239+ return Err(Error::Invalid(format!(
240+ "body is longer than {MAX_BODY} bytes"
241+ )));
242+ }
243+ Ok(())
244+}
245+
246+/// Returns `(trimmed name, normalized color)`. The name is 1..=50 characters with no control
247+/// characters. The color is `#rgb` or `#rrggbb` in any case and comes back as lowercase `#rrggbb`.
248+pub fn validate_label(name: &str, color: &str) -> Result<(String, String)> {
249+ let name = name.trim();
250+ let len = name.chars().count();
251+ if len == 0 || len > MAX_LABEL_NAME {
252+ return Err(Error::Invalid(format!(
253+ "label name must be 1 to {MAX_LABEL_NAME} characters"
254+ )));
255+ }
256+ if name.chars().any(char::is_control) {
257+ return Err(Error::Invalid(
258+ "label name contains control characters".into(),
259+ ));
260+ }
261+ let color = normalize_color(color)
262+ .ok_or_else(|| Error::Invalid(format!("label color {color:?} is not #rgb or #rrggbb")))?;
263+ Ok((name.to_string(), color))
264+}
265+
266+fn normalize_color(color: &str) -> Option<String> {
267+ let hex = color.strip_prefix('#')?;
268+ if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
269+ return None;
270+ }
271+ let lower = hex.to_ascii_lowercase();
272+ match lower.len() {
273+ 3 => {
274+ let mut out = String::with_capacity(7);
275+ out.push('#');
276+ for c in lower.chars() {
277+ out.push(c);
278+ out.push(c);
279+ }
280+ Some(out)
281+ }
282+ 6 => Some(format!("#{lower}")),
283+ _ => None,
284+ }
285+}
286+
287+#[cfg(test)]
288+mod tests {
289+ use super::*;
290+
291+ #[test]
292+ fn title_is_trimmed_and_checked() {
293+ assert_eq!(validate_title(" Fix it ").unwrap(), "Fix it");
294+ assert!(validate_title(" ").is_err());
295+ assert!(validate_title("").is_err());
296+ assert!(validate_title("a\tb").is_err());
297+ assert!(validate_title("a\nb").is_err());
298+ assert!(validate_title(&"x".repeat(MAX_TITLE)).is_ok());
299+ assert!(validate_title(&"x".repeat(MAX_TITLE + 1)).is_err());
300+ // Counted in characters, not bytes.
301+ assert!(validate_title(&"é".repeat(MAX_TITLE)).is_ok());
302+ assert!(validate_title(&"é".repeat(MAX_TITLE + 1)).is_err());
303+ }
304+
305+ #[test]
306+ fn body_is_checked_in_bytes() {
307+ assert!(validate_body("").is_ok());
308+ assert!(validate_body(&"x".repeat(MAX_BODY)).is_ok());
309+ assert!(validate_body(&"x".repeat(MAX_BODY + 1)).is_err());
310+ // 2 bytes per char: 32768 chars is exactly MAX_BODY bytes.
311+ assert!(validate_body(&"é".repeat(MAX_BODY / 2)).is_ok());
312+ assert!(validate_body(&"é".repeat(MAX_BODY / 2 + 1)).is_err());
313+ }
314+
315+ #[test]
316+ fn label_name_and_color_are_normalized() {
317+ assert_eq!(
318+ validate_label(" bug ", "#FF0000").unwrap(),
319+ ("bug".to_string(), "#ff0000".to_string())
320+ );
321+ assert_eq!(
322+ validate_label("x", "#AbC").unwrap(),
323+ ("x".to_string(), "#aabbcc".to_string())
324+ );
325+ assert!(validate_label("", "#fff").is_err());
326+ assert!(validate_label(" ", "#fff").is_err());
327+ assert!(validate_label(&"a".repeat(MAX_LABEL_NAME), "#fff").is_ok());
328+ assert!(validate_label(&"a".repeat(MAX_LABEL_NAME + 1), "#fff").is_err());
329+ assert!(validate_label("a\u{7}", "#fff").is_err());
330+ }
331+
332+ #[test]
333+ fn label_color_must_be_hex() {
334+ for bad in [
335+ "", "fff", "#ff", "#ffff", "#fffffff", "#gggggg", "red", "# fff", "#ff00 0",
336+ ] {
337+ assert!(validate_label("x", bad).is_err(), "{bad:?} accepted");
338+ }
339+ }
340+
341+ #[test]
342+ fn enums_round_trip() {
343+ for s in [IssueState::Open, IssueState::Closed] {
344+ assert_eq!(s.as_str().parse::<IssueState>().ok(), Some(s));
345+ }
346+ assert!("Open".parse::<IssueState>().is_err());
347+ for r in [StateReason::Completed, StateReason::NotPlanned] {
348+ assert_eq!(r.as_str().parse::<StateReason>().ok(), Some(r));
349+ }
350+ assert!("duplicate".parse::<StateReason>().is_err());
351+ for s in [
352+ IssueSort::Newest,
353+ IssueSort::Oldest,
354+ IssueSort::RecentlyUpdated,
355+ ] {
356+ assert_eq!(s.as_str().parse::<IssueSort>().ok(), Some(s));
357+ }
358+ assert!("bogus".parse::<IssueSort>().is_err());
359+ }
360+
361+ #[test]
362+ fn default_filter_is_open_first_page() {
363+ let f = IssueFilter::default();
364+ assert_eq!(f.state, Some(IssueState::Open));
365+ assert_eq!(f.page, 1);
366+ assert_eq!(f.per_page, 25);
367+ assert_eq!(f.sort, IssueSort::Newest);
368+ }
369+}
crates/forge-core/src/lfs_token.rsadded+216 -0
@@ -0,0 +1,216 @@
1+//! Short-lived signed tokens for Git LFS transfer actions (see docs/wave-b-interfaces.md).
2+//!
3+//! Token format: `"<expires>.<op>.<base64url-nopad(hmac_sha256)>"`, where the MAC covers
4+//! `"lfs\0" + repo_id + "\0" + op + "\0" + expires`. `op` is `download`, `upload` or `any`.
5+
6+use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine as _};
7+use hmac::{Hmac, KeyInit, Mac};
8+use sha2::Sha256;
9+use subtle::ConstantTimeEq;
10+
11+type HmacSha256 = Hmac<Sha256>;
12+
13+const MAC_LEN: usize = 32;
14+
15+/// The operation a token grants. `Any` grants both download and upload.
16+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17+pub enum LfsOp {
18+ Download,
19+ Upload,
20+ Any,
21+}
22+
23+impl LfsOp {
24+ pub fn as_str(self) -> &'static str {
25+ match self {
26+ LfsOp::Download => "download",
27+ LfsOp::Upload => "upload",
28+ LfsOp::Any => "any",
29+ }
30+ }
31+
32+ fn parse(s: &str) -> Option<LfsOp> {
33+ match s {
34+ "download" => Some(LfsOp::Download),
35+ "upload" => Some(LfsOp::Upload),
36+ "any" => Some(LfsOp::Any),
37+ _ => None,
38+ }
39+ }
40+}
41+
42+fn mac_for(key: &[u8; 32], repo_id: &str, op: &str, expires_unix: i64) -> HmacSha256 {
43+ // HMAC accepts keys of any length, so construction with a 32-byte key cannot fail.
44+ let mut mac =
45+ <HmacSha256 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
46+ mac.update(b"lfs\0");
47+ mac.update(repo_id.as_bytes());
48+ mac.update(b"\0");
49+ mac.update(op.as_bytes());
50+ mac.update(b"\0");
51+ mac.update(expires_unix.to_string().as_bytes());
52+ mac
53+}
54+
55+/// Issues a token for `repo_id`, valid for `op` until `expires_unix` (seconds since the epoch).
56+pub fn issue(key: &[u8; 32], repo_id: &str, op: LfsOp, expires_unix: i64) -> String {
57+ let tag = mac_for(key, repo_id, op.as_str(), expires_unix)
58+ .finalize()
59+ .into_bytes();
60+ format!(
61+ "{expires_unix}.{}.{}",
62+ op.as_str(),
63+ BASE64_URL_SAFE_NO_PAD.encode(tag)
64+ )
65+}
66+
67+/// Verifies `token` for `repo_id` and the wanted operation at time `now_unix`.
68+///
69+/// Rejects malformed input, expired tokens (`now >= expires`) and bad MACs. The MAC
70+/// comparison is constant time. An `Any` token satisfies `Download` and `Upload`; a
71+/// `Download` token never satisfies `Upload`, and only an `Any` token satisfies `Any`.
72+pub fn verify(key: &[u8; 32], token: &str, repo_id: &str, wanted: LfsOp, now_unix: i64) -> bool {
73+ let mut parts = token.splitn(3, '.');
74+ let (Some(expires_str), Some(op_str), Some(mac_str)) =
75+ (parts.next(), parts.next(), parts.next())
76+ else {
77+ return false;
78+ };
79+ // Strict canonical decimal: no sign, no leading zeros, no whitespace.
80+ let Ok(expires) = expires_str.parse::<i64>() else {
81+ return false;
82+ };
83+ if expires.to_string() != expires_str {
84+ return false;
85+ }
86+ if now_unix >= expires {
87+ return false;
88+ }
89+ let Some(token_op) = LfsOp::parse(op_str) else {
90+ return false;
91+ };
92+ let satisfies = token_op == LfsOp::Any || token_op == wanted;
93+ if !satisfies {
94+ return false;
95+ }
96+ let Ok(tag) = BASE64_URL_SAFE_NO_PAD.decode(mac_str) else {
97+ return false;
98+ };
99+ if tag.len() != MAC_LEN {
100+ return false;
101+ }
102+ let expected = mac_for(key, repo_id, op_str, expires)
103+ .finalize()
104+ .into_bytes();
105+ bool::from(expected.as_slice().ct_eq(&tag))
106+}
107+
108+#[cfg(test)]
109+mod tests {
110+ use super::*;
111+
112+ const KEY: [u8; 32] = [7; 32];
113+ const OTHER_KEY: [u8; 32] = [9; 32];
114+ const NOW: i64 = 1_700_000_000;
115+
116+ #[test]
117+ fn round_trip_each_op() {
118+ let dl = issue(&KEY, "repo-1", LfsOp::Download, NOW + 60);
119+ assert!(verify(&KEY, &dl, "repo-1", LfsOp::Download, NOW));
120+ let ul = issue(&KEY, "repo-1", LfsOp::Upload, NOW + 60);
121+ assert!(verify(&KEY, &ul, "repo-1", LfsOp::Upload, NOW));
122+ let any = issue(&KEY, "repo-1", LfsOp::Any, NOW + 60);
123+ assert!(verify(&KEY, &any, "repo-1", LfsOp::Any, NOW));
124+ }
125+
126+ #[test]
127+ fn token_has_expected_shape() {
128+ let token = issue(&KEY, "repo-1", LfsOp::Upload, NOW + 60);
129+ let mut parts = token.splitn(3, '.');
130+ assert_eq!(parts.next().unwrap(), (NOW + 60).to_string());
131+ assert_eq!(parts.next().unwrap(), "upload");
132+ assert_eq!(
133+ BASE64_URL_SAFE_NO_PAD
134+ .decode(parts.next().unwrap())
135+ .unwrap()
136+ .len(),
137+ MAC_LEN
138+ );
139+ }
140+
141+ #[test]
142+ fn expired_is_rejected() {
143+ let token = issue(&KEY, "repo-1", LfsOp::Any, NOW);
144+ assert!(!verify(&KEY, &token, "repo-1", LfsOp::Download, NOW));
145+ let token = issue(&KEY, "repo-1", LfsOp::Any, NOW + 1);
146+ assert!(verify(&KEY, &token, "repo-1", LfsOp::Download, NOW));
147+ assert!(!verify(&KEY, &token, "repo-1", LfsOp::Download, NOW + 1));
148+ }
149+
150+ #[test]
151+ fn wrong_repo_is_rejected() {
152+ let token = issue(&KEY, "repo-1", LfsOp::Any, NOW + 60);
153+ assert!(!verify(&KEY, &token, "repo-2", LfsOp::Download, NOW));
154+ }
155+
156+ #[test]
157+ fn wrong_key_is_rejected() {
158+ let token = issue(&KEY, "repo-1", LfsOp::Any, NOW + 60);
159+ assert!(!verify(&OTHER_KEY, &token, "repo-1", LfsOp::Download, NOW));
160+ }
161+
162+ #[test]
163+ fn wrong_op_is_rejected() {
164+ let dl = issue(&KEY, "repo-1", LfsOp::Download, NOW + 60);
165+ assert!(!verify(&KEY, &dl, "repo-1", LfsOp::Upload, NOW));
166+ assert!(!verify(&KEY, &dl, "repo-1", LfsOp::Any, NOW));
167+ let ul = issue(&KEY, "repo-1", LfsOp::Upload, NOW + 60);
168+ assert!(!verify(&KEY, &ul, "repo-1", LfsOp::Download, NOW));
169+ }
170+
171+ #[test]
172+ fn any_satisfies_download_and_upload() {
173+ let any = issue(&KEY, "repo-1", LfsOp::Any, NOW + 60);
174+ assert!(verify(&KEY, &any, "repo-1", LfsOp::Download, NOW));
175+ assert!(verify(&KEY, &any, "repo-1", LfsOp::Upload, NOW));
176+ }
177+
178+ #[test]
179+ fn tampering_is_rejected() {
180+ let token = issue(&KEY, "repo-1", LfsOp::Download, NOW + 60);
181+ // Upgrading the op in the clear must fail the MAC check.
182+ let upgraded = token.replacen(".download.", ".any.", 1);
183+ assert!(!verify(&KEY, &upgraded, "repo-1", LfsOp::Upload, NOW));
184+ // Extending expiry must fail too.
185+ let (exp, rest) = token.split_once('.').unwrap();
186+ let extended = format!("{}.{rest}", exp.parse::<i64>().unwrap() + 1000);
187+ assert!(!verify(&KEY, &extended, "repo-1", LfsOp::Download, NOW));
188+ // Flipping a MAC character must fail.
189+ let mut bytes = token.into_bytes();
190+ let last = bytes.len() - 1;
191+ bytes[last] = if bytes[last] == b'A' { b'B' } else { b'A' };
192+ let flipped = String::from_utf8(bytes).unwrap();
193+ assert!(!verify(&KEY, &flipped, "repo-1", LfsOp::Download, NOW));
194+ }
195+
196+ #[test]
197+ fn malformed_is_rejected() {
198+ assert!(!verify(&KEY, "", "repo-1", LfsOp::Any, NOW));
199+ assert!(!verify(&KEY, "garbage", "repo-1", LfsOp::Any, NOW));
200+ assert!(!verify(&KEY, "1.any", "repo-1", LfsOp::Any, NOW));
201+ assert!(!verify(
202+ &KEY,
203+ "+1700000060.any.AAAA",
204+ "repo-1",
205+ LfsOp::Any,
206+ NOW
207+ ));
208+ assert!(!verify(
209+ &KEY,
210+ "1700000060.bogus.AAAA",
211+ "repo-1",
212+ LfsOp::Any,
213+ NOW
214+ ));
215+ }
216+}
crates/forge-core/src/lib.rsadded+46 -0
@@ -0,0 +1,46 @@
1+//! Domain types, storage, configuration and security primitives for forge.
2+
3+pub mod advisories;
4+pub mod audit;
5+pub mod authz;
6+pub mod avatar;
7+pub mod ci;
8+pub mod config;
9+pub mod discussions;
10+pub mod error;
11+pub mod issue_refs;
12+pub mod issues;
13+pub mod lfs_token;
14+pub mod mail;
15+pub mod mirrors;
16+pub mod model;
17+pub mod names;
18+pub mod notifications;
19+pub mod password;
20+pub mod protection;
21+pub mod pulls;
22+pub mod raw_token;
23+pub mod reactions;
24+pub mod releases;
25+pub mod reviews;
26+pub mod secret;
27+pub mod session;
28+pub mod store;
29+pub mod time;
30+pub mod token;
31+pub mod totp;
32+pub mod tz;
33+pub mod webhooks;
34+
35+pub use audit::*;
36+pub use authz::{Action, Denied, authorize};
37+pub use config::{Config, Registration};
38+pub use error::{Error, Result};
39+pub use issues::*;
40+pub use model::*;
41+pub use notifications::*;
42+pub use pulls::*;
43+pub use reactions::{Reaction, ReactionMap, ReactionSummary, ReactionTarget};
44+pub use reviews::*;
45+pub use store::Store;
46+pub use store::edit_history_read::EditVersion;
crates/forge-core/src/mail.rsadded+366 -0
@@ -0,0 +1,366 @@
1+//! Outgoing mail: the SMTP settings, the outbox states, the retry schedule and the send error.
2+//! The store functions live in store/mail.rs; the transports live in forge-web (mail/).
3+
4+use std::fmt;
5+use std::time::Duration;
6+
7+/// Tries per message: one first try and five retries. Once the last try fails, the message gives up.
8+pub const MAX_ATTEMPTS: u32 = 6;
9+
10+/// The wait after the first, second, ... failed try. Five waits serve five retries.
11+const BACKOFF: [Duration; 5] = [
12+ Duration::from_secs(60),
13+ Duration::from_secs(5 * 60),
14+ Duration::from_secs(30 * 60),
15+ Duration::from_secs(2 * 60 * 60),
16+ Duration::from_secs(12 * 60 * 60),
17+];
18+
19+/// The wait before the next try, given that `attempts` tries have failed. `None` means give up.
20+pub fn retry_delay(attempts: u32) -> Option<Duration> {
21+ if attempts == 0 || attempts >= MAX_ATTEMPTS {
22+ return None;
23+ }
24+ BACKOFF.get((attempts - 1) as usize).copied()
25+}
26+
27+/// How the SMTP connection is secured.
28+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29+pub enum Security {
30+ /// Plain connection upgraded with STARTTLS (port 587 usually).
31+ StartTls,
32+ /// TLS from the first byte (port 465 usually).
33+ Tls,
34+ /// No encryption. Only for a relay on a trusted network.
35+ Plain,
36+}
37+
38+impl Security {
39+ pub fn as_str(self) -> &'static str {
40+ match self {
41+ Security::StartTls => "starttls",
42+ Security::Tls => "tls",
43+ Security::Plain => "none",
44+ }
45+ }
46+
47+ pub fn parse(s: &str) -> Option<Security> {
48+ match s {
49+ "starttls" => Some(Security::StartTls),
50+ "tls" => Some(Security::Tls),
51+ "none" => Some(Security::Plain),
52+ _ => None,
53+ }
54+ }
55+}
56+
57+/// The stored SMTP settings. The password is never part of this value; `has_password` says whether
58+/// one is stored.
59+#[derive(Debug, Clone, PartialEq, Eq)]
60+pub struct MailSettings {
61+ pub host: String,
62+ pub port: u16,
63+ pub security: Security,
64+ pub username: String,
65+ pub from_addr: String,
66+ pub from_name: String,
67+ pub enabled: bool,
68+ pub has_password: bool,
69+ pub updated_at: Option<String>,
70+}
71+
72+/// A change to the SMTP settings. `password: None` keeps the stored password; `Some` replaces it.
73+pub struct MailSettingsUpdate<'a> {
74+ pub host: &'a str,
75+ pub port: u16,
76+ pub security: Security,
77+ pub username: &'a str,
78+ pub from_addr: &'a str,
79+ pub from_name: &'a str,
80+ pub enabled: bool,
81+ pub password: Option<&'a str>,
82+}
83+
84+impl fmt::Debug for MailSettingsUpdate<'_> {
85+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86+ f.debug_struct("MailSettingsUpdate")
87+ .field("host", &self.host)
88+ .field("port", &self.port)
89+ .field("security", &self.security)
90+ .field("username", &self.username)
91+ .field("from_addr", &self.from_addr)
92+ .field("from_name", &self.from_name)
93+ .field("enabled", &self.enabled)
94+ .field("password", &self.password.map(|_| "<redacted>"))
95+ .finish()
96+ }
97+}
98+
99+/// The decrypted SMTP password. Its Debug output is redacted.
100+#[derive(Clone, PartialEq, Eq)]
101+pub struct MailPassword(String);
102+
103+impl MailPassword {
104+ pub fn new(value: String) -> MailPassword {
105+ MailPassword(value)
106+ }
107+
108+ pub fn expose(&self) -> &str {
109+ &self.0
110+ }
111+}
112+
113+impl fmt::Debug for MailPassword {
114+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115+ f.write_str("MailPassword(<redacted>)")
116+ }
117+}
118+
119+/// One message to send. Its Debug output omits the body, which can carry one-time links.
120+#[derive(Clone, PartialEq, Eq)]
121+pub struct OutgoingMail {
122+ pub to: String,
123+ pub subject: String,
124+ pub body: String,
125+ /// `Message-ID` header, in `<...>` form. `None` for mail that is not part of a thread.
126+ pub message_id: Option<String>,
127+ /// `In-Reply-To` and `References` headers, in `<...>` form.
128+ pub in_reply_to: Option<String>,
129+}
130+
131+impl fmt::Debug for OutgoingMail {
132+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133+ f.debug_struct("OutgoingMail")
134+ .field("to", &self.to)
135+ .field("subject", &self.subject)
136+ .field("body", &"<redacted>")
137+ .finish()
138+ }
139+}
140+
141+/// One outbox row. The body is kept for the worker; the admin list never shows it.
142+#[derive(Clone, PartialEq, Eq)]
143+pub struct OutboxEntry {
144+ pub id: String,
145+ pub idem_key: String,
146+ pub to_addr: String,
147+ pub subject: String,
148+ pub body: String,
149+ pub message_id: Option<String>,
150+ pub in_reply_to: Option<String>,
151+ pub created_at: String,
152+ /// Tries made so far.
153+ pub attempts: u32,
154+ pub next_attempt_at: Option<String>,
155+ pub sent_at: Option<String>,
156+ pub last_error: Option<String>,
157+}
158+
159+impl fmt::Debug for OutboxEntry {
160+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161+ f.debug_struct("OutboxEntry")
162+ .field("id", &self.id)
163+ .field("to_addr", &self.to_addr)
164+ .field("subject", &self.subject)
165+ .field("body", &"<redacted>")
166+ .field("attempts", &self.attempts)
167+ .field("next_attempt_at", &self.next_attempt_at)
168+ .field("sent_at", &self.sent_at)
169+ .field("last_error", &self.last_error)
170+ .finish()
171+ }
172+}
173+
174+/// Where a message stands.
175+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176+pub enum OutboxStatus {
177+ Sent,
178+ Queued,
179+ GaveUp,
180+}
181+
182+impl OutboxStatus {
183+ pub fn label(self) -> &'static str {
184+ match self {
185+ OutboxStatus::Sent => "sent",
186+ OutboxStatus::Queued => "queued",
187+ OutboxStatus::GaveUp => "gave up",
188+ }
189+ }
190+}
191+
192+impl OutboxEntry {
193+ pub fn status(&self) -> OutboxStatus {
194+ if self.sent_at.is_some() {
195+ OutboxStatus::Sent
196+ } else if self.next_attempt_at.is_some() {
197+ OutboxStatus::Queued
198+ } else {
199+ OutboxStatus::GaveUp
200+ }
201+ }
202+}
203+
204+/// Why a send failed. A transient failure is retried on the schedule; a permanent one gives up now.
205+#[derive(Debug, Clone, PartialEq, Eq)]
206+pub enum MailError {
207+ Transient(String),
208+ Permanent(String),
209+}
210+
211+impl fmt::Display for MailError {
212+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213+ match self {
214+ MailError::Transient(m) | MailError::Permanent(m) => f.write_str(m),
215+ }
216+ }
217+}
218+
219+/// Checks the SMTP settings before they are stored. `enabled` needs a host and a sender.
220+pub fn validate_settings(update: &MailSettingsUpdate<'_>) -> crate::Result<()> {
221+ use crate::Error;
222+ let bad = |m: &str| Err(Error::Invalid(m.to_owned()));
223+ if update.port == 0 {
224+ return bad("the SMTP port must be between 1 and 65535");
225+ }
226+ if update.host.chars().count() > 253
227+ || update
228+ .host
229+ .chars()
230+ .any(|c| c.is_whitespace() || c.is_control() || c == '/')
231+ {
232+ return bad("the SMTP host is not a host name");
233+ }
234+ if update.username.chars().count() > 256 || update.username.chars().any(char::is_control) {
235+ return bad("the SMTP username is too long or has control characters");
236+ }
237+ if update.from_name.chars().count() > 100 || update.from_name.chars().any(char::is_control) {
238+ return bad("the sender name must be at most 100 characters with no control characters");
239+ }
240+ if !update.from_addr.is_empty() {
241+ crate::store::validate_email(update.from_addr)?;
242+ }
243+ if update.enabled {
244+ if update.host.is_empty() {
245+ return bad("set the SMTP host before turning mail on");
246+ }
247+ if update.from_addr.is_empty() {
248+ return bad("set the sender address before turning mail on");
249+ }
250+ }
251+ Ok(())
252+}
253+
254+#[cfg(test)]
255+mod tests {
256+ use super::*;
257+
258+ #[test]
259+ fn retry_schedule_is_five_waits_then_give_up() {
260+ let mins = |n: u32| retry_delay(n).map(|d| d.as_secs() / 60);
261+ assert_eq!(retry_delay(0), None, "no try has failed yet");
262+ assert_eq!(mins(1), Some(1));
263+ assert_eq!(mins(2), Some(5));
264+ assert_eq!(mins(3), Some(30));
265+ assert_eq!(mins(4), Some(120));
266+ assert_eq!(mins(5), Some(720));
267+ assert_eq!(mins(6), None, "the sixth failed try gives up");
268+ assert_eq!(MAX_ATTEMPTS, 6);
269+ }
270+
271+ #[test]
272+ fn security_round_trips_and_rejects_unknown_names() {
273+ for s in [Security::StartTls, Security::Tls, Security::Plain] {
274+ assert_eq!(Security::parse(s.as_str()), Some(s));
275+ }
276+ assert_eq!(Security::parse("ssl"), None);
277+ }
278+
279+ #[test]
280+ fn debug_output_never_shows_the_password_or_the_body() {
281+ let pw = MailPassword::new("hunter2-smtp".into());
282+ assert!(!format!("{pw:?}").contains("hunter2"));
283+ let update = MailSettingsUpdate {
284+ host: "smtp.example",
285+ port: 587,
286+ security: Security::StartTls,
287+ username: "u",
288+ from_addr: "forge@example.com",
289+ from_name: "",
290+ enabled: true,
291+ password: Some("hunter2-smtp"),
292+ };
293+ let shown = format!("{update:?}");
294+ assert!(!shown.contains("hunter2"));
295+ assert!(shown.contains("<redacted>"));
296+ let mail = OutgoingMail {
297+ to: "a@example.com".into(),
298+ subject: "Reset".into(),
299+ body: "https://forge.test/reset/SECRET-TOKEN".into(),
300+ message_id: None,
301+ in_reply_to: None,
302+ };
303+ assert!(!format!("{mail:?}").contains("SECRET-TOKEN"));
304+ }
305+
306+ #[test]
307+ fn validation_requires_host_and_sender_before_enabling() {
308+ let base = MailSettingsUpdate {
309+ host: "smtp.example",
310+ port: 587,
311+ security: Security::StartTls,
312+ username: "",
313+ from_addr: "forge@example.com",
314+ from_name: "forge",
315+ enabled: true,
316+ password: None,
317+ };
318+ assert!(validate_settings(&base).is_ok());
319+ let no_host = MailSettingsUpdate { host: "", ..base };
320+ assert!(validate_settings(&no_host).is_err());
321+ let off = MailSettingsUpdate {
322+ host: "",
323+ enabled: false,
324+ ..base
325+ };
326+ assert!(
327+ validate_settings(&off).is_ok(),
328+ "settings may be saved while off"
329+ );
330+ let no_port = MailSettingsUpdate { port: 0, ..base };
331+ assert!(validate_settings(&no_port).is_err());
332+ let bad_host = MailSettingsUpdate {
333+ host: "smtp.example/evil",
334+ ..base
335+ };
336+ assert!(validate_settings(&bad_host).is_err());
337+ let bad_from = MailSettingsUpdate {
338+ from_addr: "not-an-address",
339+ ..base
340+ };
341+ assert!(validate_settings(&bad_from).is_err());
342+ }
343+
344+ #[test]
345+ fn status_follows_sent_at_and_next_attempt() {
346+ let mut entry = OutboxEntry {
347+ id: "1".into(),
348+ idem_key: "k".into(),
349+ to_addr: "a@example.com".into(),
350+ subject: "s".into(),
351+ body: "b".into(),
352+ message_id: None,
353+ in_reply_to: None,
354+ created_at: "t".into(),
355+ attempts: 0,
356+ next_attempt_at: Some("t".into()),
357+ sent_at: None,
358+ last_error: None,
359+ };
360+ assert_eq!(entry.status(), OutboxStatus::Queued);
361+ entry.next_attempt_at = None;
362+ assert_eq!(entry.status(), OutboxStatus::GaveUp);
363+ entry.sent_at = Some("t".into());
364+ assert_eq!(entry.status(), OutboxStatus::Sent);
365+ }
366+}
crates/forge-core/src/mirrors.rsadded+86 -0
@@ -0,0 +1,86 @@
1+//! Push and pull mirrors: the direction enum and the mirror shape. The stored shape is in
2+//! migrations/0017_mirrors.sql; the store functions live in store/mirrors.rs.
3+//!
4+//! A mirror never carries its secret. `has_secret` says whether one is stored; the plaintext is
5+//! read back only through `Store::mirror_secret`, for the sync worker.
6+
7+use crate::model::Id;
8+
9+/// Which way refs flow. Stored as the `as_str` name.
10+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11+pub enum MirrorDirection {
12+ /// The repo's refs come from the remote URL.
13+ Pull,
14+ /// Our refs are pushed to the remote URL.
15+ Push,
16+}
17+
18+impl MirrorDirection {
19+ pub fn as_str(self) -> &'static str {
20+ match self {
21+ MirrorDirection::Pull => "pull",
22+ MirrorDirection::Push => "push",
23+ }
24+ }
25+}
26+
27+impl std::str::FromStr for MirrorDirection {
28+ type Err = crate::error::Error;
29+
30+ fn from_str(s: &str) -> Result<Self, Self::Err> {
31+ match s {
32+ "pull" => Ok(MirrorDirection::Pull),
33+ "push" => Ok(MirrorDirection::Push),
34+ other => Err(crate::error::Error::Database(format!(
35+ "unknown mirror direction {other:?}"
36+ ))),
37+ }
38+ }
39+}
40+
41+/// A repo mirror. Has no secret field; see `Store::mirror_secret`.
42+#[derive(Debug, Clone, PartialEq, Eq)]
43+pub struct Mirror {
44+ pub id: Id,
45+ pub repo_id: Id,
46+ pub direction: MirrorDirection,
47+ pub url: String,
48+ pub username: String,
49+ pub has_secret: bool,
50+ pub interval_minutes: u32,
51+ pub enabled: bool,
52+ pub next_sync_at: String,
53+ pub last_sync_at: Option<String>,
54+ pub last_ok: Option<bool>,
55+ pub last_error: Option<String>,
56+ pub created_at: String,
57+}
58+
59+/// Input for `Store::create_mirror`. `secret` is plaintext; the store seals it.
60+#[derive(Debug, Clone, Copy)]
61+pub struct NewMirror<'a> {
62+ pub repo_id: &'a str,
63+ pub direction: MirrorDirection,
64+ pub url: &'a str,
65+ pub username: &'a str,
66+ pub secret: Option<&'a [u8]>,
67+ pub interval_minutes: u32,
68+}
69+
70+/// What `Store::update_mirror` does with the stored secret.
71+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72+pub enum SecretUpdate<'a> {
73+ /// Leave the stored secret as it is.
74+ Keep,
75+ /// Remove the stored secret.
76+ Clear,
77+ /// Replace the stored secret with this plaintext, which the store seals.
78+ Set(&'a [u8]),
79+}
80+
81+/// Most push mirrors one repo may have.
82+pub const MAX_PUSH_MIRRORS: u32 = 10;
83+/// Shortest allowed sync interval, in minutes. `0` means manual only.
84+pub const MIN_INTERVAL_MINUTES: u32 = 10;
85+/// Default sync interval, in minutes.
86+pub const DEFAULT_INTERVAL_MINUTES: u32 = 480;
crates/forge-core/src/model.rsadded+255 -0
@@ -0,0 +1,255 @@
1+//! Domain types. Stored shapes are described in docs/m1-interfaces.md.
2+
3+pub type Id = String;
4+
5+#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
6+#[serde(rename_all = "lowercase")]
7+pub enum Visibility {
8+ Public,
9+ Private,
10+}
11+
12+#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
13+#[serde(rename_all = "lowercase")]
14+pub enum Role {
15+ Read,
16+ Triage,
17+ Write,
18+ Maintain,
19+ Admin,
20+}
21+
22+/// What a `users` row stands for. An org is a row with `Org`; it can never authenticate.
23+#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
24+#[serde(rename_all = "lowercase")]
25+pub enum AccountKind {
26+ User,
27+ Org,
28+}
29+
30+#[derive(Clone, PartialEq)]
31+pub struct User {
32+ pub id: Id,
33+ pub username: String,
34+ pub email: Option<String>,
35+ pub password_hash: String,
36+ pub is_admin: bool,
37+ pub created_at: String,
38+ /// Raw markdown, at most 2000 chars.
39+ pub about: String,
40+ /// IANA name, e.g. "Europe/Istanbul"; "UTC" by default.
41+ pub timezone: String,
42+ /// Hash of the current avatar, or `None` when the user has none. Filled by the user queries.
43+ pub avatar_hash: Option<String>,
44+ /// Colour theme: "auto", "light" or "dark". Follows the browser when "auto".
45+ pub theme: String,
46+ /// Language code, e.g. "en"; "auto" uses the instance default.
47+ pub language: String,
48+ /// `User` for a person, `Org` for an organization.
49+ pub kind: AccountKind,
50+ /// Whether notifications are also sent by email (needs a verified email and instance mail).
51+ pub email_notifications: bool,
52+}
53+
54+impl User {
55+ /// True for an organization row. Orgs never authenticate and never act as a person.
56+ pub fn is_org(&self) -> bool {
57+ self.kind == AccountKind::Org
58+ }
59+}
60+
61+#[derive(Debug, Clone, PartialEq)]
62+pub struct Repo {
63+ pub id: Id,
64+ pub owner_id: Id,
65+ pub owner_name: String,
66+ pub name: String,
67+ pub description: String,
68+ pub visibility: Visibility,
69+ pub default_branch: String,
70+ pub archived: bool,
71+ pub created_at: String,
72+ /// The repo this one was forked from. `None` for originals and for forks whose parent was
73+ /// deleted.
74+ pub fork_of: Option<Id>,
75+}
76+
77+#[derive(Debug, Clone, PartialEq)]
78+pub struct SshKey {
79+ pub id: Id,
80+ pub user_id: Id,
81+ pub title: String,
82+ pub algorithm: String,
83+ /// Raw SSH wire-format public key blob (the bytes base64-encoded in authorized_keys).
84+ pub key_blob: Vec<u8>,
85+ pub fingerprint: String,
86+ pub created_at: String,
87+ pub last_used_at: Option<String>,
88+}
89+
90+#[derive(Debug, Clone, PartialEq)]
91+pub struct AccessToken {
92+ pub id: Id,
93+ pub user_id: Id,
94+ pub name: String,
95+ pub scopes: Vec<String>,
96+ pub expires_at: Option<String>,
97+ pub created_at: String,
98+ pub last_used_at: Option<String>,
99+}
100+
101+#[derive(Clone, PartialEq)]
102+pub struct Session {
103+ pub id: Id,
104+ pub user_id: Id,
105+ pub csrf_token: String,
106+ pub created_at: String,
107+ pub expires_at: String,
108+ pub last_seen: String,
109+ pub user_agent: Option<String>,
110+}
111+
112+const REDACTED: &str = "<redacted>";
113+
114+impl std::fmt::Debug for User {
115+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116+ f.debug_struct("User")
117+ .field("id", &self.id)
118+ .field("username", &self.username)
119+ .field("email", &self.email)
120+ .field("password_hash", &REDACTED)
121+ .field("is_admin", &self.is_admin)
122+ .field("created_at", &self.created_at)
123+ .field("about", &self.about)
124+ .field("timezone", &self.timezone)
125+ .field("avatar_hash", &self.avatar_hash)
126+ .field("theme", &self.theme)
127+ .field("language", &self.language)
128+ .field("kind", &self.kind)
129+ .finish()
130+ }
131+}
132+
133+impl std::fmt::Debug for Session {
134+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135+ f.debug_struct("Session")
136+ .field("id", &self.id)
137+ .field("user_id", &self.user_id)
138+ .field("csrf_token", &REDACTED)
139+ .field("created_at", &self.created_at)
140+ .field("expires_at", &self.expires_at)
141+ .field("last_seen", &self.last_seen)
142+ .field("user_agent", &self.user_agent)
143+ .finish()
144+ }
145+}
146+
147+impl Visibility {
148+ pub fn as_str(&self) -> &'static str {
149+ match self {
150+ Visibility::Public => "public",
151+ Visibility::Private => "private",
152+ }
153+ }
154+}
155+
156+impl std::str::FromStr for Visibility {
157+ type Err = crate::error::Error;
158+
159+ fn from_str(s: &str) -> Result<Self, Self::Err> {
160+ match s {
161+ "public" => Ok(Visibility::Public),
162+ "private" => Ok(Visibility::Private),
163+ other => Err(crate::error::Error::Invalid(format!(
164+ "visibility {other:?}"
165+ ))),
166+ }
167+ }
168+}
169+
170+impl Role {
171+ pub fn as_str(&self) -> &'static str {
172+ match self {
173+ Role::Read => "read",
174+ Role::Triage => "triage",
175+ Role::Write => "write",
176+ Role::Maintain => "maintain",
177+ Role::Admin => "admin",
178+ }
179+ }
180+}
181+
182+impl std::str::FromStr for Role {
183+ type Err = crate::error::Error;
184+
185+ fn from_str(s: &str) -> Result<Self, Self::Err> {
186+ match s {
187+ "read" => Ok(Role::Read),
188+ "triage" => Ok(Role::Triage),
189+ "write" => Ok(Role::Write),
190+ "maintain" => Ok(Role::Maintain),
191+ "admin" => Ok(Role::Admin),
192+ other => Err(crate::error::Error::Invalid(format!("role {other:?}"))),
193+ }
194+ }
195+}
196+
197+#[cfg(test)]
198+mod tests {
199+ use super::*;
200+
201+ #[test]
202+ fn debug_output_redacts_secrets() {
203+ let user = User {
204+ id: "u1".into(),
205+ username: "alice".into(),
206+ email: None,
207+ password_hash: "$argon2id$SECRETHASH".into(),
208+ is_admin: true,
209+ created_at: "now".into(),
210+ about: String::new(),
211+ timezone: "UTC".into(),
212+ avatar_hash: None,
213+ theme: "auto".into(),
214+ language: "auto".into(),
215+ kind: AccountKind::User,
216+ email_notifications: true,
217+ };
218+ let session = Session {
219+ id: "s1".into(),
220+ user_id: "u1".into(),
221+ csrf_token: "CSRFSECRET".into(),
222+ created_at: "now".into(),
223+ expires_at: "later".into(),
224+ last_seen: "now".into(),
225+ user_agent: None,
226+ };
227+ let dbg = format!("{user:?} {session:?}");
228+ assert!(dbg.contains("\"<redacted>\""));
229+ assert!(!dbg.contains("SECRETHASH"));
230+ assert!(!dbg.contains("CSRFSECRET"));
231+ assert!(dbg.contains("alice"));
232+ }
233+
234+ #[test]
235+ fn visibility_round_trips() {
236+ for v in [Visibility::Public, Visibility::Private] {
237+ assert_eq!(v.as_str().parse::<Visibility>().ok(), Some(v));
238+ }
239+ assert!("Public".parse::<Visibility>().is_err());
240+ }
241+
242+ #[test]
243+ fn role_round_trips() {
244+ for r in [
245+ Role::Read,
246+ Role::Triage,
247+ Role::Write,
248+ Role::Maintain,
249+ Role::Admin,
250+ ] {
251+ assert_eq!(r.as_str().parse::<Role>().ok(), Some(r));
252+ }
253+ assert!("owner".parse::<Role>().is_err());
254+ }
255+}
crates/forge-core/src/names.rsadded+252 -0
@@ -0,0 +1,252 @@
1+//! Owner and repo name rules.
2+
3+use unicode_normalization::UnicodeNormalization;
4+
5+use crate::error::{Error, Result};
6+
7+/// Names that may not be used as owners or repos (they collide with routes).
8+pub const RESERVED: &[&str] = &[
9+ "api",
10+ "admin",
11+ "auth",
12+ "assets",
13+ "static",
14+ "healthz",
15+ "login",
16+ "logout",
17+ "register",
18+ "setup",
19+ "settings",
20+ "new",
21+ "explore",
22+ "user",
23+ "users",
24+ "repo",
25+ "repos",
26+ "org",
27+ "orgs",
28+ "raw",
29+ "git",
30+ "internal",
31+ "help",
32+ "about",
33+ "avatars",
34+ "search",
35+ "notifications",
36+ "dashboard",
37+ "issues",
38+ "pulls",
39+ "snippets",
40+ // The reserved account that authored content is reassigned to on deletion.
41+ "ghost",
42+ "favicon.ico",
43+ "robots.txt",
44+ "security.txt",
45+ ".well-known",
46+ "-",
47+ ".",
48+ "..",
49+];
50+
51+/// NFKC normalization plus case fold.
52+pub fn normalize_name(input: &str) -> String {
53+ input.nfkc().collect::<String>().to_lowercase()
54+}
55+
56+/// Returns the folded name, or `Error::InvalidName`.
57+/// Allowed characters `[a-z0-9._-]`; must not start with `-` or `.`; must not end in `.git` or `.atom`.
58+/// Repo names must also not end in `.wiki`, so that `<name>.wiki.git` names the wiki unambiguously.
59+pub fn validate_name(input: &str) -> Result<String> {
60+ let name = normalize_name(input);
61+ if !name
62+ .chars()
63+ .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-'))
64+ {
65+ return Err(invalid(
66+ input,
67+ "only a-z, 0-9, '.', '_' and '-' are allowed",
68+ ));
69+ }
70+ check_folded(input, &name)?;
71+ if name.ends_with(".wiki") {
72+ return Err(invalid(input, "must not end with .wiki"));
73+ }
74+ Ok(name)
75+}
76+
77+/// The repo name a wiki transport path names: `name.wiki` or `name.wiki.git` gives `name`. `None`
78+/// for any other name. Repo names never end in `.wiki`, so the mapping is unambiguous.
79+pub fn wiki_base(path_name: &str) -> Option<&str> {
80+ let base = path_name.strip_suffix(".git").unwrap_or(path_name);
81+ base.strip_suffix(".wiki").filter(|b| !b.is_empty())
82+}
83+
84+/// Validates an owner (user) name. Returns `(display, folded)`: the NFKC-normalized input with
85+/// its case kept, and the lowercase form used for uniqueness and lookup.
86+/// Display characters are `[A-Za-z0-9._-]`; the folded form must pass the same rules as
87+/// `validate_name`.
88+pub fn validate_username(input: &str) -> Result<(String, String)> {
89+ let display: String = input.nfkc().collect();
90+ if !display
91+ .chars()
92+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
93+ {
94+ return Err(invalid(
95+ input,
96+ "only A-Z, a-z, 0-9, '.', '_' and '-' are allowed",
97+ ));
98+ }
99+ let folded = display.to_ascii_lowercase();
100+ check_folded(input, &folded)?;
101+ Ok((display, folded))
102+}
103+
104+/// Message for a username change that alters more than capitalization.
105+pub const CAPITALIZATION_ONLY: &str = "Only the capitalization can be changed.";
106+
107+/// Validates `new_display` and checks that it only changes the casing of `current`.
108+/// Returns the display form to store.
109+pub fn capitalization_change(current: &str, new_display: &str) -> Result<String> {
110+ let (display, folded) = validate_username(new_display)?;
111+ if folded != normalize_name(current) {
112+ return Err(Error::Invalid(CAPITALIZATION_ONLY.into()));
113+ }
114+ Ok(display)
115+}
116+
117+fn invalid(input: &str, why: &str) -> Error {
118+ Error::InvalidName(format!("{input:?}: {why}"))
119+}
120+
121+/// Length, leading/trailing, `..`, and reserved-name rules, applied to a folded name.
122+fn check_folded(input: &str, name: &str) -> Result<()> {
123+ if !(1..=64).contains(&name.chars().count()) {
124+ return Err(invalid(input, "length must be 1 to 64 characters"));
125+ }
126+ if name.starts_with(['-', '.']) {
127+ return Err(invalid(input, "must not start with '-' or '.'"));
128+ }
129+ if name.ends_with(".git") || name.ends_with(".atom") {
130+ return Err(invalid(input, "must not end with .git or .atom"));
131+ }
132+ if name.contains("..") {
133+ return Err(invalid(input, "must not contain '..'"));
134+ }
135+ if RESERVED.contains(&name) {
136+ return Err(invalid(input, "name is reserved"));
137+ }
138+ Ok(())
139+}
140+
141+#[cfg(test)]
142+mod tests {
143+ use super::*;
144+
145+ #[test]
146+ fn normalizes_nfkc_then_lowercase() {
147+ assert_eq!(normalize_name("ABC"), "abc");
148+ assert_eq!(normalize_name("Repo.Name"), "repo.name");
149+ // Fullwidth 'fi' ligature folds to "fi" under NFKC.
150+ assert_eq!(normalize_name("file"), "file");
151+ }
152+
153+ #[test]
154+ fn accepts_valid_names_and_returns_folded_form() {
155+ assert_eq!(validate_name("Alice").unwrap(), "alice");
156+ assert_eq!(validate_name("my-repo_2.x").unwrap(), "my-repo_2.x");
157+ assert_eq!(validate_name("a").unwrap(), "a");
158+ assert_eq!(validate_name(&"a".repeat(64)).unwrap().len(), 64);
159+ assert_eq!(validate_name("x.gitx").unwrap(), "x.gitx");
160+ }
161+
162+ #[test]
163+ fn rejects_invalid_names() {
164+ let bad = [
165+ "".to_string(),
166+ "a".repeat(65),
167+ "has space".into(),
168+ "slash/name".into(),
169+ "-leading".into(),
170+ ".leading".into(),
171+ "trailing.git".into(),
172+ "TRAILING.GIT".into(),
173+ "x.atom".into(),
174+ "a..b".into(),
175+ "é".into(),
176+ "api".into(),
177+ "Settings".into(),
178+ "-".into(),
179+ ".".into(),
180+ "..".into(),
181+ ];
182+ for name in bad {
183+ assert!(
184+ matches!(validate_name(&name), Err(Error::InvalidName(_))),
185+ "expected rejection for {name:?}"
186+ );
187+ }
188+ }
189+
190+ #[test]
191+ fn username_keeps_display_case_and_folds_key() {
192+ assert_eq!(
193+ validate_username("Tahinli").unwrap(),
194+ ("Tahinli".to_string(), "tahinli".to_string())
195+ );
196+ assert_eq!(
197+ validate_username("TAHINLI").unwrap(),
198+ ("TAHINLI".to_string(), "tahinli".to_string())
199+ );
200+ }
201+
202+ #[test]
203+ fn username_rejects_reserved_and_invalid() {
204+ for bad in [
205+ "ADMIN",
206+ "Settings",
207+ "Ta hinli",
208+ "Ünal",
209+ "Tahinli.GIT",
210+ "-Tahinli",
211+ "a..B",
212+ "",
213+ ] {
214+ assert!(
215+ matches!(validate_username(bad), Err(Error::InvalidName(_))),
216+ "expected rejection for {bad:?}"
217+ );
218+ }
219+ }
220+
221+ #[test]
222+ fn reserved_list_contains_route_names() {
223+ for r in [
224+ "api", "admin", "register", "explore", "orgs", "about", "git",
225+ ] {
226+ assert!(RESERVED.contains(&r));
227+ }
228+ }
229+}
230+
231+#[cfg(test)]
232+mod wiki_name_tests {
233+ use super::*;
234+
235+ #[test]
236+ fn repo_names_may_not_end_in_wiki() {
237+ assert!(matches!(
238+ validate_name("proj.wiki"),
239+ Err(Error::InvalidName(_))
240+ ));
241+ assert!(validate_name("wikiproj").is_ok());
242+ }
243+
244+ #[test]
245+ fn wiki_transport_names_map_to_the_repo() {
246+ assert_eq!(wiki_base("proj.wiki.git"), Some("proj"));
247+ assert_eq!(wiki_base("proj.wiki"), Some("proj"));
248+ assert_eq!(wiki_base("proj.git"), None);
249+ assert_eq!(wiki_base("proj"), None);
250+ assert_eq!(wiki_base(".wiki"), None);
251+ }
252+}
crates/forge-core/src/notifications.rsadded+137 -0
@@ -0,0 +1,137 @@
1+//! Notification models: repo watch levels, issue subscriptions and inbox entries.
2+
3+use crate::model::Id;
4+
5+/// How much activity a user hears about in a repo. A user with no row is `Participating`.
6+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7+pub enum WatchLevel {
8+ /// All activity in the repo.
9+ Watching,
10+ /// Only the issues and pulls the user is subscribed to, and mentions.
11+ Participating,
12+ /// Nothing from the repo, even subscribed threads. Mentions still notify.
13+ Ignoring,
14+}
15+
16+impl WatchLevel {
17+ pub fn as_str(self) -> &'static str {
18+ match self {
19+ WatchLevel::Watching => "watching",
20+ WatchLevel::Participating => "participating",
21+ WatchLevel::Ignoring => "ignoring",
22+ }
23+ }
24+
25+ pub fn parse(s: &str) -> Option<WatchLevel> {
26+ match s {
27+ "watching" => Some(WatchLevel::Watching),
28+ "participating" => Some(WatchLevel::Participating),
29+ "ignoring" => Some(WatchLevel::Ignoring),
30+ _ => None,
31+ }
32+ }
33+}
34+
35+/// State of an issue subscription row.
36+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37+pub enum SubState {
38+ Subscribed,
39+ /// Explicit opt-out. Auto-subscribe never replaces it.
40+ Ignored,
41+}
42+
43+impl SubState {
44+ pub fn as_str(self) -> &'static str {
45+ match self {
46+ SubState::Subscribed => "subscribed",
47+ SubState::Ignored => "ignored",
48+ }
49+ }
50+
51+ pub fn parse(s: &str) -> Option<SubState> {
52+ match s {
53+ "subscribed" => Some(SubState::Subscribed),
54+ "ignored" => Some(SubState::Ignored),
55+ _ => None,
56+ }
57+ }
58+}
59+
60+/// Why a user is notified. The stronger reason wins while a thread is unread.
61+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62+pub enum Reason {
63+ Mention,
64+ Assign,
65+ ReviewRequested,
66+ Author,
67+ Comment,
68+ StateChange,
69+ Subscribed,
70+ Watching,
71+}
72+
73+impl Reason {
74+ pub fn as_str(self) -> &'static str {
75+ match self {
76+ Reason::Mention => "mention",
77+ Reason::Assign => "assign",
78+ Reason::ReviewRequested => "review_requested",
79+ Reason::Author => "author",
80+ Reason::Comment => "comment",
81+ Reason::StateChange => "state_change",
82+ Reason::Subscribed => "subscribed",
83+ Reason::Watching => "watching",
84+ }
85+ }
86+
87+ pub fn parse(s: &str) -> Option<Reason> {
88+ match s {
89+ "mention" => Some(Reason::Mention),
90+ "assign" => Some(Reason::Assign),
91+ "review_requested" => Some(Reason::ReviewRequested),
92+ "author" => Some(Reason::Author),
93+ "comment" => Some(Reason::Comment),
94+ "state_change" => Some(Reason::StateChange),
95+ "subscribed" => Some(Reason::Subscribed),
96+ "watching" => Some(Reason::Watching),
97+ _ => None,
98+ }
99+ }
100+
101+ /// Strength, higher is stronger: mention > assign > review_requested > author > comment >
102+ /// state_change > subscribed > watching.
103+ pub fn rank(self) -> u8 {
104+ match self {
105+ Reason::Mention => 8,
106+ Reason::Assign => 7,
107+ Reason::ReviewRequested => 6,
108+ Reason::Author => 5,
109+ Reason::Comment => 4,
110+ Reason::StateChange => 3,
111+ Reason::Subscribed => 2,
112+ Reason::Watching => 1,
113+ }
114+ }
115+}
116+
117+/// One inbox row joined with its repo, issue or pull, and the last actor.
118+#[derive(Debug, Clone, PartialEq, Eq)]
119+pub struct Notification {
120+ pub id: Id,
121+ pub repo_id: Id,
122+ pub repo_owner: String,
123+ pub repo_name: String,
124+ pub issue_id: Id,
125+ pub number: i64,
126+ /// True for pull requests (`issues.kind = 'pull'`).
127+ pub is_pull: bool,
128+ pub title: String,
129+ /// `open` or `closed`.
130+ pub issue_state: String,
131+ pub reason: Reason,
132+ pub last_activity: String,
133+ /// `None` when the actor was deleted without a ghost reassignment or the activity had none.
134+ pub last_actor_name: Option<String>,
135+ pub unread: bool,
136+ pub updated_at: String,
137+}
crates/forge-core/src/password.rsadded+52 -0
@@ -0,0 +1,52 @@
1+//! Password hashing with argon2id.
2+
3+use argon2::Argon2;
4+use argon2::password_hash::phc::PasswordHash;
5+use argon2::password_hash::{PasswordHasher, PasswordVerifier};
6+
7+use crate::error::{Error, Result};
8+
9+/// Hashes `pw` with argon2id (default parameters) and a random salt. Returns a PHC string.
10+pub fn hash_password(pw: &str) -> Result<String> {
11+ // The trait method draws a fresh salt from the OS RNG.
12+ Argon2::default()
13+ .hash_password(pw.as_bytes())
14+ .map(|h| h.to_string())
15+ .map_err(|e| Error::Crypto(format!("argon2: {e}")))
16+}
17+
18+/// Returns true only if `pw` matches the PHC string `hash`. Any error yields false.
19+pub fn verify_password(pw: &str, hash: &str) -> bool {
20+ let Ok(parsed) = PasswordHash::new(hash) else {
21+ return false;
22+ };
23+ Argon2::default()
24+ .verify_password(pw.as_bytes(), &parsed)
25+ .is_ok()
26+}
27+
28+#[cfg(test)]
29+mod tests {
30+ use super::*;
31+
32+ #[test]
33+ fn hash_then_verify() {
34+ let hash = hash_password("correct horse").unwrap();
35+ assert!(hash.starts_with("$argon2id$"));
36+ assert!(verify_password("correct horse", &hash));
37+ assert!(!verify_password("wrong", &hash));
38+ }
39+
40+ #[test]
41+ fn salts_differ_between_hashes() {
42+ let a = hash_password("pw").unwrap();
43+ let b = hash_password("pw").unwrap();
44+ assert_ne!(a, b);
45+ }
46+
47+ #[test]
48+ fn verify_is_false_on_malformed_hash() {
49+ assert!(!verify_password("pw", "not a phc string"));
50+ assert!(!verify_password("pw", ""));
51+ }
52+}
crates/forge-core/src/protection.rsadded+373 -0
@@ -0,0 +1,373 @@
1+//! Branch protection rules: pattern syntax, validation, matching and effective-rule resolution.
2+
3+use crate::error::{Error, Result};
4+
5+/// Longest accepted pattern, in bytes.
6+pub const MAX_PATTERN_BYTES: usize = 200;
7+/// Largest accepted `required_approvals`.
8+pub const MAX_REQUIRED_APPROVALS: u32 = 20;
9+
10+#[derive(Debug, Clone, PartialEq)]
11+pub struct BranchRule {
12+ pub id: String,
13+ pub repo_id: String,
14+ pub pattern: String,
15+ pub allow_force_push: bool,
16+ pub allow_deletion: bool,
17+ /// Approvals from Write-or-higher reviewers that a pull request needs before it can merge.
18+ pub required_approvals: u32,
19+ /// A changes-requested review from a Write-or-higher reviewer blocks the merge.
20+ pub block_on_changes_requested: bool,
21+ /// The pull request head must contain the base tip before it can merge.
22+ pub require_up_to_date: bool,
23+ /// Direct pushes to the branch are rejected for everyone but repo admins. Merges through
24+ /// pull requests are unaffected.
25+ pub restrict_push: bool,
26+ pub created_at: String,
27+}
28+
29+/// The settings a rule saves, without its identity.
30+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
31+pub struct RuleFlags {
32+ pub allow_force_push: bool,
33+ pub allow_deletion: bool,
34+ pub required_approvals: u32,
35+ pub block_on_changes_requested: bool,
36+ pub require_up_to_date: bool,
37+ pub restrict_push: bool,
38+}
39+
40+impl RuleFlags {
41+ /// Only the force-push and deletion flags set; the merge and push rules off.
42+ pub fn basic(allow_force_push: bool, allow_deletion: bool) -> Self {
43+ Self {
44+ allow_force_push,
45+ allow_deletion,
46+ ..Self::default()
47+ }
48+ }
49+}
50+
51+/// The settings that apply to one branch. `Default` is the unprotected branch.
52+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
53+pub struct Protection {
54+ pub allow_force_push: bool,
55+ pub allow_deletion: bool,
56+ pub required_approvals: u32,
57+ pub block_on_changes_requested: bool,
58+ pub require_up_to_date: bool,
59+ pub restrict_push: bool,
60+}
61+
62+/// Rejects `required_approvals` above `MAX_REQUIRED_APPROVALS`.
63+pub fn validate_required_approvals(n: u32) -> Result<()> {
64+ if n > MAX_REQUIRED_APPROVALS {
65+ return Err(Error::Invalid(format!(
66+ "required approvals must be between 0 and {MAX_REQUIRED_APPROVALS}"
67+ )));
68+ }
69+ Ok(())
70+}
71+
72+/// Pattern syntax: a literal branch name, `*` matches any run of chars except `/`, `**` matches anything
73+/// including `/`. Max 200 bytes, it must be a valid branch name otherwise (no `..`, no leading `-`, no
74+/// control or space chars, no `~^:?[\`). The pattern must not include the `refs/heads/` prefix.
75+pub fn validate_pattern(p: &str) -> Result<()> {
76+ let invalid = |why: &str| Err(Error::Invalid(format!("branch pattern {why}")));
77+ if p.is_empty() {
78+ return invalid("is empty");
79+ }
80+ if p.len() > MAX_PATTERN_BYTES {
81+ return invalid(&format!("is longer than {MAX_PATTERN_BYTES} bytes"));
82+ }
83+ if p.starts_with("refs/heads/") {
84+ return invalid("must not include the refs/heads/ prefix");
85+ }
86+ if p == "@" || p.contains("@{") {
87+ return invalid("contains a reserved sequence");
88+ }
89+ if p.contains("..") {
90+ return invalid("must not contain '..'");
91+ }
92+ if p.starts_with('-') {
93+ return invalid("must not start with '-'");
94+ }
95+ if p.starts_with('/') || p.ends_with('/') || p.contains("//") {
96+ return invalid("has an empty path segment");
97+ }
98+ if p.split('/')
99+ .any(|seg| seg.starts_with('.') || seg.ends_with(".lock"))
100+ {
101+ return invalid("has a path segment that starts with '.' or ends with '.lock'");
102+ }
103+ if p.ends_with('.') {
104+ return invalid("must not end with '.'");
105+ }
106+ if p.chars()
107+ .any(|c| c.is_control() || c.is_whitespace() || "~^:?[\\".contains(c))
108+ {
109+ return invalid("contains a forbidden character");
110+ }
111+ Ok(())
112+}
113+
114+#[derive(Clone, Copy, PartialEq, Eq)]
115+enum Tok {
116+ Char(char),
117+ /// `*`: any run of chars except `/`.
118+ Star,
119+ /// `**`: any run of chars, `/` included.
120+ AnyStar,
121+}
122+
123+fn tokenize(pattern: &str) -> Vec<Tok> {
124+ let mut out = Vec::new();
125+ let mut chars = pattern.chars().peekable();
126+ while let Some(c) = chars.next() {
127+ if c != '*' {
128+ out.push(Tok::Char(c));
129+ continue;
130+ }
131+ let mut run = 1;
132+ while chars.peek() == Some(&'*') {
133+ chars.next();
134+ run += 1;
135+ }
136+ out.push(if run >= 2 { Tok::AnyStar } else { Tok::Star });
137+ }
138+ out
139+}
140+
141+/// Whether `branch` matches `pattern`. Dynamic programming over (token, char) positions, so the
142+/// cost is bounded by pattern length times branch length.
143+pub fn pattern_matches(pattern: &str, branch: &str) -> bool {
144+ let chars: Vec<char> = branch.chars().collect();
145+ let n = chars.len();
146+ // reach[j]: the tokens consumed so far can match chars[..j].
147+ let mut reach = vec![false; n + 1];
148+ reach[0] = true;
149+ for tok in tokenize(pattern) {
150+ let mut next = vec![false; n + 1];
151+ match tok {
152+ Tok::Char(c) => {
153+ for j in 0..n {
154+ next[j + 1] = reach[j] && chars[j] == c;
155+ }
156+ }
157+ Tok::Star => {
158+ next[0] = reach[0];
159+ for j in 1..=n {
160+ next[j] = reach[j] || (next[j - 1] && chars[j - 1] != '/');
161+ }
162+ }
163+ Tok::AnyStar => {
164+ next[0] = reach[0];
165+ for j in 1..=n {
166+ next[j] = reach[j] || next[j - 1];
167+ }
168+ }
169+ }
170+ reach = next;
171+ }
172+ reach[n]
173+}
174+
175+fn is_literal(pattern: &str) -> bool {
176+ !pattern.contains('*')
177+}
178+
179+/// Rules for `branch` (short name, without refs/heads/):
180+/// 1. If any rule matches: the most specific one wins. A literal (no `*`) beats a glob; among globs the
181+/// longest pattern wins, and ties go to the earliest created_at.
182+/// 2. Otherwise, if `branch == default_branch`: built-in protection {force_push: false, deletion: false}.
183+/// 3. Otherwise: unprotected {force_push: true, deletion: true}.
184+pub fn effective(rules: &[BranchRule], default_branch: &str, branch: &str) -> Protection {
185+ let best = rules
186+ .iter()
187+ .filter(|r| pattern_matches(&r.pattern, branch))
188+ .min_by(|a, b| {
189+ // Literal first, then longer pattern, then earliest creation, then pattern text
190+ // so the result never depends on the order of `rules`.
191+ is_literal(&b.pattern)
192+ .cmp(&is_literal(&a.pattern))
193+ .then_with(|| b.pattern.len().cmp(&a.pattern.len()))
194+ .then_with(|| a.created_at.cmp(&b.created_at))
195+ .then_with(|| a.pattern.cmp(&b.pattern))
196+ });
197+ if let Some(rule) = best {
198+ return Protection {
199+ allow_force_push: rule.allow_force_push,
200+ allow_deletion: rule.allow_deletion,
201+ required_approvals: rule.required_approvals,
202+ block_on_changes_requested: rule.block_on_changes_requested,
203+ require_up_to_date: rule.require_up_to_date,
204+ restrict_push: rule.restrict_push,
205+ };
206+ }
207+ if branch == default_branch {
208+ Protection {
209+ allow_force_push: false,
210+ allow_deletion: false,
211+ ..Protection::default()
212+ }
213+ } else {
214+ Protection {
215+ allow_force_push: true,
216+ allow_deletion: true,
217+ ..Protection::default()
218+ }
219+ }
220+}
221+
222+#[cfg(test)]
223+mod tests {
224+ use super::*;
225+
226+ fn rule(pattern: &str, force: bool, deletion: bool, created: &str) -> BranchRule {
227+ BranchRule {
228+ id: format!("id-{pattern}"),
229+ repo_id: "r".into(),
230+ pattern: pattern.into(),
231+ allow_force_push: force,
232+ allow_deletion: deletion,
233+ required_approvals: 0,
234+ block_on_changes_requested: false,
235+ require_up_to_date: false,
236+ restrict_push: false,
237+ created_at: created.into(),
238+ }
239+ }
240+
241+ fn flags(force: bool, deletion: bool) -> Protection {
242+ Protection {
243+ allow_force_push: force,
244+ allow_deletion: deletion,
245+ ..Protection::default()
246+ }
247+ }
248+
249+ #[test]
250+ fn pattern_matches_cases() {
251+ assert!(pattern_matches("main", "main"));
252+ assert!(!pattern_matches("main", "main2"));
253+ assert!(pattern_matches("release/*", "release/1.0"));
254+ assert!(!pattern_matches("release/*", "release/a/b"));
255+ assert!(pattern_matches("release/**", "release/a/b"));
256+ assert!(pattern_matches("*", "feature"));
257+ assert!(!pattern_matches("*", "a/b"));
258+ assert!(pattern_matches("**", "anything/at/all"));
259+ assert!(pattern_matches("**", "x"));
260+ assert!(!pattern_matches("", "x"));
261+ assert!(pattern_matches("a*b", "ab"));
262+ assert!(pattern_matches("a*b", "axxb"));
263+ assert!(!pattern_matches("a*b", "a/b"));
264+ }
265+
266+ #[test]
267+ fn validate_pattern_rejects_bad_input() {
268+ for bad in [
269+ "refs/heads/main",
270+ "a..b",
271+ "-x",
272+ "a b",
273+ "a~b",
274+ "",
275+ &"a".repeat(201),
276+ ] {
277+ assert!(validate_pattern(bad).is_err(), "{bad:?} should be rejected");
278+ }
279+ }
280+
281+ #[test]
282+ fn validate_pattern_accepts_good_input() {
283+ for good in [
284+ "main",
285+ "release/*",
286+ "release/**",
287+ "**",
288+ "a".repeat(200).as_str(),
289+ ] {
290+ assert!(
291+ validate_pattern(good).is_ok(),
292+ "{good:?} should be accepted"
293+ );
294+ }
295+ }
296+
297+ #[test]
298+ fn effective_without_rules_protects_only_default() {
299+ let none: &[BranchRule] = &[];
300+ assert_eq!(effective(none, "main", "main"), flags(false, false));
301+ assert_eq!(effective(none, "main", "feature"), flags(true, true));
302+ }
303+
304+ #[test]
305+ fn effective_literal_allows_force_push_on_main() {
306+ let rules = [rule("main", true, false, "1")];
307+ assert_eq!(effective(&rules, "main", "main"), flags(true, false));
308+ }
309+
310+ #[test]
311+ fn effective_literal_beats_glob() {
312+ let rules = [rule("*", false, false, "1"), rule("main", true, true, "2")];
313+ assert_eq!(effective(&rules, "main", "main"), flags(true, true));
314+ }
315+
316+ #[test]
317+ fn effective_longer_glob_wins() {
318+ // Both `*` and `rel*` match `release`; the longer `rel*` wins even though `*` is older.
319+ let rules = [rule("*", true, true, "0"), rule("rel*", false, false, "1")];
320+ assert_eq!(effective(&rules, "main", "release"), flags(false, false));
321+ // `release/**` is longer than `release/*`, so it wins for nested names.
322+ let rules = [
323+ rule("release/*", true, true, "1"),
324+ rule("release/**", false, false, "2"),
325+ ];
326+ assert_eq!(
327+ effective(&rules, "main", "release/a/b"),
328+ flags(false, false)
329+ );
330+ }
331+
332+ #[test]
333+ fn effective_carries_merge_and_push_rules() {
334+ let mut gated = rule("main", false, false, "1");
335+ gated.required_approvals = 2;
336+ gated.block_on_changes_requested = true;
337+ gated.require_up_to_date = true;
338+ gated.restrict_push = true;
339+ let rules = [gated];
340+ assert_eq!(
341+ effective(&rules, "main", "main"),
342+ Protection {
343+ allow_force_push: false,
344+ allow_deletion: false,
345+ required_approvals: 2,
346+ block_on_changes_requested: true,
347+ require_up_to_date: true,
348+ restrict_push: true,
349+ }
350+ );
351+ // Unprotected and built-in default branches carry no merge rules.
352+ assert_eq!(effective(&rules, "main", "feature").required_approvals, 0);
353+ assert!(!effective(&[], "main", "main").restrict_push);
354+ }
355+
356+ #[test]
357+ fn validate_required_approvals_bounds() {
358+ assert!(validate_required_approvals(0).is_ok());
359+ assert!(validate_required_approvals(MAX_REQUIRED_APPROVALS).is_ok());
360+ assert!(validate_required_approvals(MAX_REQUIRED_APPROVALS + 1).is_err());
361+ }
362+
363+ #[test]
364+ fn effective_double_star_deletion_false_protects_every_branch() {
365+ let rules = [rule("**", true, false, "1")];
366+ for branch in ["main", "feature", "a/b/c"] {
367+ assert!(
368+ !effective(&rules, "main", branch).allow_deletion,
369+ "{branch} should not allow deletion"
370+ );
371+ }
372+ }
373+}
crates/forge-core/src/pulls.rsadded+128 -0
@@ -0,0 +1,128 @@
1+//! Pull request types. A pull request is an issue with `kind = 'pull'` plus a `pull_requests`
2+//! row. The stored shape is in migrations/0007_pulls.sql; the store functions live in
3+//! store/pulls.rs.
4+
5+use crate::error::{Error, Result};
6+use crate::issues::Issue;
7+use crate::model::Id;
8+
9+/// Mergeability of a pull request, as last computed.
10+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11+pub enum Mergeable {
12+ Unknown,
13+ Clean,
14+ Conflict,
15+ Merged,
16+ UpToDate,
17+}
18+
19+impl Mergeable {
20+ pub fn as_str(&self) -> &'static str {
21+ match self {
22+ Mergeable::Unknown => "unknown",
23+ Mergeable::Clean => "clean",
24+ Mergeable::Conflict => "conflict",
25+ Mergeable::Merged => "merged",
26+ Mergeable::UpToDate => "up_to_date",
27+ }
28+ }
29+}
30+
31+impl std::str::FromStr for Mergeable {
32+ type Err = Error;
33+
34+ fn from_str(s: &str) -> Result<Self> {
35+ match s {
36+ "unknown" => Ok(Mergeable::Unknown),
37+ "clean" => Ok(Mergeable::Clean),
38+ "conflict" => Ok(Mergeable::Conflict),
39+ "merged" => Ok(Mergeable::Merged),
40+ "up_to_date" => Ok(Mergeable::UpToDate),
41+ other => Err(Error::Invalid(format!("mergeable {other:?}"))),
42+ }
43+ }
44+}
45+
46+#[derive(Debug, Clone, PartialEq)]
47+pub struct PullRequest {
48+ /// The backing issue row (`kind = 'pull'`): number, title, state, author and so on.
49+ pub issue: Issue,
50+ pub base_branch: String,
51+ /// `None` once the head repo has been deleted.
52+ pub head_repo_id: Option<Id>,
53+ pub head_branch: String,
54+ /// Last known head commit (also `refs/pull/N/head` in the base repo).
55+ pub head_oid: String,
56+ /// Base branch tip when mergeability was last computed.
57+ pub base_oid: String,
58+ pub merge_base: Option<String>,
59+ pub mergeable: Mergeable,
60+ pub conflict_files: Vec<String>,
61+ pub merged_at: Option<String>,
62+ pub merged_by: Option<Id>,
63+ pub merge_commit: Option<String>,
64+}
65+
66+/// How a pull request is merged into its base branch.
67+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68+pub enum MergeStyle {
69+ Merge,
70+ Squash,
71+ FastForward,
72+}
73+
74+impl MergeStyle {
75+ /// The value used in forms and URLs.
76+ pub fn as_str(&self) -> &'static str {
77+ match self {
78+ MergeStyle::Merge => "merge",
79+ MergeStyle::Squash => "squash",
80+ MergeStyle::FastForward => "ff",
81+ }
82+ }
83+}
84+
85+impl std::str::FromStr for MergeStyle {
86+ type Err = Error;
87+
88+ fn from_str(s: &str) -> Result<Self> {
89+ match s {
90+ "merge" => Ok(MergeStyle::Merge),
91+ "squash" => Ok(MergeStyle::Squash),
92+ "ff" => Ok(MergeStyle::FastForward),
93+ other => Err(Error::Invalid(format!("merge style {other:?}"))),
94+ }
95+ }
96+}
97+
98+#[cfg(test)]
99+mod tests {
100+ use super::*;
101+
102+ #[test]
103+ fn mergeable_round_trips() {
104+ for m in [
105+ Mergeable::Unknown,
106+ Mergeable::Clean,
107+ Mergeable::Conflict,
108+ Mergeable::Merged,
109+ Mergeable::UpToDate,
110+ ] {
111+ assert_eq!(m.as_str().parse::<Mergeable>().ok(), Some(m));
112+ }
113+ assert!("Clean".parse::<Mergeable>().is_err());
114+ }
115+
116+ #[test]
117+ fn merge_style_round_trips() {
118+ for s in [
119+ MergeStyle::Merge,
120+ MergeStyle::Squash,
121+ MergeStyle::FastForward,
122+ ] {
123+ assert_eq!(s.as_str().parse::<MergeStyle>().ok(), Some(s));
124+ }
125+ assert_eq!(MergeStyle::FastForward.as_str(), "ff");
126+ assert!("rebase".parse::<MergeStyle>().is_err());
127+ }
128+}
crates/forge-core/src/raw_token.rsadded+142 -0
@@ -0,0 +1,142 @@
1+//! Signed tokens for private raw-file URLs (see docs/m2-interfaces.md).
2+//!
3+//! Token format: `"<expires>.<base64url-nopad(hmac_sha256)>"`, where the MAC covers
4+//! `"raw-v1:" + repo_id + ":" + expires`.
5+
6+use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine as _};
7+use hmac::{Hmac, KeyInit, Mac};
8+use sha2::Sha256;
9+
10+type HmacSha256 = Hmac<Sha256>;
11+
12+const MAC_LEN: usize = 32;
13+
14+fn mac_for(key: &[u8; 32], repo_id: &str, expires_unix: i64) -> HmacSha256 {
15+ // HMAC accepts keys of any length, so construction with a 32-byte key cannot fail.
16+ let mut mac =
17+ <HmacSha256 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
18+ mac.update(b"raw-v1:");
19+ mac.update(repo_id.as_bytes());
20+ mac.update(b":");
21+ mac.update(expires_unix.to_string().as_bytes());
22+ mac
23+}
24+
25+/// Signs access to `repo_id`'s raw files until `expires_unix` (seconds since the epoch).
26+pub fn sign_raw(key: &[u8; 32], repo_id: &str, expires_unix: i64) -> String {
27+ let tag = mac_for(key, repo_id, expires_unix).finalize().into_bytes();
28+ format!("{expires_unix}.{}", BASE64_URL_SAFE_NO_PAD.encode(tag))
29+}
30+
31+/// Verifies a token for `repo_id` at time `now_unix`. Rejects malformed input, expired
32+/// tokens (`now >= expires`) and bad MACs. The MAC comparison is constant time.
33+pub fn verify_raw(key: &[u8; 32], token: &str, repo_id: &str, now_unix: i64) -> bool {
34+ let Some((expires_str, mac_str)) = token.split_once('.') else {
35+ return false;
36+ };
37+ // Strict canonical decimal: no sign, no leading zeros, no whitespace.
38+ let Ok(expires) = expires_str.parse::<i64>() else {
39+ return false;
40+ };
41+ if expires.to_string() != expires_str {
42+ return false;
43+ }
44+ if now_unix >= expires {
45+ return false;
46+ }
47+ let Ok(tag) = BASE64_URL_SAFE_NO_PAD.decode(mac_str) else {
48+ return false;
49+ };
50+ if tag.len() != MAC_LEN {
51+ return false;
52+ }
53+ mac_for(key, repo_id, expires).verify_slice(&tag).is_ok()
54+}
55+
56+#[cfg(test)]
57+mod tests {
58+ use super::*;
59+
60+ const KEY: [u8; 32] = [7; 32];
61+ const OTHER_KEY: [u8; 32] = [9; 32];
62+ const NOW: i64 = 1_700_000_000;
63+
64+ #[test]
65+ fn round_trip() {
66+ let token = sign_raw(&KEY, "repo-1", NOW + 60);
67+ assert!(verify_raw(&KEY, &token, "repo-1", NOW));
68+ }
69+
70+ #[test]
71+ fn token_has_expected_shape() {
72+ let token = sign_raw(&KEY, "repo-1", NOW + 60);
73+ let (expires, mac) = token.split_once('.').unwrap();
74+ assert_eq!(expires, (NOW + 60).to_string());
75+ assert_eq!(BASE64_URL_SAFE_NO_PAD.decode(mac).unwrap().len(), MAC_LEN);
76+ }
77+
78+ #[test]
79+ fn expired_is_rejected() {
80+ let token = sign_raw(&KEY, "repo-1", NOW);
81+ // now == expires is already expired.
82+ assert!(!verify_raw(&KEY, &token, "repo-1", NOW));
83+ assert!(!verify_raw(&KEY, &token, "repo-1", NOW + 1));
84+ assert!(verify_raw(&KEY, &token, "repo-1", NOW - 1));
85+ }
86+
87+ #[test]
88+ fn wrong_repo_is_rejected() {
89+ let token = sign_raw(&KEY, "repo-1", NOW + 60);
90+ assert!(!verify_raw(&KEY, &token, "repo-2", NOW));
91+ }
92+
93+ #[test]
94+ fn tampered_mac_is_rejected() {
95+ let token = sign_raw(&KEY, "repo-1", NOW + 60);
96+ let (expires, mac) = token.split_once('.').unwrap();
97+ let mut tag = BASE64_URL_SAFE_NO_PAD.decode(mac).unwrap();
98+ tag[0] ^= 1;
99+ let tampered = format!("{expires}.{}", BASE64_URL_SAFE_NO_PAD.encode(tag));
100+ assert!(!verify_raw(&KEY, &tampered, "repo-1", NOW));
101+ }
102+
103+ #[test]
104+ fn extended_expiry_is_rejected() {
105+ let token = sign_raw(&KEY, "repo-1", NOW + 60);
106+ let (_, mac) = token.split_once('.').unwrap();
107+ let forged = format!("{}.{mac}", NOW + 3600);
108+ assert!(!verify_raw(&KEY, &forged, "repo-1", NOW));
109+ }
110+
111+ #[test]
112+ fn garbage_is_rejected() {
113+ let good = sign_raw(&KEY, "repo-1", NOW + 60);
114+ let (expires, mac) = good.split_once('.').unwrap();
115+ let inputs = [
116+ String::new(),
117+ "garbage".to_owned(),
118+ ".".to_owned(),
119+ expires.to_string(),
120+ format!("{expires}."),
121+ format!(".{mac}"),
122+ format!("{expires}.{mac}.extra"),
123+ format!("+{expires}.{mac}"),
124+ format!("{expires}.!!!"),
125+ format!("{expires}.{}", BASE64_URL_SAFE_NO_PAD.encode([0u8; 16])),
126+ format!("{expires}.{mac}=="),
127+ format!("notanumber.{mac}"),
128+ ];
129+ for input in &inputs {
130+ assert!(
131+ !verify_raw(&KEY, input, "repo-1", NOW),
132+ "accepted {input:?}"
133+ );
134+ }
135+ }
136+
137+ #[test]
138+ fn different_key_is_rejected() {
139+ let token = sign_raw(&KEY, "repo-1", NOW + 60);
140+ assert!(!verify_raw(&OTHER_KEY, &token, "repo-1", NOW));
141+ }
142+}
crates/forge-core/src/reactions.rsadded+112 -0
@@ -0,0 +1,112 @@
1+//! Emoji reactions: the fixed set a user can put on an issue, a pull request or a comment, and
2+//! the summaries the pages show. Stored as the short name (`+1`, `heart`, ...).
3+
4+use crate::error::{Error, Result};
5+use crate::model::Id;
6+
7+/// The fixed reaction set, in the order the pages list them.
8+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
9+pub enum Reaction {
10+ PlusOne,
11+ MinusOne,
12+ Laugh,
13+ Hooray,
14+ Confused,
15+ Heart,
16+ Rocket,
17+ Eyes,
18+}
19+
20+impl Reaction {
21+ /// Every reaction, in display order.
22+ pub const ALL: [Reaction; 8] = [
23+ Reaction::PlusOne,
24+ Reaction::MinusOne,
25+ Reaction::Laugh,
26+ Reaction::Hooray,
27+ Reaction::Confused,
28+ Reaction::Heart,
29+ Reaction::Rocket,
30+ Reaction::Eyes,
31+ ];
32+
33+ /// The short name stored in the database and sent in forms.
34+ pub fn name(self) -> &'static str {
35+ match self {
36+ Reaction::PlusOne => "+1",
37+ Reaction::MinusOne => "-1",
38+ Reaction::Laugh => "laugh",
39+ Reaction::Hooray => "hooray",
40+ Reaction::Confused => "confused",
41+ Reaction::Heart => "heart",
42+ Reaction::Rocket => "rocket",
43+ Reaction::Eyes => "eyes",
44+ }
45+ }
46+
47+ /// The glyph shown for the reaction.
48+ pub fn emoji(self) -> &'static str {
49+ match self {
50+ Reaction::PlusOne => "\u{1f44d}",
51+ Reaction::MinusOne => "\u{1f44e}",
52+ Reaction::Laugh => "\u{1f604}",
53+ Reaction::Hooray => "\u{1f389}",
54+ Reaction::Confused => "\u{1f615}",
55+ Reaction::Heart => "\u{2764}\u{fe0f}",
56+ Reaction::Rocket => "\u{1f680}",
57+ Reaction::Eyes => "\u{1f440}",
58+ }
59+ }
60+
61+ /// The reaction with this short name, or `None` for anything outside the set.
62+ pub fn parse(name: &str) -> Option<Reaction> {
63+ Reaction::ALL.into_iter().find(|r| r.name() == name)
64+ }
65+}
66+
67+impl std::str::FromStr for Reaction {
68+ type Err = Error;
69+
70+ fn from_str(s: &str) -> Result<Self> {
71+ Reaction::parse(s).ok_or_else(|| Error::Invalid(format!("unknown reaction: {s}")))
72+ }
73+}
74+
75+/// What a reaction is attached to. Pull requests are issue rows, so they use `Issue`.
76+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77+pub enum ReactionTarget {
78+ Issue,
79+ Comment,
80+ /// A discussion (store/discussions.rs).
81+ Discussion,
82+ /// A discussion comment or reply.
83+ DiscussionComment,
84+}
85+
86+impl ReactionTarget {
87+ /// The `target_kind` column value.
88+ pub fn as_str(self) -> &'static str {
89+ match self {
90+ ReactionTarget::Issue => "issue",
91+ ReactionTarget::Comment => "comment",
92+ ReactionTarget::Discussion => "discussion",
93+ ReactionTarget::DiscussionComment => "discussion_comment",
94+ }
95+ }
96+}
97+
98+/// One reaction on one target: how many users chose it, whether the viewer did, and the first
99+/// usernames (at most ten, earliest reaction first).
100+#[derive(Debug, Clone, PartialEq, Eq)]
101+pub struct ReactionSummary {
102+ pub reaction: Reaction,
103+ pub count: usize,
104+ pub reacted_by_viewer: bool,
105+ pub users: Vec<String>,
106+}
107+
108+/// Usernames kept per summary.
109+pub const SUMMARY_USERS: usize = 10;
110+
111+/// Summaries of one target, keyed by the target id.
112+pub type ReactionMap = std::collections::HashMap<Id, Vec<ReactionSummary>>;
crates/forge-core/src/releases.rsadded+112 -0
@@ -0,0 +1,112 @@
1+//! Releases: a tag with a title, notes and optional downloadable assets. The stored shape is in
2+//! migrations/0020_releases.sql; the store functions live in store/releases.rs.
3+//!
4+//! A release names a git tag. The tag itself lives in git, so deleting a release keeps it.
5+//! Asset bytes live in the blob store under `assets/<asset id>`; only metadata is stored here.
6+
7+use crate::model::Id;
8+
9+/// Longest release title, in characters.
10+pub const MAX_TITLE_CHARS: usize = 200;
11+/// Longest release body, in characters.
12+pub const MAX_BODY_CHARS: usize = 65_536;
13+/// Longest asset file name, in bytes. Names are ASCII, so bytes and characters agree.
14+pub const MAX_ASSET_NAME_LEN: usize = 200;
15+
16+/// A release. `published_at` is set once it is first published and stays set; `draft` decides
17+/// visibility. Drafts are shown only to people with Write on the repo.
18+#[derive(Debug, Clone, PartialEq, Eq)]
19+pub struct Release {
20+ pub id: Id,
21+ pub repo_id: Id,
22+ pub tag: String,
23+ /// The commit the tag names when the release was made.
24+ pub target_oid: String,
25+ pub title: String,
26+ pub body: String,
27+ pub draft: bool,
28+ pub prerelease: bool,
29+ pub author_id: Id,
30+ pub author_name: String,
31+ pub created_at: String,
32+ pub published_at: Option<String>,
33+}
34+
35+/// A file attached to a release. `download_count` counts completed starts of downloads.
36+#[derive(Debug, Clone, PartialEq, Eq)]
37+pub struct ReleaseAsset {
38+ pub id: Id,
39+ pub release_id: Id,
40+ pub name: String,
41+ pub size: u64,
42+ pub sha256: String,
43+ pub content_type: String,
44+ pub download_count: u64,
45+ pub uploader_id: Id,
46+ pub created_at: String,
47+}
48+
49+/// Input for `Store::create_release`.
50+#[derive(Debug, Clone, Copy)]
51+pub struct NewRelease<'a> {
52+ pub repo_id: &'a str,
53+ pub tag: &'a str,
54+ pub target_oid: &'a str,
55+ pub title: &'a str,
56+ pub body: &'a str,
57+ pub draft: bool,
58+ pub prerelease: bool,
59+ pub author_id: &'a str,
60+}
61+
62+/// The editable fields of a release. The tag and its target never change.
63+#[derive(Debug, Clone, Copy)]
64+pub struct ReleaseEdit<'a> {
65+ pub title: &'a str,
66+ pub body: &'a str,
67+ pub draft: bool,
68+ pub prerelease: bool,
69+}
70+
71+/// Input for `Store::add_asset`. The blob is already written under `assets/<id>`.
72+#[derive(Debug, Clone, Copy)]
73+pub struct NewAsset<'a> {
74+ pub id: &'a str,
75+ pub release_id: &'a str,
76+ pub name: &'a str,
77+ pub size: u64,
78+ pub sha256: &'a str,
79+ pub content_type: &'a str,
80+ pub uploader_id: &'a str,
81+}
82+
83+/// True for a file name that is safe in a `Content-Disposition` header and a URL segment: 1 to
84+/// [`MAX_ASSET_NAME_LEN`] of ASCII letters, digits, `.`, `-`, `_` or `+`, not starting with a dot.
85+pub fn valid_asset_name(name: &str) -> bool {
86+ !name.is_empty()
87+ && name.len() <= MAX_ASSET_NAME_LEN
88+ && !name.starts_with('.')
89+ && name
90+ .bytes()
91+ .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_' | b'+'))
92+}
93+
94+/// The content type stored for an upload: a `type/subtype` token pair, or
95+/// `application/octet-stream` when the client sent nothing usable.
96+pub fn clean_content_type(raw: Option<&str>) -> String {
97+ let fallback = "application/octet-stream";
98+ let Some(raw) = raw.map(str::trim) else {
99+ return fallback.to_owned();
100+ };
101+ let ok_token = |s: &str| {
102+ !s.is_empty()
103+ && s.bytes()
104+ .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'+' | b'_'))
105+ };
106+ match raw.split_once('/') {
107+ Some((main, sub)) if ok_token(main) && ok_token(sub) => {
108+ format!("{}/{}", main.to_ascii_lowercase(), sub.to_ascii_lowercase())
109+ }
110+ _ => fallback.to_owned(),
111+ }
112+}
crates/forge-core/src/reviews.rsadded+123 -0
@@ -0,0 +1,123 @@
1+//! Pull request reviews and line comments. The stored shape is in migrations/0008_reviews.sql;
2+//! the store functions live in store/reviews.rs.
3+
4+use crate::error::{Error, Result};
5+use crate::model::Id;
6+
7+/// State of a review. `Pending` is a draft visible only to its author.
8+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9+pub enum ReviewState {
10+ Pending,
11+ Commented,
12+ Approved,
13+ ChangesRequested,
14+ Dismissed,
15+}
16+
17+impl ReviewState {
18+ pub fn as_str(&self) -> &'static str {
19+ match self {
20+ ReviewState::Pending => "pending",
21+ ReviewState::Commented => "commented",
22+ ReviewState::Approved => "approved",
23+ ReviewState::ChangesRequested => "changes_requested",
24+ ReviewState::Dismissed => "dismissed",
25+ }
26+ }
27+}
28+
29+impl std::str::FromStr for ReviewState {
30+ type Err = Error;
31+
32+ fn from_str(s: &str) -> Result<Self> {
33+ match s {
34+ "pending" => Ok(ReviewState::Pending),
35+ "commented" => Ok(ReviewState::Commented),
36+ "approved" => Ok(ReviewState::Approved),
37+ "changes_requested" => Ok(ReviewState::ChangesRequested),
38+ "dismissed" => Ok(ReviewState::Dismissed),
39+ other => Err(Error::Invalid(format!("review state {other:?}"))),
40+ }
41+ }
42+}
43+
44+/// Which side of the diff a comment is on: `Old` for deleted lines, `New` for added and context
45+/// lines.
46+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47+pub enum Side {
48+ Old,
49+ New,
50+}
51+
52+impl Side {
53+ pub fn as_str(&self) -> &'static str {
54+ match self {
55+ Side::Old => "old",
56+ Side::New => "new",
57+ }
58+ }
59+}
60+
61+impl std::str::FromStr for Side {
62+ type Err = Error;
63+
64+ fn from_str(s: &str) -> Result<Self> {
65+ match s {
66+ "old" => Ok(Side::Old),
67+ "new" => Ok(Side::New),
68+ other => Err(Error::Invalid(format!("diff side {other:?}"))),
69+ }
70+ }
71+}
72+
73+#[derive(Debug, Clone, PartialEq)]
74+pub struct Review {
75+ pub id: Id,
76+ pub issue_id: Id,
77+ pub author_id: Id,
78+ pub author_name: String,
79+ pub state: ReviewState,
80+ pub body: String,
81+ pub commit_oid: String,
82+ pub dismissed_reason: Option<String>,
83+ /// The state the review had before it was dismissed (`Approved` or `ChangesRequested`). None
84+ /// for reviews dismissed before this was recorded, and for reviews that were not dismissed.
85+ pub dismissed_from: Option<ReviewState>,
86+ pub created_at: String,
87+ pub submitted_at: Option<String>,
88+}
89+
90+#[derive(Debug, Clone, PartialEq)]
91+pub struct ReviewComment {
92+ pub id: Id,
93+ pub review_id: Id,
94+ pub author_id: Id,
95+ pub author_name: String,
96+ pub path: String,
97+ pub side: Side,
98+ pub line: u32,
99+ pub commit_oid: String,
100+ pub diff_hunk: String,
101+ pub reply_to: Option<Id>,
102+ pub body: String,
103+ pub outdated: bool,
104+ pub resolved: bool,
105+ pub created_at: String,
106+ pub updated_at: String,
107+ /// True when the review this comment belongs to is still pending.
108+ pub pending: bool,
109+}
110+
111+/// A root comment and its replies, oldest first.
112+#[derive(Debug, Clone, PartialEq)]
113+pub struct Thread {
114+ pub root: ReviewComment,
115+ pub replies: Vec<ReviewComment>,
116+}
117+
118+/// Who currently approves and who requests changes, by username.
119+#[derive(Debug, Clone, Default, PartialEq, Eq)]
120+pub struct ReviewSummary {
121+ pub approved_by: Vec<String>,
122+ pub changes_requested_by: Vec<String>,
123+}
crates/forge-core/src/secret.rsadded+178 -0
@@ -0,0 +1,178 @@
1+//! Instance key handling and XChaCha20-Poly1305 sealing of secrets at rest.
2+
3+use std::path::Path;
4+
5+use base64::prelude::{BASE64_STANDARD, Engine as _};
6+use chacha20poly1305::aead::Aead;
7+use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce};
8+
9+use crate::error::{Error, Result};
10+
11+const PREFIX: &str = "v1:";
12+const NONCE_LEN: usize = 24;
13+
14+/// Loads the 32-byte instance key at `path`, creating it (mode 0600) if missing.
15+pub fn load_or_create_key(path: &Path) -> Result<[u8; 32]> {
16+ if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
17+ std::fs::create_dir_all(parent)?;
18+ }
19+ match create_key(path) {
20+ Ok(key) => Ok(key),
21+ Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => read_key(path),
22+ Err(e) => Err(e.into()),
23+ }
24+}
25+
26+fn create_key(path: &Path) -> std::io::Result<[u8; 32]> {
27+ use std::io::Write;
28+
29+ use rand::TryRng;
30+
31+ let mut key = [0u8; 32];
32+ rand::rngs::SysRng
33+ .try_fill_bytes(&mut key)
34+ .map_err(std::io::Error::other)?;
35+
36+ let mut options = std::fs::OpenOptions::new();
37+ options.write(true).create_new(true);
38+ #[cfg(unix)]
39+ {
40+ use std::os::unix::fs::OpenOptionsExt;
41+ options.mode(0o600);
42+ }
43+ let mut file = options.open(path)?;
44+ let written = file.write_all(&key).and_then(|()| file.sync_all());
45+ if let Err(e) = written {
46+ // Do not leave a truncated key behind; the next start would reject it.
47+ drop(file);
48+ let _ = std::fs::remove_file(path);
49+ return Err(e);
50+ }
51+ Ok(key)
52+}
53+
54+fn read_key(path: &Path) -> Result<[u8; 32]> {
55+ #[cfg(unix)]
56+ {
57+ use std::os::unix::fs::PermissionsExt;
58+ let mode = std::fs::metadata(path)?.permissions().mode();
59+ if mode & 0o077 != 0 {
60+ return Err(Error::Crypto(format!(
61+ "instance key {} is group/world accessible (mode {:o}); expected 0600",
62+ path.display(),
63+ mode & 0o777
64+ )));
65+ }
66+ }
67+ let bytes = std::fs::read(path)?;
68+ <[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| {
69+ Error::Crypto(format!(
70+ "instance key {} has length {}, expected 32",
71+ path.display(),
72+ bytes.len()
73+ ))
74+ })
75+}
76+
77+/// Seals `plaintext`. Output format: `"v1:" + base64(nonce || ciphertext)`.
78+///
79+/// Errors if the cipher rejects the input; the caller must not store plaintext instead.
80+pub fn seal(key: &[u8; 32], plaintext: &[u8]) -> Result<String> {
81+ let mut nonce = [0u8; NONCE_LEN];
82+ rand::fill(&mut nonce);
83+ let cipher = XChaCha20Poly1305::new(key.into());
84+ let ciphertext = cipher
85+ .encrypt(&XNonce::from(nonce), plaintext)
86+ .map_err(|_| Error::Crypto("sealing failed".into()))?;
87+ let mut blob = Vec::with_capacity(NONCE_LEN + ciphertext.len());
88+ blob.extend_from_slice(&nonce);
89+ blob.extend_from_slice(&ciphertext);
90+ Ok(format!("{PREFIX}{}", BASE64_STANDARD.encode(blob)))
91+}
92+
93+/// Opens a value produced by [`seal`].
94+pub fn open(key: &[u8; 32], sealed: &str) -> Result<Vec<u8>> {
95+ let encoded = sealed
96+ .strip_prefix(PREFIX)
97+ .ok_or_else(|| Error::Crypto("sealed value has no v1 prefix".into()))?;
98+ let blob = BASE64_STANDARD
99+ .decode(encoded)
100+ .map_err(|_| Error::Crypto("sealed value is not valid base64".into()))?;
101+ if blob.len() < NONCE_LEN {
102+ return Err(Error::Crypto("sealed value is too short".into()));
103+ }
104+ let (nonce, ciphertext) = blob.split_at(NONCE_LEN);
105+ let nonce = <[u8; NONCE_LEN]>::try_from(nonce)
106+ .map_err(|_| Error::Crypto("sealed value has a bad nonce".into()))?;
107+ let cipher = XChaCha20Poly1305::new(key.into());
108+ cipher
109+ .decrypt(&XNonce::from(nonce), ciphertext)
110+ .map_err(|_| Error::Crypto("sealed value failed authentication".into()))
111+}
112+
113+#[cfg(test)]
114+mod tests {
115+ use super::*;
116+
117+ #[test]
118+ fn seal_open_round_trip_and_format() {
119+ let key = [7u8; 32];
120+ let sealed = seal(&key, b"hello secret").unwrap();
121+ assert!(sealed.starts_with("v1:"));
122+ assert_eq!(open(&key, &sealed).unwrap(), b"hello secret");
123+ // Fresh nonce each time.
124+ assert_ne!(sealed, seal(&key, b"hello secret").unwrap());
125+ }
126+
127+ #[test]
128+ fn open_rejects_wrong_key_tamper_and_garbage() {
129+ let key = [7u8; 32];
130+ let other = [8u8; 32];
131+ let sealed = seal(&key, b"data").unwrap();
132+ assert!(open(&other, &sealed).is_err());
133+ let mut tampered = sealed.clone();
134+ let last = tampered.pop().unwrap();
135+ tampered.push(if last == 'A' { 'B' } else { 'A' });
136+ assert!(open(&key, &tampered).is_err());
137+ assert!(open(&key, "data").is_err());
138+ assert!(open(&key, "v1:!!!").is_err());
139+ assert!(open(&key, "v1:AAAA").is_err());
140+ assert!(open(&key, "").is_err());
141+ }
142+
143+ #[test]
144+ fn key_is_created_with_32_bytes_and_reloaded() {
145+ let dir = tempfile::tempdir().unwrap();
146+ let path = dir.path().join("sub").join("instance.key");
147+ let created = load_or_create_key(&path).unwrap();
148+ assert_eq!(std::fs::read(&path).unwrap().len(), 32);
149+ assert_eq!(load_or_create_key(&path).unwrap(), created);
150+ }
151+
152+ #[cfg(unix)]
153+ #[test]
154+ fn key_file_mode_is_0600_and_loose_modes_are_rejected() {
155+ use std::os::unix::fs::PermissionsExt;
156+ let dir = tempfile::tempdir().unwrap();
157+ let path = dir.path().join("instance.key");
158+ load_or_create_key(&path).unwrap();
159+ let mode = std::fs::metadata(&path).unwrap().permissions().mode();
160+ assert_eq!(mode & 0o777, 0o600);
161+
162+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
163+ assert!(load_or_create_key(&path).is_err());
164+ }
165+
166+ #[test]
167+ fn wrong_length_key_is_rejected() {
168+ let dir = tempfile::tempdir().unwrap();
169+ let path = dir.path().join("instance.key");
170+ std::fs::write(&path, [1u8; 31]).unwrap();
171+ #[cfg(unix)]
172+ {
173+ use std::os::unix::fs::PermissionsExt;
174+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
175+ }
176+ assert!(matches!(load_or_create_key(&path), Err(Error::Crypto(_))));
177+ }
178+}
crates/forge-core/src/session.rsadded+32 -0
@@ -0,0 +1,32 @@
1+//! Session and CSRF token generation.
2+
3+use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine as _};
4+
5+use crate::token::random_32;
6+
7+/// base64url(32 random bytes), used as the cookie value. The DB stores its sha256.
8+pub fn new_session_token() -> String {
9+ BASE64_URL_SAFE_NO_PAD.encode(random_32())
10+}
11+
12+pub fn new_csrf_token() -> String {
13+ BASE64_URL_SAFE_NO_PAD.encode(random_32())
14+}
15+
16+#[cfg(test)]
17+mod tests {
18+ use super::*;
19+
20+ #[test]
21+ fn tokens_are_unique_base64url_of_32_bytes() {
22+ let a = new_session_token();
23+ let b = new_csrf_token();
24+ assert_eq!(a.len(), 43);
25+ assert_eq!(b.len(), 43);
26+ assert_ne!(a, b);
27+ assert!(
28+ a.bytes()
29+ .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
30+ );
31+ }
32+}
crates/forge-core/src/store/account_tokens.rsadded+537 -0
@@ -0,0 +1,537 @@
1+//! Account mail tokens: password reset, email verification and invites. A token is 32 random
2+//! bytes shown once in the mail link; only its sha256 is stored. Redeeming one is an UPDATE that
3+//! is guarded by `used_at IS NULL` and the expiry, so a link works at most once. Timestamps are
4+//! fixed-width UTC stamps, so expiry is a text comparison.
5+
6+use std::time::Duration;
7+
8+use super::issues::finish;
9+use super::{
10+ Store, col_int, col_opt_text, col_text, db_err, fetch_opt, insert_err, map_user, new_id,
11+};
12+use crate::error::{Error, Result};
13+use crate::model::{AccountKind, User};
14+use crate::names::normalize_name;
15+use crate::time::{now_stamp, stamp_after};
16+use crate::token::{generate_token, hash_token};
17+
18+/// How long a password reset link works.
19+pub const RESET_TTL: Duration = Duration::from_secs(60 * 60);
20+/// How long an email verification link works.
21+pub const VERIFY_TTL: Duration = Duration::from_secs(24 * 60 * 60);
22+/// How long an invite link works.
23+pub const INVITE_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60);
24+
25+impl Store {
26+ /// Mints a reset token for `user_id` and drops the user's unused ones, so only the newest
27+ /// link works. Returns the plaintext token, which only the mail link carries.
28+ pub async fn create_reset_token(&self, user_id: &str) -> Result<String> {
29+ let token = generate_token();
30+ let hash = hash_token(&token);
31+ let (now, expires) = (now_stamp(), stamp_after(RESET_TTL));
32+ let conn = self.inner.conn.lock().await;
33+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
34+ let outcome: Result<()> = async {
35+ tx.execute(
36+ "DELETE FROM password_reset_tokens WHERE user_id = ?1 AND used_at IS NULL",
37+ (user_id.to_owned(),),
38+ )
39+ .await
40+ .map_err(db_err)?;
41+ tx.execute(
42+ "INSERT INTO password_reset_tokens (token_hash, user_id, created_at, expires_at) \
43+ VALUES (?1, ?2, ?3, ?4)",
44+ (hash, user_id.to_owned(), now, expires),
45+ )
46+ .await
47+ .map_err(db_err)?;
48+ Ok(())
49+ }
50+ .await;
51+ finish(tx, outcome).await?;
52+ Ok(token)
53+ }
54+
55+ /// Redeems a reset token. Returns the id of its user, or `None` when the token is unknown,
56+ /// used or expired. The UPDATE decides, so two redemptions of one link cannot both succeed.
57+ pub async fn redeem_reset_token(&self, token: &str) -> Result<Option<String>> {
58+ let hash = hash_token(token);
59+ let now = now_stamp();
60+ let conn = self.inner.conn.lock().await;
61+ let claimed = conn
62+ .execute(
63+ "UPDATE password_reset_tokens SET used_at = ?2 WHERE token_hash = ?1 \
64+ AND used_at IS NULL AND expires_at > ?2",
65+ (hash.clone(), now),
66+ )
67+ .await
68+ .map_err(db_err)?;
69+ if claimed == 0 {
70+ return Ok(None);
71+ }
72+ fetch_opt(
73+ &conn,
74+ "SELECT user_id FROM password_reset_tokens WHERE token_hash = ?1",
75+ (hash,),
76+ |row| col_text(row, 0),
77+ )
78+ .await
79+ }
80+
81+ /// True while a reset token is unused and unexpired. Does not consume it.
82+ pub async fn reset_token_is_live(&self, token: &str) -> Result<bool> {
83+ let conn = self.read_conn().await;
84+ let found = fetch_opt(
85+ &conn,
86+ "SELECT 1 FROM password_reset_tokens WHERE token_hash = ?1 AND used_at IS NULL \
87+ AND expires_at > ?2",
88+ (hash_token(token), now_stamp()),
89+ |row| col_int(row, 0),
90+ )
91+ .await?;
92+ Ok(found.is_some())
93+ }
94+
95+ /// The user whose verified address is `email`, compared without case. Unverified addresses
96+ /// never match, so a reset mail only goes to an address the account has proven it owns.
97+ pub async fn verified_user_by_email(&self, email: &str) -> Result<Option<User>> {
98+ let conn = self.read_conn().await;
99+ fetch_opt(
100+ &conn,
101+ "SELECT id, username, email, password_hash, is_admin, created_at, about, timezone, \
102+ (SELECT hash FROM avatars WHERE avatars.user_id = users.id) AS avatar_hash, \
103+ theme, language, kind, email_notifications FROM users WHERE LOWER(email) = LOWER(?1) \
104+ AND email_verified_at IS NOT NULL AND kind = 'user' LIMIT 1",
105+ (email.to_owned(),),
106+ map_user,
107+ )
108+ .await
109+ }
110+
111+ /// Mints a verification token for `address` of `user_id`. Returns the plaintext token.
112+ pub async fn create_verify_token(&self, user_id: &str, address: &str) -> Result<String> {
113+ let token = generate_token();
114+ let hash = hash_token(&token);
115+ let (now, expires) = (now_stamp(), stamp_after(VERIFY_TTL));
116+ let conn = self.inner.conn.lock().await;
117+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
118+ let outcome: Result<()> = async {
119+ tx.execute(
120+ "DELETE FROM email_verify_tokens WHERE user_id = ?1 AND used_at IS NULL",
121+ (user_id.to_owned(),),
122+ )
123+ .await
124+ .map_err(db_err)?;
125+ tx.execute(
126+ "INSERT INTO email_verify_tokens (token_hash, user_id, email, created_at, expires_at) \
127+ VALUES (?1, ?2, ?3, ?4, ?5)",
128+ (hash, user_id.to_owned(), address.to_owned(), now, expires),
129+ )
130+ .await
131+ .map_err(db_err)?;
132+ Ok(())
133+ }
134+ .await;
135+ finish(tx, outcome).await?;
136+ Ok(token)
137+ }
138+
139+ /// Redeems a verification token and marks the address verified, as long as it is still the
140+ /// user's address. Returns the user id, or `None` when the token is unusable or stale.
141+ pub async fn redeem_verify_token(&self, token: &str) -> Result<Option<String>> {
142+ let hash = hash_token(token);
143+ let now = now_stamp();
144+ let conn = self.inner.conn.lock().await;
145+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
146+ let outcome: Result<Option<String>> = async {
147+ let claimed = tx
148+ .execute(
149+ "UPDATE email_verify_tokens SET used_at = ?2 WHERE token_hash = ?1 \
150+ AND used_at IS NULL AND expires_at > ?2",
151+ (hash.clone(), now.clone()),
152+ )
153+ .await
154+ .map_err(db_err)?;
155+ if claimed == 0 {
156+ return Ok(None);
157+ }
158+ let Some((user_id, address)) = fetch_opt(
159+ &tx,
160+ "SELECT user_id, email FROM email_verify_tokens WHERE token_hash = ?1",
161+ (hash,),
162+ |row| Ok((col_text(row, 0)?, col_text(row, 1)?)),
163+ )
164+ .await?
165+ else {
166+ return Ok(None);
167+ };
168+ let verified = tx
169+ .execute(
170+ "UPDATE users SET email_verified_at = ?3 WHERE id = ?1 AND email = ?2",
171+ (user_id.clone(), address, now),
172+ )
173+ .await
174+ .map_err(db_err)?;
175+ Ok((verified > 0).then_some(user_id))
176+ }
177+ .await;
178+ finish(tx, outcome).await
179+ }
180+
181+ /// Clears the verified mark. Called when the address changes, since the new one needs its
182+ /// own link.
183+ pub async fn clear_email_verified(&self, user_id: &str) -> Result<()> {
184+ let conn = self.inner.conn.lock().await;
185+ conn.execute(
186+ "UPDATE users SET email_verified_at = NULL WHERE id = ?1",
187+ (user_id.to_owned(),),
188+ )
189+ .await
190+ .map_err(db_err)?;
191+ Ok(())
192+ }
193+
194+ /// Whether the user's current address has been verified.
195+ pub async fn email_is_verified(&self, user_id: &str) -> Result<bool> {
196+ let conn = self.read_conn().await;
197+ let stamp = fetch_opt(
198+ &conn,
199+ "SELECT email_verified_at FROM users WHERE id = ?1",
200+ (user_id.to_owned(),),
201+ |row| col_opt_text(row, 0),
202+ )
203+ .await?;
204+ Ok(stamp.flatten().is_some())
205+ }
206+
207+ /// Creates an invite for `address`, dropping earlier unaccepted invites for it. Refused when
208+ /// an account already uses the address. Returns the plaintext token.
209+ pub async fn create_invite(&self, address: &str, invited_by: &str) -> Result<String> {
210+ let token = generate_token();
211+ let hash = hash_token(&token);
212+ let (now, expires) = (now_stamp(), stamp_after(INVITE_TTL));
213+ let conn = self.inner.conn.lock().await;
214+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
215+ let outcome: Result<()> = async {
216+ let taken = fetch_opt(
217+ &tx,
218+ "SELECT COUNT(*) FROM users WHERE LOWER(email) = LOWER(?1)",
219+ (address.to_owned(),),
220+ |row| col_int(row, 0),
221+ )
222+ .await?
223+ .unwrap_or(0);
224+ if taken > 0 {
225+ return Err(Error::Conflict(
226+ "an account already uses that address".into(),
227+ ));
228+ }
229+ tx.execute(
230+ "DELETE FROM invites WHERE LOWER(email) = LOWER(?1) AND accepted_at IS NULL",
231+ (address.to_owned(),),
232+ )
233+ .await
234+ .map_err(db_err)?;
235+ tx.execute(
236+ "INSERT INTO invites (token_hash, email, invited_by, created_at, expires_at) \
237+ VALUES (?1, ?2, ?3, ?4, ?5)",
238+ (
239+ hash,
240+ address.to_owned(),
241+ invited_by.to_owned(),
242+ now,
243+ expires,
244+ ),
245+ )
246+ .await
247+ .map_err(db_err)?;
248+ Ok(())
249+ }
250+ .await;
251+ finish(tx, outcome).await?;
252+ Ok(token)
253+ }
254+
255+ /// The address an invite is for, while the invite is unaccepted and unexpired.
256+ pub async fn invite_email(&self, token: &str) -> Result<Option<String>> {
257+ let conn = self.read_conn().await;
258+ fetch_opt(
259+ &conn,
260+ "SELECT email FROM invites WHERE token_hash = ?1 AND accepted_at IS NULL \
261+ AND expires_at > ?2",
262+ (hash_token(token), now_stamp()),
263+ |row| col_text(row, 0),
264+ )
265+ .await
266+ }
267+
268+ /// Accepts an invite and creates its account in one transaction. The invite's address is
269+ /// verified at creation. Returns `None` when the invite is not usable. A taken username is a
270+ /// `Conflict`, and the invite stays unused.
271+ pub async fn accept_invite(
272+ &self,
273+ token: &str,
274+ username: &str,
275+ password_hash: &str,
276+ ) -> Result<Option<User>> {
277+ let hash = hash_token(token);
278+ let now = now_stamp();
279+ let conn = self.inner.conn.lock().await;
280+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
281+ let outcome: Result<Option<User>> = async {
282+ let Some(address) = fetch_opt(
283+ &tx,
284+ "SELECT email FROM invites WHERE token_hash = ?1 AND accepted_at IS NULL \
285+ AND expires_at > ?2",
286+ (hash.clone(), now.clone()),
287+ |row| col_text(row, 0),
288+ )
289+ .await?
290+ else {
291+ return Ok(None);
292+ };
293+ let user = User {
294+ id: new_id(),
295+ username: username.to_owned(),
296+ email: Some(address.clone()),
297+ password_hash: password_hash.to_owned(),
298+ is_admin: false,
299+ created_at: now.clone(),
300+ about: String::new(),
301+ timezone: "UTC".to_owned(),
302+ avatar_hash: None,
303+ theme: "auto".into(),
304+ language: "auto".into(),
305+ kind: AccountKind::User,
306+ email_notifications: true,
307+ };
308+ tx.execute(
309+ "INSERT INTO users (id, username, username_folded, email, password_hash, is_admin, \
310+ created_at, email_verified_at) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?6)",
311+ (
312+ user.id.clone(),
313+ user.username.clone(),
314+ normalize_name(username),
315+ address,
316+ user.password_hash.clone(),
317+ now.clone(),
318+ ),
319+ )
320+ .await
321+ .map_err(|e| insert_err(e, "username is already taken"))?;
322+ tx.execute(
323+ "UPDATE invites SET accepted_at = ?2, accepted_by = ?3 WHERE token_hash = ?1",
324+ (hash, now, user.id.clone()),
325+ )
326+ .await
327+ .map_err(db_err)?;
328+ Ok(Some(user))
329+ }
330+ .await;
331+ finish(tx, outcome).await
332+ }
333+}
334+
335+#[cfg(test)]
336+mod tests {
337+ use super::*;
338+
339+ async fn store() -> (tempfile::TempDir, Store) {
340+ let dir = tempfile::tempdir().expect("tempdir");
341+ let store = Store::open(&dir.path().join("tokens.db"))
342+ .await
343+ .expect("store");
344+ (dir, store)
345+ }
346+
347+ async fn alice(store: &Store) -> User {
348+ store
349+ .create_user("alice", Some("alice@example.com"), "hash", false)
350+ .await
351+ .expect("user")
352+ }
353+
354+ /// Moves a token's expiry into the past, the way time passing would.
355+ async fn expire(store: &Store, table: &str, token: &str) {
356+ let conn = store.inner.conn.lock().await;
357+ conn.execute(
358+ &format!("UPDATE {table} SET expires_at = ?2 WHERE token_hash = ?1"),
359+ (hash_token(token), "2000-01-01T00:00:00.000000Z".to_owned()),
360+ )
361+ .await
362+ .expect("expire");
363+ }
364+
365+ #[tokio::test]
366+ async fn reset_token_redeems_once() {
367+ let (_dir, store) = store().await;
368+ let user = alice(&store).await;
369+ let token = store.create_reset_token(&user.id).await.unwrap();
370+ assert!(store.reset_token_is_live(&token).await.unwrap());
371+ assert_eq!(
372+ store.redeem_reset_token(&token).await.unwrap(),
373+ Some(user.id.clone())
374+ );
375+ assert_eq!(store.redeem_reset_token(&token).await.unwrap(), None);
376+ assert!(!store.reset_token_is_live(&token).await.unwrap());
377+ }
378+
379+ #[tokio::test]
380+ async fn expired_and_unknown_reset_tokens_are_refused() {
381+ let (_dir, store) = store().await;
382+ let user = alice(&store).await;
383+ let token = store.create_reset_token(&user.id).await.unwrap();
384+ expire(&store, "password_reset_tokens", &token).await;
385+ assert_eq!(store.redeem_reset_token(&token).await.unwrap(), None);
386+ assert_eq!(store.redeem_reset_token("forge_nope").await.unwrap(), None);
387+ }
388+
389+ #[tokio::test]
390+ async fn a_new_reset_token_replaces_the_unused_one() {
391+ let (_dir, store) = store().await;
392+ let user = alice(&store).await;
393+ let first = store.create_reset_token(&user.id).await.unwrap();
394+ let second = store.create_reset_token(&user.id).await.unwrap();
395+ assert_eq!(store.redeem_reset_token(&first).await.unwrap(), None);
396+ assert!(store.redeem_reset_token(&second).await.unwrap().is_some());
397+ }
398+
399+ #[tokio::test]
400+ async fn only_a_verified_address_matches_a_reset_lookup() {
401+ let (_dir, store) = store().await;
402+ let user = alice(&store).await;
403+ assert!(
404+ store
405+ .verified_user_by_email("ALICE@example.com")
406+ .await
407+ .unwrap()
408+ .is_none(),
409+ "unverified address must not match"
410+ );
411+ let token = store
412+ .create_verify_token(&user.id, "alice@example.com")
413+ .await
414+ .unwrap();
415+ assert_eq!(
416+ store.redeem_verify_token(&token).await.unwrap(),
417+ Some(user.id.clone())
418+ );
419+ assert!(store.email_is_verified(&user.id).await.unwrap());
420+ let found = store
421+ .verified_user_by_email("ALICE@example.com")
422+ .await
423+ .unwrap()
424+ .expect("verified user");
425+ assert_eq!(found.id, user.id);
426+ }
427+
428+ #[tokio::test]
429+ async fn verify_token_is_single_use_and_refused_after_the_address_changes() {
430+ let (_dir, store) = store().await;
431+ let user = alice(&store).await;
432+ let token = store
433+ .create_verify_token(&user.id, "alice@example.com")
434+ .await
435+ .unwrap();
436+ store
437+ .set_email(&user.id, "other@example.com")
438+ .await
439+ .unwrap();
440+ assert_eq!(store.redeem_verify_token(&token).await.unwrap(), None);
441+ assert!(!store.email_is_verified(&user.id).await.unwrap());
442+
443+ let fresh = store
444+ .create_verify_token(&user.id, "other@example.com")
445+ .await
446+ .unwrap();
447+ assert!(store.redeem_verify_token(&fresh).await.unwrap().is_some());
448+ assert_eq!(store.redeem_verify_token(&fresh).await.unwrap(), None);
449+ }
450+
451+ #[tokio::test]
452+ async fn clearing_the_mark_unverifies_the_address() {
453+ let (_dir, store) = store().await;
454+ let user = alice(&store).await;
455+ let token = store
456+ .create_verify_token(&user.id, "alice@example.com")
457+ .await
458+ .unwrap();
459+ store.redeem_verify_token(&token).await.unwrap();
460+ store.clear_email_verified(&user.id).await.unwrap();
461+ assert!(!store.email_is_verified(&user.id).await.unwrap());
462+ }
463+
464+ #[tokio::test]
465+ async fn invite_for_a_taken_address_is_refused() {
466+ let (_dir, store) = store().await;
467+ let user = alice(&store).await;
468+ let err = store
469+ .create_invite("ALICE@example.com", &user.id)
470+ .await
471+ .expect_err("taken");
472+ assert!(matches!(err, Error::Conflict(_)));
473+ }
474+
475+ #[tokio::test]
476+ async fn invite_creates_one_verified_account_and_is_single_use() {
477+ let (_dir, store) = store().await;
478+ let admin = alice(&store).await;
479+ let token = store
480+ .create_invite("bob@example.com", &admin.id)
481+ .await
482+ .unwrap();
483+ assert_eq!(
484+ store.invite_email(&token).await.unwrap().as_deref(),
485+ Some("bob@example.com")
486+ );
487+ let bob = store
488+ .accept_invite(&token, "bob", "hash")
489+ .await
490+ .unwrap()
491+ .expect("account");
492+ assert_eq!(bob.email.as_deref(), Some("bob@example.com"));
493+ assert!(store.email_is_verified(&bob.id).await.unwrap());
494+ assert!(store.invite_email(&token).await.unwrap().is_none());
495+ assert!(
496+ store
497+ .accept_invite(&token, "carol", "hash")
498+ .await
499+ .unwrap()
500+ .is_none()
501+ );
502+ }
503+
504+ #[tokio::test]
505+ async fn a_taken_username_leaves_the_invite_unused() {
506+ let (_dir, store) = store().await;
507+ let admin = alice(&store).await;
508+ let token = store
509+ .create_invite("bob@example.com", &admin.id)
510+ .await
511+ .unwrap();
512+ let err = store
513+ .accept_invite(&token, "Alice", "hash")
514+ .await
515+ .expect_err("taken username");
516+ assert!(matches!(err, Error::Conflict(_)));
517+ assert!(store.invite_email(&token).await.unwrap().is_some());
518+ }
519+
520+ #[tokio::test]
521+ async fn expired_invite_is_refused() {
522+ let (_dir, store) = store().await;
523+ let admin = alice(&store).await;
524+ let token = store
525+ .create_invite("bob@example.com", &admin.id)
526+ .await
527+ .unwrap();
528+ expire(&store, "invites", &token).await;
529+ assert!(
530+ store
531+ .accept_invite(&token, "bob", "hash")
532+ .await
533+ .unwrap()
534+ .is_none()
535+ );
536+ }
537+}
crates/forge-core/src/store/advisories.rsadded+788 -0
@@ -0,0 +1,788 @@
1+//! Advisory storage: the OSV rows of each ecosystem, the dependencies read from each repo's
2+//! lockfiles, and the alerts that match them.
3+//!
4+//! An alert is keyed by (repo, advisory, ecosystem, package, version). A scan opens alerts for new
5+//! matches, reopens a `fixed` alert whose match came back, and marks an `open` alert `fixed` when
6+//! its dependency is gone. `dismissed` alerts are left alone by scans.
7+
8+use std::collections::{BTreeSet, HashMap};
9+
10+use turso::{Row, Value};
11+
12+use super::{Store, col_int, col_text, db_err, fetch_all, fetch_opt, finish};
13+use crate::advisories::{Advisory, Dependency, Range, affects};
14+use crate::error::{Error, Result};
15+use crate::time::now_stamp;
16+
17+/// The state of one alert.
18+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19+pub enum AlertState {
20+ Open,
21+ Dismissed,
22+ Fixed,
23+}
24+
25+impl AlertState {
26+ pub fn as_str(self) -> &'static str {
27+ match self {
28+ AlertState::Open => "open",
29+ AlertState::Dismissed => "dismissed",
30+ AlertState::Fixed => "fixed",
31+ }
32+ }
33+
34+ fn parse(raw: &str) -> Result<Self> {
35+ match raw {
36+ "open" => Ok(AlertState::Open),
37+ "dismissed" => Ok(AlertState::Dismissed),
38+ "fixed" => Ok(AlertState::Fixed),
39+ other => Err(Error::Database(format!("unknown alert state {other:?}"))),
40+ }
41+ }
42+}
43+
44+/// The identity of one alert within a repo.
45+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
46+pub struct AlertKey {
47+ pub advisory_id: String,
48+ pub ecosystem: String,
49+ pub package: String,
50+ pub version: String,
51+}
52+
53+/// One alert, with the advisory's summary and severity for display.
54+#[derive(Debug, Clone, PartialEq, Eq)]
55+pub struct Alert {
56+ pub key: AlertKey,
57+ pub state: AlertState,
58+ pub summary: String,
59+ pub severity: String,
60+ pub created_at: String,
61+ pub updated_at: String,
62+}
63+
64+/// One admin's inbox row for a repo: how many alerts a scan opened since the row was last read.
65+#[derive(Debug, Clone, PartialEq, Eq)]
66+pub struct AdvisoryNotice {
67+ pub repo_id: String,
68+ /// The repo's owner and name, for the inbox row and its link.
69+ pub repo_owner: String,
70+ pub repo_name: String,
71+ pub new_alerts: u64,
72+ pub unread: bool,
73+ pub updated_at: String,
74+}
75+
76+/// What a scan changed: alerts opened (new or reopened) and alerts marked fixed.
77+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
78+pub struct ScanOutcome {
79+ pub opened: usize,
80+ pub fixed: usize,
81+}
82+
83+fn json_err(e: serde_json::Error) -> Error {
84+ Error::Database(format!("advisory JSON: {e}"))
85+}
86+
87+fn map_advisory(row: &Row) -> Result<Advisory> {
88+ let ranges_json = col_text(row, 5)?;
89+ let versions_json = col_text(row, 6)?;
90+ Ok(Advisory {
91+ id: col_text(row, 0)?,
92+ ecosystem: col_text(row, 1)?,
93+ package: col_text(row, 2)?,
94+ summary: col_text(row, 3)?,
95+ severity: col_text(row, 4)?,
96+ ranges: serde_json::from_str::<Vec<Range>>(&ranges_json).map_err(json_err)?,
97+ versions: serde_json::from_str::<Vec<String>>(&versions_json).map_err(json_err)?,
98+ modified: col_text(row, 7)?,
99+ aliases: Vec::new(),
100+ })
101+}
102+
103+fn key_of(row: &Row, offset: usize) -> Result<AlertKey> {
104+ Ok(AlertKey {
105+ advisory_id: col_text(row, offset)?,
106+ ecosystem: col_text(row, offset + 1)?,
107+ package: col_text(row, offset + 2)?,
108+ version: col_text(row, offset + 3)?,
109+ })
110+}
111+
112+fn map_alert(row: &Row) -> Result<Alert> {
113+ Ok(Alert {
114+ key: key_of(row, 0)?,
115+ state: AlertState::parse(&col_text(row, 4)?)?,
116+ summary: col_text(row, 5)?,
117+ severity: col_text(row, 6)?,
118+ created_at: col_text(row, 7)?,
119+ updated_at: col_text(row, 8)?,
120+ })
121+}
122+
123+fn key_params(repo_id: &str, key: &AlertKey) -> Vec<Value> {
124+ vec![
125+ Value::Text(repo_id.to_string()),
126+ Value::Text(key.advisory_id.clone()),
127+ Value::Text(key.ecosystem.clone()),
128+ Value::Text(key.package.clone()),
129+ Value::Text(key.version.clone()),
130+ ]
131+}
132+
133+impl Store {
134+ /// Replaces every advisory of `ecosystem` with `advisories`, in one transaction. Rows for other
135+ /// ecosystems are not touched. Callers rescan the repos afterwards.
136+ pub async fn replace_advisories(&self, ecosystem: &str, advisories: &[Advisory]) -> Result<()> {
137+ let conn = self.inner.conn.lock().await;
138+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
139+ let outcome: Result<()> = async {
140+ tx.execute(
141+ "DELETE FROM advisories WHERE ecosystem = ?1",
142+ (ecosystem.to_string(),),
143+ )
144+ .await
145+ .map_err(db_err)?;
146+ for advisory in advisories {
147+ if advisory.ecosystem != ecosystem {
148+ continue;
149+ }
150+ let params = vec![
151+ Value::Text(advisory.id.clone()),
152+ Value::Text(advisory.ecosystem.clone()),
153+ Value::Text(advisory.package.clone()),
154+ Value::Text(advisory.summary.clone()),
155+ Value::Text(advisory.severity.clone()),
156+ Value::Text(serde_json::to_string(&advisory.ranges).map_err(json_err)?),
157+ Value::Text(serde_json::to_string(&advisory.versions).map_err(json_err)?),
158+ Value::Text(advisory.modified.clone()),
159+ ];
160+ tx.execute(
161+ "INSERT OR REPLACE INTO advisories (id, ecosystem, package, summary, severity, \
162+ ranges_json, versions_json, modified) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
163+ params,
164+ )
165+ .await
166+ .map_err(db_err)?;
167+ }
168+ Ok(())
169+ }
170+ .await;
171+ finish(tx, outcome).await
172+ }
173+
174+ /// The advisories that name `package` in `ecosystem`.
175+ pub async fn advisories_for_package(
176+ &self,
177+ ecosystem: &str,
178+ package: &str,
179+ ) -> Result<Vec<Advisory>> {
180+ let conn = self.read_conn().await;
181+ fetch_all(
182+ &conn,
183+ "SELECT id, ecosystem, package, summary, severity, ranges_json, versions_json, modified \
184+ FROM advisories WHERE ecosystem = ?1 AND package = ?2 ORDER BY id",
185+ (ecosystem.to_string(), package.to_string()),
186+ map_advisory,
187+ )
188+ .await
189+ }
190+
191+ /// Every repo id, for a rescan after an advisory refresh.
192+ pub async fn all_repo_ids(&self) -> Result<Vec<String>> {
193+ let conn = self.read_conn().await;
194+ fetch_all(&conn, "SELECT id FROM repos ORDER BY id", (), |row| {
195+ col_text(row, 0)
196+ })
197+ .await
198+ }
199+
200+ /// Records the dependencies of one repo and reconciles its alerts with the advisories that
201+ /// match them. The dependency rows are replaced; the alerts are opened, reopened or marked
202+ /// fixed as described in the module docs.
203+ pub async fn scan_repo(&self, repo_id: &str, deps: &[Dependency]) -> Result<ScanOutcome> {
204+ let unique: BTreeSet<&Dependency> = deps.iter().collect();
205+ // Matches are computed before the write lock, from the read pool.
206+ let mut by_package: HashMap<(String, String), Vec<Advisory>> = HashMap::new();
207+ let mut matched: BTreeSet<AlertKey> = BTreeSet::new();
208+ for dep in &unique {
209+ let pair = (dep.ecosystem.clone(), dep.package.clone());
210+ if !by_package.contains_key(&pair) {
211+ let found = self
212+ .advisories_for_package(&dep.ecosystem, &dep.package)
213+ .await?;
214+ by_package.insert(pair.clone(), found);
215+ }
216+ for advisory in by_package.get(&pair).into_iter().flatten() {
217+ if affects(advisory, &dep.version) {
218+ matched.insert(AlertKey {
219+ advisory_id: advisory.id.clone(),
220+ ecosystem: advisory.ecosystem.clone(),
221+ package: advisory.package.clone(),
222+ version: dep.version.clone(),
223+ });
224+ }
225+ }
226+ }
227+
228+ let conn = self.inner.conn.lock().await;
229+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
230+ let rid = repo_id.to_string();
231+ let outcome: Result<ScanOutcome> = async {
232+ tx.execute(
233+ "DELETE FROM repo_dependencies WHERE repo_id = ?1",
234+ (rid.clone(),),
235+ )
236+ .await
237+ .map_err(db_err)?;
238+ for dep in &unique {
239+ tx.execute(
240+ "INSERT OR IGNORE INTO repo_dependencies (repo_id, ecosystem, package, version, \
241+ manifest_path) VALUES (?1, ?2, ?3, ?4, ?5)",
242+ (
243+ rid.clone(),
244+ dep.ecosystem.clone(),
245+ dep.package.clone(),
246+ dep.version.clone(),
247+ dep.manifest_path.clone(),
248+ ),
249+ )
250+ .await
251+ .map_err(db_err)?;
252+ }
253+
254+ let existing: HashMap<AlertKey, AlertState> = fetch_all(
255+ &tx,
256+ "SELECT advisory_id, ecosystem, package, version, state FROM repo_alerts \
257+ WHERE repo_id = ?1",
258+ (rid.clone(),),
259+ |row| Ok((key_of(row, 0)?, AlertState::parse(&col_text(row, 4)?)?)),
260+ )
261+ .await?
262+ .into_iter()
263+ .collect();
264+
265+ let now = now_stamp();
266+ let mut outcome = ScanOutcome::default();
267+ for key in &matched {
268+ let mut params = key_params(&rid, key);
269+ match existing.get(key) {
270+ None => {
271+ params.extend([
272+ Value::Text(AlertState::Open.as_str().to_string()),
273+ Value::Text(now.clone()),
274+ Value::Text(now.clone()),
275+ ]);
276+ tx.execute(
277+ "INSERT INTO repo_alerts (repo_id, advisory_id, ecosystem, package, \
278+ version, state, created_at, updated_at) \
279+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
280+ params,
281+ )
282+ .await
283+ .map_err(db_err)?;
284+ outcome.opened += 1;
285+ }
286+ Some(AlertState::Fixed) => {
287+ set_state(&tx, &rid, key, AlertState::Open, &now).await?;
288+ outcome.opened += 1;
289+ }
290+ Some(AlertState::Open | AlertState::Dismissed) => {}
291+ }
292+ }
293+ for (key, state) in &existing {
294+ if *state == AlertState::Open && !matched.contains(key) {
295+ set_state(&tx, &rid, key, AlertState::Fixed, &now).await?;
296+ outcome.fixed += 1;
297+ }
298+ }
299+ Ok(outcome)
300+ }
301+ .await;
302+ finish(tx, outcome).await
303+ }
304+
305+ /// Moves one alert from `from` to `to`. Returns false when the alert is not in `from`.
306+ pub async fn set_alert_state(
307+ &self,
308+ repo_id: &str,
309+ key: &AlertKey,
310+ from: AlertState,
311+ to: AlertState,
312+ ) -> Result<bool> {
313+ let conn = self.inner.conn.lock().await;
314+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
315+ let rid = repo_id.to_string();
316+ let now = now_stamp();
317+ let outcome: Result<bool> = async {
318+ let mut params = key_params(&rid, key);
319+ params.extend([
320+ Value::Text(to.as_str().to_string()),
321+ Value::Text(now.clone()),
322+ Value::Text(from.as_str().to_string()),
323+ ]);
324+ let changed = tx
325+ .execute(
326+ "UPDATE repo_alerts SET state = ?6, updated_at = ?7 WHERE repo_id = ?1 AND \
327+ advisory_id = ?2 AND ecosystem = ?3 AND package = ?4 AND version = ?5 AND \
328+ state = ?8",
329+ params,
330+ )
331+ .await
332+ .map_err(db_err)?;
333+ Ok(changed > 0)
334+ }
335+ .await;
336+ finish(tx, outcome).await
337+ }
338+
339+ /// The alerts of a repo, open first, with the advisory's summary and severity.
340+ pub async fn list_alerts(&self, repo_id: &str) -> Result<Vec<Alert>> {
341+ let conn = self.read_conn().await;
342+ fetch_all(
343+ &conn,
344+ "SELECT a.advisory_id, a.ecosystem, a.package, a.version, a.state, \
345+ COALESCE(v.summary, ''), COALESCE(v.severity, ''), a.created_at, a.updated_at \
346+ FROM repo_alerts a \
347+ LEFT JOIN advisories v ON v.id = a.advisory_id AND v.ecosystem = a.ecosystem \
348+ AND v.package = a.package \
349+ WHERE a.repo_id = ?1 \
350+ ORDER BY CASE a.state WHEN 'open' THEN 0 WHEN 'dismissed' THEN 1 ELSE 2 END, \
351+ a.created_at, a.advisory_id, a.package",
352+ (repo_id.to_string(),),
353+ map_alert,
354+ )
355+ .await
356+ }
357+
358+ /// How many dependency rows the last scan of a repo recorded.
359+ pub async fn dependency_count(&self, repo_id: &str) -> Result<u64> {
360+ let conn = self.read_conn().await;
361+ let mut rows = conn
362+ .query(
363+ "SELECT COUNT(*) FROM repo_dependencies WHERE repo_id = ?1",
364+ (repo_id.to_string(),),
365+ )
366+ .await
367+ .map_err(db_err)?;
368+ let row = rows
369+ .next()
370+ .await
371+ .map_err(db_err)?
372+ .ok_or_else(super::missing_row)?;
373+ u64::try_from(col_int(&row, 0)?).map_err(|_| Error::Database("negative count".into()))
374+ }
375+}
376+
377+impl Store {
378+ /// Adds `new_alerts` to the admin's inbox row for `repo_id` and marks it unread. While the row
379+ /// is unread the counts add up; once read, a new count starts it again.
380+ pub async fn notify_new_alerts(
381+ &self,
382+ user_id: &str,
383+ repo_id: &str,
384+ new_alerts: u64,
385+ ) -> Result<()> {
386+ let count = i64::try_from(new_alerts)
387+ .map_err(|_| Error::Database("alert count too large".into()))?;
388+ let conn = self.inner.conn.lock().await;
389+ conn.execute(
390+ "INSERT INTO advisory_notifications (user_id, repo_id, new_alerts, unread, updated_at) \
391+ VALUES (?1, ?2, ?3, 1, ?4) \
392+ ON CONFLICT (user_id, repo_id) DO UPDATE SET \
393+ new_alerts = CASE WHEN advisory_notifications.unread = 1 \
394+ THEN advisory_notifications.new_alerts + excluded.new_alerts \
395+ ELSE excluded.new_alerts END, \
396+ unread = 1, updated_at = excluded.updated_at",
397+ vec![
398+ Value::Text(user_id.to_string()),
399+ Value::Text(repo_id.to_string()),
400+ Value::Integer(count),
401+ Value::Text(now_stamp()),
402+ ],
403+ )
404+ .await
405+ .map_err(db_err)?;
406+ Ok(())
407+ }
408+
409+ /// The user's inbox rows for repos with security alerts, newest first. `unread_only`, `before`
410+ /// and `limit` mean what they mean in `list_notifications`.
411+ pub async fn list_advisory_notifications(
412+ &self,
413+ user_id: &str,
414+ unread_only: bool,
415+ before: Option<&str>,
416+ limit: u32,
417+ ) -> Result<Vec<AdvisoryNotice>> {
418+ let mut sql = format!("{ADVISORY_NOTICE_SELECT} WHERE n.user_id = ?");
419+ let mut params = vec![Value::Text(user_id.to_string())];
420+ if unread_only {
421+ sql.push_str(" AND n.unread = 1");
422+ }
423+ if let Some(stamp) = before {
424+ sql.push_str(" AND n.updated_at < ?");
425+ params.push(Value::Text(stamp.to_string()));
426+ }
427+ sql.push_str(" ORDER BY n.updated_at DESC, n.repo_id DESC LIMIT ?");
428+ params.push(Value::Integer(i64::from(limit.clamp(1, 200))));
429+ let conn = self.read_conn().await;
430+ fetch_all(&conn, &sql, params, map_advisory_notice).await
431+ }
432+
433+ /// The user's inbox row for one repo's alerts, if there is one.
434+ pub async fn advisory_notification(
435+ &self,
436+ user_id: &str,
437+ repo_id: &str,
438+ ) -> Result<Option<AdvisoryNotice>> {
439+ let conn = self.read_conn().await;
440+ fetch_opt(
441+ &conn,
442+ &format!("{ADVISORY_NOTICE_SELECT} WHERE n.user_id = ?1 AND n.repo_id = ?2 LIMIT 1"),
443+ (user_id.to_string(), repo_id.to_string()),
444+ map_advisory_notice,
445+ )
446+ .await
447+ }
448+
449+ /// Marks the user's inbox row for one repo's alerts read. Another user's row changes nothing.
450+ pub async fn mark_advisory_notification_read(
451+ &self,
452+ user_id: &str,
453+ repo_id: &str,
454+ ) -> Result<()> {
455+ let conn = self.inner.conn.lock().await;
456+ conn.execute(
457+ "UPDATE advisory_notifications SET unread = 0 WHERE user_id = ?1 AND repo_id = ?2",
458+ (user_id.to_string(), repo_id.to_string()),
459+ )
460+ .await
461+ .map_err(db_err)?;
462+ Ok(())
463+ }
464+
465+ /// Marks the user's unread alert rows read, in one repo when `repo_id` is given, otherwise all.
466+ pub async fn mark_all_advisory_notifications_read(
467+ &self,
468+ user_id: &str,
469+ repo_id: Option<&str>,
470+ ) -> Result<()> {
471+ let conn = self.inner.conn.lock().await;
472+ let result = match repo_id {
473+ Some(repo) => {
474+ conn.execute(
475+ "UPDATE advisory_notifications SET unread = 0 WHERE user_id = ?1 AND repo_id = ?2 AND unread = 1",
476+ (user_id.to_string(), repo.to_string()),
477+ )
478+ .await
479+ }
480+ None => {
481+ conn.execute(
482+ "UPDATE advisory_notifications SET unread = 0 WHERE user_id = ?1 AND unread = 1",
483+ (user_id.to_string(),),
484+ )
485+ .await
486+ }
487+ };
488+ result.map_err(db_err)?;
489+ Ok(())
490+ }
491+}
492+
493+/// Inbox rows of repos with alerts, joined with the repo and its owner. `?` placeholders are
494+/// appended by the callers.
495+const ADVISORY_NOTICE_SELECT: &str = "SELECT n.repo_id, o.username, r.name, n.new_alerts, \
496+ n.unread, n.updated_at FROM advisory_notifications n \
497+ JOIN repos r ON r.id = n.repo_id \
498+ JOIN users o ON o.id = r.owner_id";
499+
500+fn map_advisory_notice(row: &Row) -> Result<AdvisoryNotice> {
501+ Ok(AdvisoryNotice {
502+ repo_id: col_text(row, 0)?,
503+ repo_owner: col_text(row, 1)?,
504+ repo_name: col_text(row, 2)?,
505+ new_alerts: u64::try_from(col_int(row, 3)?)
506+ .map_err(|_| Error::Database("negative count".into()))?,
507+ unread: col_int(row, 4)? != 0,
508+ updated_at: col_text(row, 5)?,
509+ })
510+}
511+
512+async fn set_state(
513+ tx: &turso::transaction::Transaction<'_>,
514+ repo_id: &str,
515+ key: &AlertKey,
516+ state: AlertState,
517+ now: &str,
518+) -> Result<()> {
519+ let mut params = key_params(repo_id, key);
520+ params.extend([
521+ Value::Text(state.as_str().to_string()),
522+ Value::Text(now.to_string()),
523+ ]);
524+ tx.execute(
525+ "UPDATE repo_alerts SET state = ?6, updated_at = ?7 WHERE repo_id = ?1 AND advisory_id = ?2 \
526+ AND ecosystem = ?3 AND package = ?4 AND version = ?5",
527+ params,
528+ )
529+ .await
530+ .map_err(db_err)?;
531+ Ok(())
532+}
533+
534+#[cfg(test)]
535+mod tests {
536+ use super::*;
537+ use crate::advisories::{Event, Range};
538+ use crate::store::Store;
539+
540+ fn advisory(id: &str, package: &str, fixed: &str) -> Advisory {
541+ Advisory {
542+ id: id.into(),
543+ ecosystem: "crates.io".into(),
544+ package: package.into(),
545+ summary: format!("{id} summary"),
546+ severity: "HIGH".into(),
547+ modified: String::new(),
548+ aliases: Vec::new(),
549+ ranges: vec![Range {
550+ events: vec![Event::Introduced("0".into()), Event::Fixed(fixed.into())],
551+ }],
552+ versions: Vec::new(),
553+ }
554+ }
555+
556+ fn dep(package: &str, version: &str) -> Dependency {
557+ Dependency {
558+ ecosystem: "crates.io".into(),
559+ package: package.into(),
560+ version: version.into(),
561+ manifest_path: "Cargo.lock".into(),
562+ }
563+ }
564+
565+ async fn store_with_repo(dir: &tempfile::TempDir) -> (Store, String) {
566+ let store = Store::open(&dir.path().join("forge.db")).await.unwrap();
567+ let alice = store
568+ .create_user("Alice", None, "hash", false)
569+ .await
570+ .unwrap();
571+ let repo = store
572+ .create_repo(&alice.id, "demo", "", crate::Visibility::Private, "main")
573+ .await
574+ .unwrap();
575+ (store, repo.id)
576+ }
577+
578+ #[tokio::test]
579+ async fn scan_opens_reconciles_and_keeps_dismissed() {
580+ let dir = tempfile::tempdir().unwrap();
581+ let (store, repo) = store_with_repo(&dir).await;
582+ store
583+ .replace_advisories(
584+ "crates.io",
585+ &[
586+ advisory("RUSTSEC-1", "old", "2.0.0"),
587+ advisory("RUSTSEC-2", "other", "2.0.0"),
588+ ],
589+ )
590+ .await
591+ .unwrap();
592+
593+ let first = store
594+ .scan_repo(&repo, &[dep("old", "1.0.0"), dep("other", "1.5.0")])
595+ .await
596+ .unwrap();
597+ assert_eq!(
598+ first,
599+ ScanOutcome {
600+ opened: 2,
601+ fixed: 0
602+ }
603+ );
604+ assert_eq!(store.dependency_count(&repo).await.unwrap(), 2);
605+
606+ // Upgrading `other` past its fix closes its alert. `old` stays vulnerable.
607+ let second = store
608+ .scan_repo(&repo, &[dep("old", "1.0.0"), dep("other", "2.0.0")])
609+ .await
610+ .unwrap();
611+ assert_eq!(
612+ second,
613+ ScanOutcome {
614+ opened: 0,
615+ fixed: 1
616+ }
617+ );
618+ let third = store
619+ .scan_repo(&repo, &[dep("old", "1.0.0")])
620+ .await
621+ .unwrap();
622+ assert_eq!(
623+ third,
624+ ScanOutcome {
625+ opened: 0,
626+ fixed: 0
627+ }
628+ );
629+
630+ let alerts = store.list_alerts(&repo).await.unwrap();
631+ assert_eq!(alerts.len(), 2);
632+ let open = alerts
633+ .iter()
634+ .find(|a| a.key.advisory_id == "RUSTSEC-1")
635+ .unwrap();
636+ assert_eq!(open.state, AlertState::Open);
637+ assert_eq!(open.summary, "RUSTSEC-1 summary");
638+ assert_eq!(open.severity, "HIGH");
639+ let fixed = alerts
640+ .iter()
641+ .find(|a| a.key.advisory_id == "RUSTSEC-2")
642+ .unwrap();
643+ assert_eq!(fixed.state, AlertState::Fixed);
644+
645+ // Dismiss, then a scan keeps it dismissed; a reopen moves it back.
646+ assert!(
647+ store
648+ .set_alert_state(&repo, &open.key, AlertState::Open, AlertState::Dismissed)
649+ .await
650+ .unwrap()
651+ );
652+ assert!(
653+ !store
654+ .set_alert_state(&repo, &open.key, AlertState::Open, AlertState::Dismissed)
655+ .await
656+ .unwrap()
657+ );
658+ let kept = store
659+ .scan_repo(&repo, &[dep("old", "1.0.0")])
660+ .await
661+ .unwrap();
662+ assert_eq!(
663+ kept,
664+ ScanOutcome {
665+ opened: 0,
666+ fixed: 0
667+ }
668+ );
669+ assert!(
670+ store
671+ .set_alert_state(&repo, &open.key, AlertState::Dismissed, AlertState::Open)
672+ .await
673+ .unwrap()
674+ );
675+ }
676+
677+ #[tokio::test]
678+ async fn fixed_alert_reopens_when_the_match_returns() {
679+ let dir = tempfile::tempdir().unwrap();
680+ let (store, repo) = store_with_repo(&dir).await;
681+ store
682+ .replace_advisories("crates.io", &[advisory("RUSTSEC-3", "back", "2.0.0")])
683+ .await
684+ .unwrap();
685+ store
686+ .scan_repo(&repo, &[dep("back", "1.0.0")])
687+ .await
688+ .unwrap();
689+ store
690+ .scan_repo(&repo, &[dep("back", "2.0.0")])
691+ .await
692+ .unwrap();
693+ let again = store
694+ .scan_repo(&repo, &[dep("back", "1.0.1")])
695+ .await
696+ .unwrap();
697+ assert_eq!(
698+ again,
699+ ScanOutcome {
700+ opened: 1,
701+ fixed: 0
702+ }
703+ );
704+ let alerts = store.list_alerts(&repo).await.unwrap();
705+ assert_eq!(alerts.len(), 2);
706+ assert_eq!(alerts[0].state, AlertState::Open);
707+ }
708+
709+ #[tokio::test]
710+ async fn new_alert_notices_add_up_while_unread_and_stay_per_user() {
711+ let dir = tempfile::tempdir().unwrap();
712+ let (store, repo) = store_with_repo(&dir).await;
713+ let bob = store.create_user("Bob", None, "hash", false).await.unwrap();
714+ let owner = store.repo_by_id(&repo).await.unwrap().unwrap().owner_id;
715+ store.notify_new_alerts(&owner, &repo, 2).await.unwrap();
716+ store.notify_new_alerts(&owner, &repo, 3).await.unwrap();
717+ let notices = store
718+ .list_advisory_notifications(&owner, false, None, 50)
719+ .await
720+ .unwrap();
721+ assert_eq!(notices.len(), 1);
722+ assert_eq!(notices[0].repo_id, repo);
723+ assert!(notices[0].repo_owner.eq_ignore_ascii_case("alice"));
724+ assert_eq!(notices[0].repo_name, "demo");
725+ assert_eq!(notices[0].new_alerts, 5);
726+ assert!(notices[0].unread);
727+ assert!(
728+ store
729+ .list_advisory_notifications(&bob.id, false, None, 50)
730+ .await
731+ .unwrap()
732+ .is_empty()
733+ );
734+ }
735+
736+ #[tokio::test]
737+ async fn alert_notices_are_marked_read_by_repo_or_all() {
738+ let dir = tempfile::tempdir().unwrap();
739+ let (store, repo) = store_with_repo(&dir).await;
740+ let owner = store.repo_by_id(&repo).await.unwrap().unwrap().owner_id;
741+ store.notify_new_alerts(&owner, &repo, 1).await.unwrap();
742+ store
743+ .mark_advisory_notification_read(&owner, &repo)
744+ .await
745+ .unwrap();
746+ let read = store.advisory_notification(&owner, &repo).await.unwrap();
747+ assert!(!read.expect("row").unread);
748+ store.notify_new_alerts(&owner, &repo, 4).await.unwrap();
749+ store
750+ .mark_all_advisory_notifications_read(&owner, Some(&repo))
751+ .await
752+ .unwrap();
753+ let unread = store
754+ .list_advisory_notifications(&owner, true, None, 50)
755+ .await
756+ .unwrap();
757+ assert!(unread.is_empty());
758+ }
759+
760+ #[tokio::test]
761+ async fn replacing_advisories_is_scoped_to_one_ecosystem() {
762+ let dir = tempfile::tempdir().unwrap();
763+ let (store, _repo) = store_with_repo(&dir).await;
764+ store
765+ .replace_advisories("crates.io", &[advisory("RUSTSEC-4", "a", "2.0.0")])
766+ .await
767+ .unwrap();
768+ store.replace_advisories("npm", &[]).await.unwrap();
769+ assert_eq!(
770+ store
771+ .advisories_for_package("crates.io", "a")
772+ .await
773+ .unwrap()
774+ .len(),
775+ 1
776+ );
777+ store
778+ .replace_advisories("crates.io", &[advisory("RUSTSEC-5", "a", "3.0.0")])
779+ .await
780+ .unwrap();
781+ let rows = store
782+ .advisories_for_package("crates.io", "a")
783+ .await
784+ .unwrap();
785+ assert_eq!(rows.len(), 1);
786+ assert_eq!(rows[0].id, "RUSTSEC-5");
787+ }
788+}
crates/forge-core/src/store/audit.rsadded+119 -0
@@ -0,0 +1,119 @@
1+//! Audit log storage. Append-only: the only delete is the retention purge.
2+
3+use turso::{Row, Value};
4+
5+use super::{Store, col_opt_text, col_text, db_err, fetch_all};
6+use crate::audit::{AuditAction, AuditCursor, AuditEntry, AuditFilter};
7+use crate::error::{Error, Result};
8+
9+const AUDIT_COLS: &str = "id, at, actor_id, actor_name, action, target_kind, target_id, \
10+ target_name, ip, data";
11+/// Largest page `list_audit` returns, whatever the caller asks for.
12+const MAX_AUDIT_PAGE: u32 = 200;
13+
14+fn map_audit(row: &Row) -> Result<AuditEntry> {
15+ let action = col_text(row, 4)?;
16+ Ok(AuditEntry {
17+ id: col_text(row, 0)?,
18+ at: col_text(row, 1)?,
19+ actor_id: col_opt_text(row, 2)?,
20+ actor_name: col_text(row, 3)?,
21+ action: AuditAction::parse(&action)
22+ .ok_or_else(|| Error::Database(format!("unknown audit action {action:?}")))?,
23+ target_kind: col_text(row, 5)?,
24+ target_id: col_text(row, 6)?,
25+ target_name: col_text(row, 7)?,
26+ ip: col_text(row, 8)?,
27+ data: col_text(row, 9)?,
28+ })
29+}
30+
31+impl Store {
32+ /// Appends one entry. Nothing else in the store writes to `audit_log`.
33+ pub async fn record_audit(&self, entry: &AuditEntry) -> Result<()> {
34+ let actor_id = match &entry.actor_id {
35+ Some(id) => Value::Text(id.clone()),
36+ None => Value::Null,
37+ };
38+ let params = vec![
39+ Value::Text(entry.id.clone()),
40+ Value::Text(entry.at.clone()),
41+ actor_id,
42+ Value::Text(entry.actor_name.clone()),
43+ Value::Text(entry.action.as_str().to_string()),
44+ Value::Text(entry.target_kind.clone()),
45+ Value::Text(entry.target_id.clone()),
46+ Value::Text(entry.target_name.clone()),
47+ Value::Text(entry.ip.clone()),
48+ Value::Text(entry.data.clone()),
49+ ];
50+ let conn = self.inner.conn.lock().await;
51+ conn.execute(
52+ "INSERT INTO audit_log (id, at, actor_id, actor_name, action, target_kind, target_id, \
53+ target_name, ip, data) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
54+ params,
55+ )
56+ .await
57+ .map_err(db_err)?;
58+ Ok(())
59+ }
60+
61+ /// Entries matching `filter`, newest first. `before` continues after the last entry of the
62+ /// previous page. `limit` is clamped to 1..=200.
63+ pub async fn list_audit(
64+ &self,
65+ filter: &AuditFilter,
66+ before: Option<&AuditCursor>,
67+ limit: u32,
68+ ) -> Result<Vec<AuditEntry>> {
69+ let mut clauses: Vec<&str> = Vec::new();
70+ let mut params: Vec<Value> = Vec::new();
71+ if let Some(actor) = &filter.actor_id {
72+ clauses.push("actor_id = ?");
73+ params.push(Value::Text(actor.clone()));
74+ }
75+ if let Some(action) = filter.action {
76+ clauses.push("action = ?");
77+ params.push(Value::Text(action.as_str().to_string()));
78+ }
79+ if let Some(kind) = &filter.target_kind {
80+ clauses.push("target_kind = ?");
81+ params.push(Value::Text(kind.clone()));
82+ }
83+ if let Some(id) = &filter.target_id {
84+ clauses.push("target_id = ?");
85+ params.push(Value::Text(id.clone()));
86+ }
87+ if let Some(user) = &filter.involving_user {
88+ clauses.push("(actor_id = ? OR (target_kind = 'user' AND target_id = ?))");
89+ params.push(Value::Text(user.clone()));
90+ params.push(Value::Text(user.clone()));
91+ }
92+ if let Some(cursor) = before {
93+ clauses.push("(at < ? OR (at = ? AND id < ?))");
94+ params.push(Value::Text(cursor.at.clone()));
95+ params.push(Value::Text(cursor.at.clone()));
96+ params.push(Value::Text(cursor.id.clone()));
97+ }
98+ let where_sql = if clauses.is_empty() {
99+ String::new()
100+ } else {
101+ format!("WHERE {}", clauses.join(" AND "))
102+ };
103+ let sql = format!(
104+ "SELECT {AUDIT_COLS} FROM audit_log {where_sql} ORDER BY at DESC, id DESC LIMIT ?"
105+ );
106+ params.push(Value::Integer(i64::from(limit.clamp(1, MAX_AUDIT_PAGE))));
107+ let conn = self.read_conn().await;
108+ fetch_all(&conn, &sql, params, map_audit).await
109+ }
110+
111+ /// Retention: deletes entries stamped before `cutoff` (an RFC 3339 UTC stamp). Returns the
112+ /// number of entries removed. This is the only delete on the audit log.
113+ pub async fn purge_audit_before(&self, cutoff: &str) -> Result<u64> {
114+ let conn = self.inner.conn.lock().await;
115+ conn.execute("DELETE FROM audit_log WHERE at < ?1", (cutoff.to_string(),))
116+ .await
117+ .map_err(db_err)
118+ }
119+}
crates/forge-core/src/store/backup_runs.rsadded+349 -0
@@ -0,0 +1,349 @@
1+//! Records of backup and fsck runs, shown on `/admin/backups`. The scheduler and the admin
2+//! actions write them; nothing deletes them.
3+
4+use turso::{Row, Value};
5+
6+use super::{Store, col_text, db_err, fetch_all, new_id};
7+use crate::error::{Error, Result};
8+use crate::time::now_stamp;
9+
10+const RUN_COLS: &str = "id, kind, started_at, finished_at, ok, path, bytes, error, \"trigger\"";
11+/// Largest page `recent_runs` returns.
12+const MAX_RUNS: u32 = 200;
13+
14+/// What a run did.
15+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16+pub enum RunKind {
17+ Backup,
18+ Fsck,
19+}
20+
21+impl RunKind {
22+ pub fn as_str(self) -> &'static str {
23+ match self {
24+ RunKind::Backup => "backup",
25+ RunKind::Fsck => "fsck",
26+ }
27+ }
28+
29+ pub fn parse(text: &str) -> Option<RunKind> {
30+ match text {
31+ "backup" => Some(RunKind::Backup),
32+ "fsck" => Some(RunKind::Fsck),
33+ _ => None,
34+ }
35+ }
36+}
37+
38+/// What started a run.
39+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40+pub enum RunTrigger {
41+ Schedule,
42+ Manual,
43+ Cli,
44+}
45+
46+impl RunTrigger {
47+ pub fn as_str(self) -> &'static str {
48+ match self {
49+ RunTrigger::Schedule => "schedule",
50+ RunTrigger::Manual => "manual",
51+ RunTrigger::Cli => "cli",
52+ }
53+ }
54+
55+ pub fn parse(text: &str) -> Option<RunTrigger> {
56+ match text {
57+ "schedule" => Some(RunTrigger::Schedule),
58+ "manual" => Some(RunTrigger::Manual),
59+ "cli" => Some(RunTrigger::Cli),
60+ _ => None,
61+ }
62+ }
63+}
64+
65+/// One row of `backup_runs`. `finished_at` and `ok` are `None` while the run is in progress.
66+#[derive(Debug, Clone, PartialEq, Eq)]
67+pub struct BackupRun {
68+ pub id: String,
69+ pub kind: RunKind,
70+ pub trigger: RunTrigger,
71+ pub started_at: String,
72+ pub finished_at: Option<String>,
73+ pub ok: Option<bool>,
74+ pub path: Option<String>,
75+ pub bytes: Option<u64>,
76+ pub error: Option<String>,
77+}
78+
79+fn opt_int(row: &Row, i: usize) -> Result<Option<i64>> {
80+ match row.get_value(i).map_err(db_err)? {
81+ Value::Integer(n) => Ok(Some(n)),
82+ Value::Null => Ok(None),
83+ _ => Err(Error::Database(format!("column {i} is not an integer"))),
84+ }
85+}
86+
87+fn opt_text(row: &Row, i: usize) -> Result<Option<String>> {
88+ match row.get_value(i).map_err(db_err)? {
89+ Value::Text(s) => Ok(Some(s)),
90+ Value::Null => Ok(None),
91+ _ => Err(Error::Database(format!("column {i} is not text"))),
92+ }
93+}
94+
95+fn map_run(row: &Row) -> Result<BackupRun> {
96+ let kind = col_text(row, 1)?;
97+ let trigger = col_text(row, 8)?;
98+ let bytes = match opt_int(row, 6)? {
99+ Some(n) => Some(u64::try_from(n).map_err(|_| Error::Database("negative run size".into()))?),
100+ None => None,
101+ };
102+ Ok(BackupRun {
103+ id: col_text(row, 0)?,
104+ kind: RunKind::parse(&kind)
105+ .ok_or_else(|| Error::Database(format!("unknown run kind {kind:?}")))?,
106+ trigger: RunTrigger::parse(&trigger)
107+ .ok_or_else(|| Error::Database(format!("unknown run trigger {trigger:?}")))?,
108+ started_at: col_text(row, 2)?,
109+ finished_at: opt_text(row, 3)?,
110+ ok: opt_int(row, 4)?.map(|n| n != 0),
111+ path: opt_text(row, 5)?,
112+ bytes,
113+ error: opt_text(row, 7)?,
114+ })
115+}
116+
117+impl Store {
118+ /// Records the start of a run and returns its id. The row stays unfinished until
119+ /// [`Store::finish_run`] is called.
120+ pub async fn start_run(&self, kind: RunKind, trigger: RunTrigger) -> Result<String> {
121+ let id = new_id();
122+ let conn = self.inner.conn.lock().await;
123+ conn.execute(
124+ "INSERT INTO backup_runs (id, kind, started_at, finished_at, ok, path, bytes, error, \"trigger\") \
125+ VALUES (?1, ?2, ?3, NULL, NULL, NULL, NULL, NULL, ?4)",
126+ vec![
127+ Value::Text(id.clone()),
128+ Value::Text(kind.as_str().to_owned()),
129+ Value::Text(now_stamp()),
130+ Value::Text(trigger.as_str().to_owned()),
131+ ],
132+ )
133+ .await
134+ .map_err(db_err)?;
135+ Ok(id)
136+ }
137+
138+ /// Marks run `id` finished. `path` and `bytes` describe a finished backup set; `error` holds
139+ /// the first failure when `ok` is false.
140+ pub async fn finish_run(
141+ &self,
142+ id: &str,
143+ ok: bool,
144+ path: Option<&str>,
145+ bytes: Option<u64>,
146+ error: Option<&str>,
147+ ) -> Result<()> {
148+ let bytes = match bytes {
149+ Some(n) => Value::Integer(
150+ i64::try_from(n).map_err(|_| Error::Invalid("run size is too large".into()))?,
151+ ),
152+ None => Value::Null,
153+ };
154+ let conn = self.inner.conn.lock().await;
155+ let changed = conn
156+ .execute(
157+ "UPDATE backup_runs SET finished_at = ?2, ok = ?3, path = ?4, bytes = ?5, error = ?6 \
158+ WHERE id = ?1",
159+ vec![
160+ Value::Text(id.to_owned()),
161+ Value::Text(now_stamp()),
162+ Value::Integer(i64::from(ok)),
163+ path.map_or(Value::Null, |p| Value::Text(p.to_owned())),
164+ bytes,
165+ error.map_or(Value::Null, |e| Value::Text(e.to_owned())),
166+ ],
167+ )
168+ .await
169+ .map_err(db_err)?;
170+ if changed == 0 {
171+ return Err(Error::NotFound);
172+ }
173+ Ok(())
174+ }
175+
176+ /// The newest `limit` runs of any kind, newest first. `limit` is clamped to 1..=200.
177+ pub async fn recent_runs(&self, limit: u32) -> Result<Vec<BackupRun>> {
178+ let sql = format!(
179+ "SELECT {RUN_COLS} FROM backup_runs ORDER BY started_at DESC, id DESC LIMIT ?1"
180+ );
181+ let conn = self.read_conn().await;
182+ fetch_all(&conn, &sql, (i64::from(limit.clamp(1, MAX_RUNS)),), map_run).await
183+ }
184+
185+ /// The newest run of `kind` that finished with `ok`, if any.
186+ pub async fn last_ok(&self, kind: RunKind) -> Result<Option<BackupRun>> {
187+ self.latest_run(kind, true).await
188+ }
189+
190+ /// The newest run of `kind`, whatever its outcome.
191+ pub async fn last_run(&self, kind: RunKind) -> Result<Option<BackupRun>> {
192+ self.latest_run(kind, false).await
193+ }
194+
195+ async fn latest_run(&self, kind: RunKind, only_ok: bool) -> Result<Option<BackupRun>> {
196+ let filter = if only_ok { " AND ok = 1" } else { "" };
197+ let sql = format!(
198+ "SELECT {RUN_COLS} FROM backup_runs WHERE kind = ?1{filter} \
199+ ORDER BY started_at DESC, id DESC LIMIT 1"
200+ );
201+ let conn = self.read_conn().await;
202+ let rows = fetch_all(&conn, &sql, (kind.as_str().to_owned(),), map_run).await?;
203+ Ok(rows.into_iter().next())
204+ }
205+
206+ /// Ids of every repo, in id order. The fsck job walks this list.
207+ pub async fn repo_ids(&self) -> Result<Vec<String>> {
208+ let conn = self.read_conn().await;
209+ fetch_all(&conn, "SELECT id FROM repos ORDER BY id", (), |row| {
210+ col_text(row, 0)
211+ })
212+ .await
213+ }
214+}
215+
216+#[cfg(test)]
217+mod tests {
218+ use super::*;
219+
220+ async fn store() -> (tempfile::TempDir, Store) {
221+ let dir = tempfile::tempdir().expect("tempdir");
222+ let store = Store::open(&dir.path().join("forge.db"))
223+ .await
224+ .expect("open");
225+ (dir, store)
226+ }
227+
228+ #[tokio::test]
229+ async fn start_and_finish_round_trip() {
230+ let (_dir, store) = store().await;
231+ let id = store
232+ .start_run(RunKind::Backup, RunTrigger::Manual)
233+ .await
234+ .expect("start");
235+ let open = store.recent_runs(20).await.expect("recent");
236+ assert_eq!(open.len(), 1);
237+ assert_eq!(open[0].id, id);
238+ assert_eq!(open[0].ok, None, "an unfinished run has no outcome");
239+ assert_eq!(open[0].finished_at, None);
240+
241+ store
242+ .finish_run(&id, true, Some("/backups/forge-backup-x"), Some(4096), None)
243+ .await
244+ .expect("finish");
245+ let done = store.recent_runs(20).await.expect("recent");
246+ assert_eq!(done[0].ok, Some(true));
247+ assert_eq!(done[0].path.as_deref(), Some("/backups/forge-backup-x"));
248+ assert_eq!(done[0].bytes, Some(4096));
249+ assert!(done[0].finished_at.is_some());
250+ assert_eq!(done[0].trigger, RunTrigger::Manual);
251+
252+ assert!(matches!(
253+ store.finish_run("nope", true, None, None, None).await,
254+ Err(Error::NotFound)
255+ ));
256+ }
257+
258+ #[tokio::test]
259+ async fn last_ok_skips_failures_and_kinds() {
260+ let (_dir, store) = store().await;
261+ assert!(store.last_ok(RunKind::Backup).await.expect("q").is_none());
262+
263+ let good = store
264+ .start_run(RunKind::Backup, RunTrigger::Schedule)
265+ .await
266+ .expect("start");
267+ store
268+ .finish_run(&good, true, None, None, None)
269+ .await
270+ .expect("finish");
271+ let bad = store
272+ .start_run(RunKind::Backup, RunTrigger::Schedule)
273+ .await
274+ .expect("start");
275+ store
276+ .finish_run(&bad, false, None, None, Some("disk full"))
277+ .await
278+ .expect("finish");
279+ let fsck = store
280+ .start_run(RunKind::Fsck, RunTrigger::Cli)
281+ .await
282+ .expect("start");
283+ store
284+ .finish_run(&fsck, true, None, None, None)
285+ .await
286+ .expect("finish");
287+
288+ let ok = store
289+ .last_ok(RunKind::Backup)
290+ .await
291+ .expect("q")
292+ .expect("some");
293+ assert_eq!(ok.id, good);
294+ let last = store
295+ .last_run(RunKind::Backup)
296+ .await
297+ .expect("q")
298+ .expect("some");
299+ assert_eq!(last.id, bad);
300+ assert_eq!(last.error.as_deref(), Some("disk full"));
301+ let fsck_ok = store
302+ .last_ok(RunKind::Fsck)
303+ .await
304+ .expect("q")
305+ .expect("some");
306+ assert_eq!(fsck_ok.kind, RunKind::Fsck);
307+ assert_eq!(fsck_ok.trigger, RunTrigger::Cli);
308+ }
309+
310+ #[tokio::test]
311+ async fn recent_runs_is_newest_first_and_limited() {
312+ let (_dir, store) = store().await;
313+ let mut ids = Vec::new();
314+ for _ in 0..3 {
315+ ids.push(
316+ store
317+ .start_run(RunKind::Backup, RunTrigger::Manual)
318+ .await
319+ .expect("start"),
320+ );
321+ }
322+ let runs = store.recent_runs(2).await.expect("recent");
323+ assert_eq!(runs.len(), 2);
324+ assert_eq!(runs[0].id, ids[2]);
325+ assert_eq!(runs[1].id, ids[1]);
326+ assert_eq!(store.recent_runs(0).await.expect("recent").len(), 1);
327+ }
328+
329+ #[tokio::test]
330+ async fn repo_ids_lists_every_repo() {
331+ let (_dir, store) = store().await;
332+ assert!(store.repo_ids().await.expect("ids").is_empty());
333+ let user = store
334+ .create_user("alice", None, "hash", false)
335+ .await
336+ .expect("user");
337+ let repo = store
338+ .create_repo(
339+ &user.id,
340+ "demo",
341+ "",
342+ crate::model::Visibility::Private,
343+ "main",
344+ )
345+ .await
346+ .expect("repo");
347+ assert_eq!(store.repo_ids().await.expect("ids"), vec![repo.id]);
348+ }
349+}
crates/forge-core/src/store/ci.rsadded+2013 -0

Diff too large; view file

@@ -0,0 +1,2013 @@
1+//! CI store: runners, registration tokens, runs, jobs, artifacts and sealed repo secrets.
2+//!
3+//! Pure SELECTs run on `read_conn()`. Writes, and anything that reads then writes, run on the
4+//! writer. Multi-step changes (create, claim, finish, cancel, reap, purge) run in one transaction.
5+//! The `needs` and `runs_on` arrays are read out of the JobSpec JSON in `spec_json`.
6+
7+use std::collections::BTreeMap;
8+use std::time::Duration;
9+
10+use turso::{Connection, Row, Value};
11+
12+use super::{
13+ Store, col_blob, col_bool, col_int, col_opt_text, col_text, db_err, fetch_all, fetch_opt,
14+ finish, insert_err, missing_row, new_id,
15+};
16+use crate::ci::{CiArtifact, CiJob, CiRun, CiRunner, JobState, NewJob, RunState};
17+use crate::error::{Error, Result};
18+use crate::secret;
19+use crate::time::{now_stamp, stamp_after};
20+use crate::token::{generate_token, hash_token};
21+
22+const JOB_COLS: &str = "id, run_id, name, spec, state, runner_id, exit_code, message, queued_at, \
23+ started_at, finished_at, heartbeat_at, cancel_requested, log_bytes";
24+const RUN_COLS: &str = "id, repo_id, number, trigger, git_ref, commit_oid, pull_number, actor_id, \
25+ state, config_error, created_at, finished_at";
26+const RUNNER_COLS: &str = "id, name, labels, created_at, last_seen_at, disabled";
27+const ARTIFACT_COLS: &str = "id, job_id, name, size, sha256, created_at";
28+
29+/// Longest runner name, in bytes.
30+const MAX_RUNNER_NAME: usize = 100;
31+/// Longest runner label, in bytes.
32+const MAX_LABEL: usize = 64;
33+/// Longest page `list_runs` returns.
34+const MAX_PER_PAGE: u32 = 100;
35+
36+fn text(s: &str) -> Value {
37+ Value::Text(s.to_string())
38+}
39+
40+fn opt_text(v: Option<&str>) -> Value {
41+ v.map_or(Value::Null, text)
42+}
43+
44+fn opt_int_value(v: Option<i64>) -> Value {
45+ v.map_or(Value::Null, Value::Integer)
46+}
47+
48+/// An integer column that may be NULL.
49+fn opt_int(row: &Row, i: usize) -> Result<Option<i64>> {
50+ match row.get_value(i).map_err(db_err)? {
51+ Value::Integer(n) => Ok(Some(n)),
52+ Value::Null => Ok(None),
53+ _ => Err(Error::Database(format!("column {i} is not an integer"))),
54+ }
55+}
56+
57+fn map_runner(row: &Row) -> Result<CiRunner> {
58+ let labels = serde_json::from_str::<Vec<String>>(&col_text(row, 2)?)
59+ .map_err(|e| Error::Database(format!("runner labels are not a string list: {e}")))?;
60+ Ok(CiRunner {
61+ id: col_text(row, 0)?,
62+ name: col_text(row, 1)?,
63+ labels,
64+ created_at: col_text(row, 3)?,
65+ last_seen_at: col_opt_text(row, 4)?,
66+ disabled: col_bool(row, 5)?,
67+ })
68+}
69+
70+fn map_run(row: &Row) -> Result<CiRun> {
71+ Ok(CiRun {
72+ id: col_text(row, 0)?,
73+ repo_id: col_text(row, 1)?,
74+ number: col_int(row, 2)?,
75+ trigger: col_text(row, 3)?,
76+ git_ref: col_text(row, 4)?,
77+ commit_oid: col_text(row, 5)?,
78+ pull_number: opt_int(row, 6)?,
79+ actor_id: col_text(row, 7)?,
80+ state: col_text(row, 8)?.parse::<RunState>()?,
81+ config_error: col_opt_text(row, 9)?,
82+ created_at: col_text(row, 10)?,
83+ finished_at: col_opt_text(row, 11)?,
84+ })
85+}
86+
87+fn map_job(row: &Row) -> Result<CiJob> {
88+ Ok(CiJob {
89+ id: col_text(row, 0)?,
90+ run_id: col_text(row, 1)?,
91+ name: col_text(row, 2)?,
92+ spec_json: col_text(row, 3)?,
93+ state: col_text(row, 4)?.parse::<JobState>()?,
94+ runner_id: col_opt_text(row, 5)?,
95+ exit_code: opt_int(row, 6)?,
96+ message: col_opt_text(row, 7)?,
97+ queued_at: col_opt_text(row, 8)?,
98+ started_at: col_opt_text(row, 9)?,
99+ finished_at: col_opt_text(row, 10)?,
100+ heartbeat_at: col_opt_text(row, 11)?,
101+ cancel_requested: col_bool(row, 12)?,
102+ log_bytes: col_int(row, 13)?,
103+ })
104+}
105+
106+fn map_artifact(row: &Row) -> Result<CiArtifact> {
107+ Ok(CiArtifact {
108+ id: col_text(row, 0)?,
109+ job_id: col_text(row, 1)?,
110+ name: col_text(row, 2)?,
111+ size: col_int(row, 3)?,
112+ sha256: col_text(row, 4)?,
113+ created_at: col_text(row, 5)?,
114+ })
115+}
116+
117+/// The `needs` array of a JobSpec. Missing or malformed input gives no needs.
118+fn spec_needs(spec: &str) -> Vec<String> {
119+ serde_json::from_str::<serde_json::Value>(spec)
120+ .ok()
121+ .and_then(|v| {
122+ v.get("needs")
123+ .and_then(serde_json::Value::as_array)
124+ .map(|items| {
125+ items
126+ .iter()
127+ .filter_map(|n| n.as_str().map(str::to_owned))
128+ .collect()
129+ })
130+ })
131+ .unwrap_or_default()
132+}
133+
134+/// True when every label in the spec's `runs_on` is one of the runner's labels. A missing
135+/// `runs_on` means no requirement. A malformed spec is never claimable.
136+fn runs_on_covered(spec: &str, labels: &[String]) -> bool {
137+ let Ok(value) = serde_json::from_str::<serde_json::Value>(spec) else {
138+ return false;
139+ };
140+ match value.get("runs_on") {
141+ None | Some(serde_json::Value::Null) => true,
142+ Some(serde_json::Value::Array(items)) => items.iter().all(|item| {
143+ item.as_str()
144+ .is_some_and(|want| labels.iter().any(|have| have == want))
145+ }),
146+ Some(_) => false,
147+ }
148+}
149+
150+/// Secret names follow the JobSpec rule: `^[A-Z_][A-Z0-9_]{0,63}$`.
151+fn validate_secret_name(name: &str) -> Result<()> {
152+ let first_ok = name
153+ .bytes()
154+ .next()
155+ .is_some_and(|b| b.is_ascii_uppercase() || b == b'_');
156+ let rest_ok = name
157+ .bytes()
158+ .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_');
159+ if first_ok && rest_ok && name.len() <= 64 {
160+ Ok(())
161+ } else {
162+ Err(Error::Invalid(format!(
163+ "secret name {name:?} must match [A-Z_][A-Z0-9_]{{0,63}}"
164+ )))
165+ }
166+}
167+
168+async fn load_job(conn: &Connection, id: &str) -> Result<Option<CiJob>> {
169+ fetch_opt(
170+ conn,
171+ &format!("SELECT {JOB_COLS} FROM ci_jobs WHERE id = ?1"),
172+ (id.to_string(),),
173+ map_job,
174+ )
175+ .await
176+}
177+
178+async fn jobs_of_run(conn: &Connection, run_id: &str) -> Result<Vec<CiJob>> {
179+ fetch_all(
180+ conn,
181+ &format!("SELECT {JOB_COLS} FROM ci_jobs WHERE run_id = ?1 ORDER BY name"),
182+ (run_id.to_string(),),
183+ map_job,
184+ )
185+ .await
186+}
187+
188+async fn load_run(conn: &Connection, id: &str) -> Result<Option<CiRun>> {
189+ fetch_opt(
190+ conn,
191+ &format!("SELECT {RUN_COLS} FROM ci_runs WHERE id = ?1"),
192+ (id.to_string(),),
193+ map_run,
194+ )
195+ .await
196+}
197+
198+async fn load_runner(conn: &Connection, id: &str) -> Result<Option<CiRunner>> {
199+ fetch_opt(
200+ conn,
201+ &format!("SELECT {RUNNER_COLS} FROM ci_runners WHERE id = ?1"),
202+ (id.to_string(),),
203+ map_runner,
204+ )
205+ .await
206+}
207+
208+/// Moves blocked jobs on. A blocked job whose needs all succeeded becomes `Queued`. One whose
209+/// need ended without success, or is missing, becomes `Skipped`. Repeats until nothing changes,
210+/// so skips cascade to every dependant.
211+async fn propagate(conn: &Connection, run_id: &str, now: &str) -> Result<()> {
212+ let jobs = jobs_of_run(conn, run_id).await?;
213+ let needs: Vec<Vec<String>> = jobs.iter().map(|j| spec_needs(&j.spec_json)).collect();
214+ let mut states: Vec<JobState> = jobs.iter().map(|j| j.state).collect();
215+ loop {
216+ let mut changed = false;
217+ for i in 0..jobs.len() {
218+ if states[i] != JobState::Blocked {
219+ continue;
220+ }
221+ let mut all_success = true;
222+ let mut dead = false;
223+ for need in &needs[i] {
224+ match jobs.iter().position(|j| &j.name == need).map(|k| states[k]) {
225+ Some(JobState::Success) => {}
226+ Some(s) if !s.is_terminal() => all_success = false,
227+ _ => dead = true,
228+ }
229+ }
230+ let next = if dead {
231+ JobState::Skipped
232+ } else if all_success {
233+ JobState::Queued
234+ } else {
235+ continue;
236+ };
237+ let n = if next == JobState::Queued {
238+ conn.execute(
239+ "UPDATE ci_jobs SET state = 'queued', queued_at = ?2 \
240+ WHERE id = ?1 AND state = 'blocked'",
241+ (jobs[i].id.clone(), now.to_string()),
242+ )
243+ .await
244+ } else {
245+ conn.execute(
246+ "UPDATE ci_jobs SET state = 'skipped', finished_at = ?2 \
247+ WHERE id = ?1 AND state = 'blocked'",
248+ (jobs[i].id.clone(), now.to_string()),
249+ )
250+ .await
251+ }
252+ .map_err(db_err)?;
253+ if n == 1 {
254+ states[i] = next;
255+ changed = true;
256+ }
257+ }
258+ if !changed {
259+ return Ok(());
260+ }
261+ }
262+}
263+
264+/// Recomputes a live run's state from its jobs: any running, queued or blocked job makes it
265+/// `Running`; otherwise `Failure` if any job failed or errored, `Cancelled` if any was cancelled,
266+/// else `Success`. Terminal runs and config-error runs are left alone.
267+async fn recompute_run(conn: &Connection, run_id: &str, now: &str) -> Result<()> {
268+ let run = load_run(conn, run_id).await?.ok_or(Error::NotFound)?;
269+ if run.state.is_terminal() || run.config_error.is_some() {
270+ return Ok(());
271+ }
272+ let states: Vec<JobState> = fetch_all(
273+ conn,
274+ "SELECT state FROM ci_jobs WHERE run_id = ?1",
275+ (run_id.to_string(),),
276+ |row| col_text(row, 0)?.parse::<JobState>(),
277+ )
278+ .await?;
279+ let active =
280+ |s: &JobState| matches!(s, JobState::Running | JobState::Queued | JobState::Blocked);
281+ let next = if states.iter().any(active) {
282+ RunState::Running
283+ } else if states
284+ .iter()
285+ .any(|s| matches!(s, JobState::Failure | JobState::Error))
286+ {
287+ RunState::Failure
288+ } else if states.contains(&JobState::Cancelled) {
289+ RunState::Cancelled
290+ } else {
291+ RunState::Success
292+ };
293+ let finished = next.is_terminal().then(|| now.to_string());
294+ conn.execute(
295+ "UPDATE ci_runs SET state = ?2, finished_at = ?3 WHERE id = ?1",
296+ vec![
297+ text(run_id),
298+ text(next.as_str()),
299+ opt_text(finished.as_deref()),
300+ ],
301+ )
302+ .await
303+ .map_err(db_err)?;
304+ Ok(())
305+}
306+
307+impl Store {
308+ fn seal_ci_secret(&self, plain: &[u8]) -> Result<Vec<u8>> {
309+ let key = self
310+ .inner
311+ .secret_key
312+ .get()
313+ .ok_or_else(|| Error::Crypto("instance secret key is not set".into()))?;
314+ Ok(secret::seal(key, plain)?.into_bytes())
315+ }
316+
317+ /// Creates a single-use registration token valid for `ttl_hours`. Returns the plaintext,
318+ /// which is shown once. Only its sha256 is stored.
319+ pub async fn create_registration_token(
320+ &self,
321+ created_by: &str,
322+ ttl_hours: u32,
323+ ) -> Result<String> {
324+ if ttl_hours == 0 {
325+ return Err(Error::Invalid(
326+ "a registration token needs at least one hour of life".into(),
327+ ));
328+ }
329+ let token = generate_token();
330+ let now = now_stamp();
331+ let expires = stamp_after(Duration::from_secs(u64::from(ttl_hours) * 3600));
332+ let conn = self.inner.conn.lock().await;
333+ conn.execute(
334+ "INSERT INTO ci_registration_tokens (token_hash, created_by, created_at, expires_at) \
335+ VALUES (?1, ?2, ?3, ?4)",
336+ vec![
337+ text(&hash_token(&token)),
338+ text(created_by),
339+ text(&now),
340+ text(&expires),
341+ ],
342+ )
343+ .await
344+ .map_err(|e| insert_err(e, "registration token collision"))?;
345+ Ok(token)
346+ }
347+
348+ /// Consumes a registration token and creates a runner. Returns the runner and its secret,
349+ /// which is shown once. `Invalid` when the token is unknown, used or expired.
350+ pub async fn register_runner(
351+ &self,
352+ token_plain: &str,
353+ name: &str,
354+ labels: &[String],
355+ ) -> Result<(CiRunner, String)> {
356+ let name = name.trim();
357+ if name.is_empty() || name.len() > MAX_RUNNER_NAME {
358+ return Err(Error::Invalid(format!(
359+ "a runner name needs 1 to {MAX_RUNNER_NAME} bytes"
360+ )));
361+ }
362+ if labels.iter().any(|l| l.is_empty() || l.len() > MAX_LABEL) {
363+ return Err(Error::Invalid(format!(
364+ "each runner label needs 1 to {MAX_LABEL} bytes"
365+ )));
366+ }
367+ let labels_json =
368+ serde_json::to_string(labels).map_err(|e| Error::Database(e.to_string()))?;
369+ let secret = generate_token();
370+ let secret_hash = hash_token(&secret);
371+ let token_hash = hash_token(token_plain);
372+ let id = new_id();
373+ let now = now_stamp();
374+
375+ let conn = self.inner.conn.lock().await;
376+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
377+ let outcome: Result<CiRunner> = async {
378+ let used = tx
379+ .execute(
380+ "UPDATE ci_registration_tokens SET used_at = ?2 \
381+ WHERE token_hash = ?1 AND used_at IS NULL AND expires_at > ?3",
382+ vec![text(&token_hash), text(&now), text(&now)],
383+ )
384+ .await
385+ .map_err(db_err)?;
386+ if used != 1 {
387+ return Err(Error::Invalid(
388+ "the registration token is invalid, used or expired".into(),
389+ ));
390+ }
391+ tx.execute(
392+ "INSERT INTO ci_runners (id, name, labels, secret_hash, created_at, disabled) \
393+ VALUES (?1, ?2, ?3, ?4, ?5, 0)",
394+ vec![
395+ text(&id),
396+ text(name),
397+ text(&labels_json),
398+ text(&secret_hash),
399+ text(&now),
400+ ],
401+ )
402+ .await
403+ .map_err(|e| insert_err(e, "runner secret collision"))?;
404+ load_runner(&tx, &id).await?.ok_or_else(missing_row)
405+ }
406+ .await;
407+ let runner = finish(tx, outcome).await?;
408+ Ok((runner, secret))
409+ }
410+
411+ /// The runner with this secret, or `None`. Updates its `last_seen_at`. A disabled runner is
412+ /// returned with `disabled: true`; the caller decides what to do with it.
413+ pub async fn runner_by_secret(&self, secret_plain: &str) -> Result<Option<CiRunner>> {
414+ let hash = hash_token(secret_plain);
415+ let now = now_stamp();
416+ let conn = self.inner.conn.lock().await;
417+ let found = fetch_opt(
418+ &conn,
419+ &format!("SELECT {RUNNER_COLS} FROM ci_runners WHERE secret_hash = ?1"),
420+ (hash,),
421+ map_runner,
422+ )
423+ .await?;
424+ let Some(runner) = found else {
425+ return Ok(None);
426+ };
427+ conn.execute(
428+ "UPDATE ci_runners SET last_seen_at = ?2 WHERE id = ?1",
429+ (runner.id.clone(), now.clone()),
430+ )
431+ .await
432+ .map_err(db_err)?;
433+ Ok(Some(CiRunner {
434+ last_seen_at: Some(now),
435+ ..runner
436+ }))
437+ }
438+
439+ /// All runners, by name.
440+ pub async fn list_runners(&self) -> Result<Vec<CiRunner>> {
441+ let conn = self.read_conn().await;
442+ fetch_all(
443+ &conn,
444+ &format!("SELECT {RUNNER_COLS} FROM ci_runners ORDER BY name, id"),
445+ (),
446+ map_runner,
447+ )
448+ .await
449+ }
450+
451+ /// Disabled runners get no jobs. `NotFound` if the runner does not exist.
452+ pub async fn set_runner_disabled(&self, id: &str, disabled: bool) -> Result<()> {
453+ let conn = self.inner.conn.lock().await;
454+ let n = conn
455+ .execute(
456+ "UPDATE ci_runners SET disabled = ?2 WHERE id = ?1",
457+ (id.to_string(), i64::from(disabled)),
458+ )
459+ .await
460+ .map_err(db_err)?;
461+ if n == 0 {
462+ return Err(Error::NotFound);
463+ }
464+ Ok(())
465+ }
466+
467+ /// Removes a runner. `NotFound` if it does not exist.
468+ pub async fn delete_runner(&self, id: &str) -> Result<()> {
469+ let conn = self.inner.conn.lock().await;
470+ let n = conn
471+ .execute("DELETE FROM ci_runners WHERE id = ?1", (id.to_string(),))
472+ .await
473+ .map_err(db_err)?;
474+ if n == 0 {
475+ return Err(Error::NotFound);
476+ }
477+ Ok(())
478+ }
479+
480+ /// Creates a run with the next number for the repo, and its jobs, in one transaction.
481+ ///
482+ /// Initial states: a config error gives an `Error` run with no jobs. A run with no jobs
483+ /// succeeds at once. With `needs_approval` the run waits and its need-less jobs are
484+ /// `Blocked` until `approve_run`. Otherwise need-less jobs are `Queued` and the run is
485+ /// `Queued`. Jobs with needs are always `Blocked` at first.
486+ #[allow(clippy::too_many_arguments)]
487+ pub async fn create_run(
488+ &self,
489+ repo_id: &str,
490+ trigger: &str,
491+ git_ref: &str,
492+ commit_oid: &str,
493+ pull_number: Option<i64>,
494+ actor_id: &str,
495+ needs_approval: bool,
496+ jobs: &[NewJob<'_>],
497+ config_error: Option<&str>,
498+ ) -> Result<CiRun> {
499+ let id = new_id();
500+ let now = now_stamp();
501+ let conn = self.inner.conn.lock().await;
502+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
503+ let outcome: Result<CiRun> = async {
504+ let number = fetch_opt(
505+ &tx,
506+ "SELECT COALESCE(MAX(number), 0) + 1 FROM ci_runs WHERE repo_id = ?1",
507+ (repo_id.to_string(),),
508+ |row| col_int(row, 0),
509+ )
510+ .await?
511+ .ok_or_else(missing_row)?;
512+ let (state, finished_at) = if config_error.is_some() {
513+ (RunState::Error, Some(now.as_str()))
514+ } else if jobs.is_empty() {
515+ (RunState::Success, Some(now.as_str()))
516+ } else if needs_approval {
517+ (RunState::WaitingApproval, None)
518+ } else {
519+ (RunState::Queued, None)
520+ };
521+ tx.execute(
522+ "INSERT INTO ci_runs (id, repo_id, number, trigger, git_ref, commit_oid, \
523+ pull_number, actor_id, state, config_error, created_at, finished_at) \
524+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
525+ vec![
526+ text(&id),
527+ text(repo_id),
528+ Value::Integer(number),
529+ text(trigger),
530+ text(git_ref),
531+ text(commit_oid),
532+ opt_int_value(pull_number),
533+ text(actor_id),
534+ text(state.as_str()),
535+ opt_text(config_error),
536+ text(&now),
537+ opt_text(finished_at),
538+ ],
539+ )
540+ .await
541+ .map_err(db_err)?;
542+
543+ if config_error.is_none() {
544+ for job in jobs {
545+ let ready = job.needs.is_empty() && !needs_approval;
546+ let (job_state, queued_at) = if ready {
547+ (JobState::Queued, Some(now.as_str()))
548+ } else {
549+ (JobState::Blocked, None)
550+ };
551+ tx.execute(
552+ "INSERT INTO ci_jobs (id, run_id, name, spec, state, queued_at, \
553+ cancel_requested, log_bytes) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, 0)",
554+ vec![
555+ text(&new_id()),
556+ text(&id),
557+ text(job.name),
558+ text(job.spec_json),
559+ text(job_state.as_str()),
560+ opt_text(queued_at),
561+ ],
562+ )
563+ .await
564+ .map_err(|e| insert_err(e, "duplicate job name in this run"))?;
565+ }
566+ }
567+ load_run(&tx, &id).await?.ok_or_else(missing_row)
568+ }
569+ .await;
570+ finish(tx, outcome).await
571+ }
572+
573+ /// `WaitingApproval` -> `Queued`. Need-less jobs become `Queued`. `Conflict` when the run is
574+ /// not waiting for approval.
575+ pub async fn approve_run(&self, run_id: &str) -> Result<()> {
576+ let now = now_stamp();
577+ let conn = self.inner.conn.lock().await;
578+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
579+ let outcome: Result<()> = async {
580+ let run = load_run(&tx, run_id).await?.ok_or(Error::NotFound)?;
581+ if run.state != RunState::WaitingApproval {
582+ return Err(Error::Conflict(
583+ "the run is not waiting for approval".into(),
584+ ));
585+ }
586+ tx.execute(
587+ "UPDATE ci_runs SET state = 'queued' WHERE id = ?1 AND state = 'waiting_approval'",
588+ (run_id.to_string(),),
589+ )
590+ .await
591+ .map_err(db_err)?;
592+ propagate(&tx, run_id, &now).await
593+ }
594+ .await;
595+ finish(tx, outcome).await
596+ }
597+
598+ /// Claims the oldest queued job whose `runs_on` the runner's labels cover. The claim is an
599+ /// UPDATE guarded by `state = 'queued'`, so only one claimer can win a job. Returns the job,
600+ /// its run and the plaintext job token, which is shown once. A disabled runner gets nothing.
601+ pub async fn claim_job(&self, runner: &CiRunner) -> Result<Option<(CiJob, CiRun, String)>> {
602+ if runner.disabled {
603+ return Ok(None);
604+ }
605+ let now = now_stamp();
606+ let token = generate_token();
607+ let token_hash = hash_token(&token);
608+ let conn = self.inner.conn.lock().await;
609+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
610+ let outcome: Result<Option<(CiJob, CiRun)>> = async {
611+ let queued: Vec<(String, String)> = fetch_all(
612+ &tx,
613+ "SELECT id, spec FROM ci_jobs WHERE state = 'queued' ORDER BY queued_at, id",
614+ (),
615+ |row| Ok((col_text(row, 0)?, col_text(row, 1)?)),
616+ )
617+ .await?;
618+ for (job_id, spec) in queued {
619+ if !runs_on_covered(&spec, &runner.labels) {
620+ continue;
621+ }
622+ let won = tx
623+ .execute(
624+ "UPDATE ci_jobs SET state = 'running', runner_id = ?2, job_token_hash = ?3, \
625+ started_at = ?4, heartbeat_at = ?4 WHERE id = ?1 AND state = 'queued'",
626+ vec![
627+ text(&job_id),
628+ text(&runner.id),
629+ text(&token_hash),
630+ text(&now),
631+ ],
632+ )
633+ .await
634+ .map_err(db_err)?;
635+ if won != 1 {
636+ continue;
637+ }
638+ let job = load_job(&tx, &job_id).await?.ok_or_else(missing_row)?;
639+ tx.execute(
640+ "UPDATE ci_runs SET state = 'running' WHERE id = ?1 AND state = 'queued'",
641+ (job.run_id.clone(),),
642+ )
643+ .await
644+ .map_err(db_err)?;
645+ let run = load_run(&tx, &job.run_id).await?.ok_or_else(missing_row)?;
646+ return Ok(Some((job, run)));
647+ }
648+ Ok(None)
649+ }
650+ .await;
651+ let claimed = finish(tx, outcome).await?;
652+ Ok(claimed.map(|(job, run)| (job, run, token)))
653+ }
654+
655+ /// The job, only while it is running and the token matches its stored hash.
656+ pub async fn job_by_token(&self, job_id: &str, token_plain: &str) -> Result<Option<CiJob>> {
657+ let conn = self.read_conn().await;
658+ fetch_opt(
659+ &conn,
660+ &format!(
661+ "SELECT {JOB_COLS} FROM ci_jobs \
662+ WHERE id = ?1 AND job_token_hash = ?2 AND state = 'running'"
663+ ),
664+ (job_id.to_string(), hash_token(token_plain)),
665+ map_job,
666+ )
667+ .await
668+ }
669+
670+ /// Stamps the job's heartbeat and returns whether a cancel was requested. `NotFound` when
671+ /// the job is not running any more, so the runner stops.
672+ pub async fn heartbeat(&self, job_id: &str) -> Result<bool> {
673+ let now = now_stamp();
674+ let conn = self.inner.conn.lock().await;
675+ let n = conn
676+ .execute(
677+ "UPDATE ci_jobs SET heartbeat_at = ?2 WHERE id = ?1 AND state = 'running'",
678+ (job_id.to_string(), now),
679+ )
680+ .await
681+ .map_err(db_err)?;
682+ if n != 1 {
683+ return Err(Error::NotFound);
684+ }
685+ fetch_opt(
686+ &conn,
687+ "SELECT cancel_requested FROM ci_jobs WHERE id = ?1",
688+ (job_id.to_string(),),
689+ |row| col_bool(row, 0),
690+ )
691+ .await?
692+ .ok_or(Error::NotFound)
693+ }
694+
695+ /// Adds to the job's log size.
696+ pub async fn add_log_bytes(&self, job_id: &str, n: i64) -> Result<()> {
697+ if n < 0 {
698+ return Err(Error::Invalid("log bytes cannot go down".into()));
699+ }
700+ let conn = self.inner.conn.lock().await;
701+ let changed = conn
702+ .execute(
703+ "UPDATE ci_jobs SET log_bytes = log_bytes + ?2 WHERE id = ?1",
704+ (job_id.to_string(), n),
705+ )
706+ .await
707+ .map_err(db_err)?;
708+ if changed == 0 {
709+ return Err(Error::NotFound);
710+ }
711+ Ok(())
712+ }
713+
714+ /// Finishes a job with a terminal state. Dependants are then unblocked or skipped, and the
715+ /// run state is recomputed. Returns the run. `Conflict` if the job already finished.
716+ pub async fn finish_job(
717+ &self,
718+ job_id: &str,
719+ state: JobState,
720+ exit_code: Option<i64>,
721+ message: Option<&str>,
722+ ) -> Result<CiRun> {
723+ if !state.is_terminal() {
724+ return Err(Error::Invalid(
725+ "a job can only finish in a terminal state".into(),
726+ ));
727+ }
728+ let now = now_stamp();
729+ let conn = self.inner.conn.lock().await;
730+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
731+ let outcome: Result<CiRun> = async {
732+ let job = load_job(&tx, job_id).await?.ok_or(Error::NotFound)?;
733+ if job.state.is_terminal() {
734+ return Err(Error::Conflict("the job has already finished".into()));
735+ }
736+ tx.execute(
737+ "UPDATE ci_jobs SET state = ?2, exit_code = ?3, message = ?4, finished_at = ?5 \
738+ WHERE id = ?1",
739+ vec![
740+ text(job_id),
741+ text(state.as_str()),
742+ opt_int_value(exit_code),
743+ opt_text(message),
744+ text(&now),
745+ ],
746+ )
747+ .await
748+ .map_err(db_err)?;
749+ propagate(&tx, &job.run_id, &now).await?;
750+ recompute_run(&tx, &job.run_id, &now).await?;
751+ load_run(&tx, &job.run_id).await?.ok_or_else(missing_row)
752+ }
753+ .await;
754+ finish(tx, outcome).await
755+ }
756+
757+ /// Cancels the run's queued and blocked jobs now, and sets `cancel_requested` on its running
758+ /// jobs. Does nothing to a run that already finished.
759+ pub async fn request_cancel(&self, run_id: &str) -> Result<()> {
760+ let now = now_stamp();
761+ let conn = self.inner.conn.lock().await;
762+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
763+ let outcome: Result<()> = async {
764+ let run = load_run(&tx, run_id).await?.ok_or(Error::NotFound)?;
765+ if run.state.is_terminal() {
766+ return Ok(());
767+ }
768+ tx.execute(
769+ "UPDATE ci_jobs SET state = 'cancelled', finished_at = ?2 \
770+ WHERE run_id = ?1 AND state IN ('queued', 'blocked')",
771+ (run_id.to_string(), now.clone()),
772+ )
773+ .await
774+ .map_err(db_err)?;
775+ tx.execute(
776+ "UPDATE ci_jobs SET cancel_requested = 1 WHERE run_id = ?1 AND state = 'running'",
777+ (run_id.to_string(),),
778+ )
779+ .await
780+ .map_err(db_err)?;
781+ recompute_run(&tx, run_id, &now).await
782+ }
783+ .await;
784+ finish(tx, outcome).await
785+ }
786+
787+ /// A new run, with the next number, for the same commit, ref, trigger and job specs. The
788+ /// new run is queued at once, without approval.
789+ pub async fn rerun(&self, run_id: &str, actor_id: &str) -> Result<CiRun> {
790+ let (run, jobs) = {
791+ let conn = self.read_conn().await;
792+ let run = load_run(&conn, run_id).await?.ok_or(Error::NotFound)?;
793+ let jobs = jobs_of_run(&conn, run_id).await?;
794+ (run, jobs)
795+ };
796+ let owned: Vec<(String, String, Vec<String>)> = jobs
797+ .iter()
798+ .map(|j| {
799+ (
800+ j.name.clone(),
801+ j.spec_json.clone(),
802+ spec_needs(&j.spec_json),
803+ )
804+ })
805+ .collect();
806+ let new_jobs: Vec<NewJob<'_>> = owned
807+ .iter()
808+ .map(|(name, spec, needs)| NewJob {
809+ name,
810+ spec_json: spec,
811+ needs,
812+ })
813+ .collect();
814+ self.create_run(
815+ &run.repo_id,
816+ &run.trigger,
817+ &run.git_ref,
818+ &run.commit_oid,
819+ run.pull_number,
820+ actor_id,
821+ false,
822+ &new_jobs,
823+ run.config_error.as_deref(),
824+ )
825+ .await
826+ }
827+
828+ /// Running jobs whose heartbeat is older than `older_than` become `Error` with the message
829+ /// "runner lost". Their dependants and run are updated as in `finish_job`. Returns the
830+ /// reaped jobs.
831+ pub async fn reap_lost_jobs(&self, older_than: &str) -> Result<Vec<CiJob>> {
832+ let now = now_stamp();
833+ let conn = self.inner.conn.lock().await;
834+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
835+ let outcome: Result<Vec<CiJob>> = async {
836+ let lost = fetch_all(
837+ &tx,
838+ &format!(
839+ "SELECT {JOB_COLS} FROM ci_jobs \
840+ WHERE state = 'running' AND heartbeat_at < ?1 ORDER BY id"
841+ ),
842+ (older_than.to_string(),),
843+ map_job,
844+ )
845+ .await?;
846+ let mut runs: Vec<String> = Vec::new();
847+ for job in &lost {
848+ tx.execute(
849+ "UPDATE ci_jobs SET state = 'error', message = 'runner lost', finished_at = ?2 \
850+ WHERE id = ?1 AND state = 'running'",
851+ (job.id.clone(), now.clone()),
852+ )
853+ .await
854+ .map_err(db_err)?;
855+ if !runs.contains(&job.run_id) {
856+ runs.push(job.run_id.clone());
857+ }
858+ }
859+ for run_id in &runs {
860+ propagate(&tx, run_id, &now).await?;
861+ recompute_run(&tx, run_id, &now).await?;
862+ }
863+ let mut out = Vec::with_capacity(lost.len());
864+ for job in &lost {
865+ out.push(load_job(&tx, &job.id).await?.ok_or_else(missing_row)?);
866+ }
867+ Ok(out)
868+ }
869+ .await;
870+ finish(tx, outcome).await
871+ }
872+
873+ pub async fn run_by_number(&self, repo_id: &str, n: i64) -> Result<Option<CiRun>> {
874+ let conn = self.read_conn().await;
875+ fetch_opt(
876+ &conn,
877+ &format!("SELECT {RUN_COLS} FROM ci_runs WHERE repo_id = ?1 AND number = ?2"),
878+ (repo_id.to_string(), n),
879+ map_run,
880+ )
881+ .await
882+ }
883+
884+ pub async fn run_by_id(&self, id: &str) -> Result<Option<CiRun>> {
885+ let conn = self.read_conn().await;
886+ load_run(&conn, id).await
887+ }
888+
889+ /// One page of a repo's runs, newest first, and the total count. `page` starts at 1 and
890+ /// `per_page` is capped at 100.
891+ pub async fn list_runs(
892+ &self,
893+ repo_id: &str,
894+ page: u32,
895+ per_page: u32,
896+ ) -> Result<(Vec<CiRun>, u64)> {
897+ let per_page = per_page.clamp(1, MAX_PER_PAGE);
898+ let offset = i64::from(page.max(1).saturating_sub(1)) * i64::from(per_page);
899+ let conn = self.read_conn().await;
900+ let total = fetch_opt(
901+ &conn,
902+ "SELECT COUNT(*) FROM ci_runs WHERE repo_id = ?1",
903+ (repo_id.to_string(),),
904+ |row| col_int(row, 0),
905+ )
906+ .await?
907+ .ok_or_else(missing_row)?;
908+ let runs = fetch_all(
909+ &conn,
910+ &format!(
911+ "SELECT {RUN_COLS} FROM ci_runs WHERE repo_id = ?1 \
912+ ORDER BY number DESC LIMIT ?2 OFFSET ?3"
913+ ),
914+ (repo_id.to_string(), i64::from(per_page), offset),
915+ map_run,
916+ )
917+ .await?;
918+ let total =
919+ u64::try_from(total).map_err(|_| Error::Database("negative run count".into()))?;
920+ Ok((runs, total))
921+ }
922+
923+ /// The jobs of a run, by name.
924+ pub async fn jobs_for_run(&self, run_id: &str) -> Result<Vec<CiJob>> {
925+ let conn = self.read_conn().await;
926+ jobs_of_run(&conn, run_id).await
927+ }
928+
929+ pub async fn job_by_id(&self, id: &str) -> Result<Option<CiJob>> {
930+ let conn = self.read_conn().await;
931+ load_job(&conn, id).await
932+ }
933+
934+ /// Records an artifact of a job. `Conflict` if the job already has one with this name.
935+ pub async fn add_artifact(
936+ &self,
937+ job_id: &str,
938+ name: &str,
939+ size: i64,
940+ sha256: &str,
941+ ) -> Result<CiArtifact> {
942+ if size < 0 {
943+ return Err(Error::Invalid("an artifact size cannot be negative".into()));
944+ }
945+ let artifact = CiArtifact {
946+ id: new_id(),
947+ job_id: job_id.to_string(),
948+ name: name.to_string(),
949+ size,
950+ sha256: sha256.to_string(),
951+ created_at: now_stamp(),
952+ };
953+ let conn = self.inner.conn.lock().await;
954+ conn.execute(
955+ "INSERT INTO ci_artifacts (id, job_id, name, size, sha256, created_at) \
956+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
957+ vec![
958+ text(&artifact.id),
959+ text(&artifact.job_id),
960+ text(&artifact.name),
961+ Value::Integer(artifact.size),
962+ text(&artifact.sha256),
963+ text(&artifact.created_at),
964+ ],
965+ )
966+ .await
967+ .map_err(|e| insert_err(e, "this job already has an artifact with that name"))?;
968+ Ok(artifact)
969+ }
970+
971+ /// The artifacts of a job, by name.
972+ pub async fn list_artifacts(&self, job_id: &str) -> Result<Vec<CiArtifact>> {
973+ let conn = self.read_conn().await;
974+ fetch_all(
975+ &conn,
976+ &format!("SELECT {ARTIFACT_COLS} FROM ci_artifacts WHERE job_id = ?1 ORDER BY name"),
977+ (job_id.to_string(),),
978+ map_artifact,
979+ )
980+ .await
981+ }
982+
983+ /// One artifact of a job, by name.
984+ pub async fn artifact(&self, job_id: &str, name: &str) -> Result<Option<CiArtifact>> {
985+ let conn = self.read_conn().await;
986+ fetch_opt(
987+ &conn,
988+ &format!("SELECT {ARTIFACT_COLS} FROM ci_artifacts WHERE job_id = ?1 AND name = ?2"),
989+ (job_id.to_string(), name.to_string()),
990+ map_artifact,
991+ )
992+ .await
993+ }
994+
995+ /// Retention. Jobs of runs that finished before `logs_before` and still have logs get
996+ /// `log_bytes = 0`; their ids are returned so the caller deletes the log files. Artifact rows
997+ /// created before `artifacts_before` are deleted; their ids are returned so the caller
998+ /// deletes the files.
999+ pub async fn purge_old(
1000+ &self,
1001+ logs_before: &str,
1002+ artifacts_before: &str,
1003+ ) -> Result<(Vec<String>, Vec<String>)> {
1004+ let conn = self.inner.conn.lock().await;
1005+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
1006+ let outcome: Result<(Vec<String>, Vec<String>)> = async {
1007+ let job_ids = fetch_all(
1008+ &tx,
1009+ "SELECT j.id FROM ci_jobs j JOIN ci_runs r ON r.id = j.run_id \
1010+ WHERE r.finished_at IS NOT NULL AND r.finished_at < ?1 AND j.log_bytes > 0 \
1011+ ORDER BY j.id",
1012+ (logs_before.to_string(),),
1013+ |row| col_text(row, 0),
1014+ )
1015+ .await?;
1016+ for id in &job_ids {
1017+ tx.execute(
1018+ "UPDATE ci_jobs SET log_bytes = 0 WHERE id = ?1",
1019+ (id.clone(),),
1020+ )
1021+ .await
1022+ .map_err(db_err)?;
1023+ }
1024+ let artifact_ids = fetch_all(
1025+ &tx,
1026+ "SELECT id FROM ci_artifacts WHERE created_at < ?1 ORDER BY id",
1027+ (artifacts_before.to_string(),),
1028+ |row| col_text(row, 0),
1029+ )
1030+ .await?;
1031+ tx.execute(
1032+ "DELETE FROM ci_artifacts WHERE created_at < ?1",
1033+ (artifacts_before.to_string(),),
1034+ )
1035+ .await
1036+ .map_err(db_err)?;
1037+ Ok((job_ids, artifact_ids))
1038+ }
1039+ .await;
1040+ finish(tx, outcome).await
1041+ }
1042+
1043+ /// Stores or replaces a repo secret. Sealed with the instance key. The name must match the
1044+ /// JobSpec secret rule, and the value must be UTF-8.
1045+ pub async fn set_ci_secret(&self, repo_id: &str, name: &str, value: &[u8]) -> Result<()> {
1046+ validate_secret_name(name)?;
1047+ std::str::from_utf8(value)
1048+ .map_err(|_| Error::Invalid("a CI secret value must be UTF-8".into()))?;
1049+ let sealed = self.seal_ci_secret(value)?;
1050+ let now = now_stamp();
1051+ let conn = self.inner.conn.lock().await;
1052+ conn.execute(
1053+ "INSERT INTO ci_secrets (repo_id, name, sealed, created_at, updated_at) \
1054+ VALUES (?1, ?2, ?3, ?4, ?5) \
1055+ ON CONFLICT(repo_id, name) DO UPDATE SET sealed = excluded.sealed, \
1056+ updated_at = excluded.updated_at",
1057+ vec![
1058+ text(repo_id),
1059+ text(name),
1060+ Value::Blob(sealed),
1061+ text(&now),
1062+ text(&now),
1063+ ],
1064+ )
1065+ .await
1066+ .map_err(db_err)?;
1067+ Ok(())
1068+ }
1069+
1070+ /// Deletes a repo secret. Returns whether one existed.
1071+ pub async fn delete_ci_secret(&self, repo_id: &str, name: &str) -> Result<bool> {
1072+ let conn = self.inner.conn.lock().await;
1073+ let n = conn
1074+ .execute(
1075+ "DELETE FROM ci_secrets WHERE repo_id = ?1 AND name = ?2",
1076+ (repo_id.to_string(), name.to_string()),
1077+ )
1078+ .await
1079+ .map_err(db_err)?;
1080+ Ok(n > 0)
1081+ }
1082+
1083+ /// Secret names and their `updated_at`, by name. Values are never read.
1084+ pub async fn list_ci_secret_names(&self, repo_id: &str) -> Result<Vec<(String, String)>> {
1085+ let conn = self.read_conn().await;
1086+ fetch_all(
1087+ &conn,
1088+ "SELECT name, updated_at FROM ci_secrets WHERE repo_id = ?1 ORDER BY name",
1089+ (repo_id.to_string(),),
1090+ |row| Ok((col_text(row, 0)?, col_text(row, 1)?)),
1091+ )
1092+ .await
1093+ }
1094+
1095+ /// Opened values of the named secrets. Names that do not exist are omitted. For the claim
1096+ /// path only.
1097+ pub async fn ci_secret_values(
1098+ &self,
1099+ repo_id: &str,
1100+ names: &[String],
1101+ ) -> Result<BTreeMap<String, String>> {
1102+ let rows: Vec<(String, Vec<u8>)> = {
1103+ let conn = self.read_conn().await;
1104+ fetch_all(
1105+ &conn,
1106+ "SELECT name, sealed FROM ci_secrets WHERE repo_id = ?1",
1107+ (repo_id.to_string(),),
1108+ |row| Ok((col_text(row, 0)?, col_blob(row, 1)?)),
1109+ )
1110+ .await?
1111+ };
1112+ let mut out = BTreeMap::new();
1113+ for (name, sealed) in rows {
1114+ if !names.contains(&name) {
1115+ continue;
1116+ }
1117+ let key = self
1118+ .inner
1119+ .secret_key
1120+ .get()
1121+ .ok_or_else(|| Error::Crypto("instance secret key is not set".into()))?;
1122+ let sealed = String::from_utf8(sealed)
1123+ .map_err(|_| Error::Crypto("sealed CI secret is not UTF-8".into()))?;
1124+ let plain = secret::open(key, &sealed)?;
1125+ let value = String::from_utf8(plain)
1126+ .map_err(|_| Error::Crypto("CI secret is not UTF-8".into()))?;
1127+ out.insert(name, value);
1128+ }
1129+ Ok(out)
1130+ }
1131+}
1132+
1133+#[cfg(test)]
1134+mod tests {
1135+ use super::*;
1136+ use crate::model::{Repo, User, Visibility};
1137+ use crate::token::hash_token;
1138+
1139+ const KEY: [u8; 32] = [9u8; 32];
1140+ const PAST: &str = "2000-01-01T00:00:00.000000000Z";
1141+ const DAWN: &str = "1970-01-01T00:00:00.000000000Z";
1142+
1143+ struct Fx {
1144+ _dir: tempfile::TempDir,
1145+ store: Store,
1146+ alice: User,
1147+ repo: Repo,
1148+ }
1149+
1150+ async fn fixture() -> Fx {
1151+ let dir = tempfile::tempdir().unwrap();
1152+ let store = Store::open(&dir.path().join("forge.db")).await.unwrap();
1153+ store.set_secret_key(KEY);
1154+ let alice = store
1155+ .create_user("Alice", None, "hash", false)
1156+ .await
1157+ .unwrap();
1158+ let repo = store
1159+ .create_repo(&alice.id, "proj", "", Visibility::Public, "main")
1160+ .await
1161+ .unwrap();
1162+ Fx {
1163+ _dir: dir,
1164+ store,
1165+ alice,
1166+ repo,
1167+ }
1168+ }
1169+
1170+ struct Job {
1171+ name: String,
1172+ spec: String,
1173+ needs: Vec<String>,
1174+ }
1175+
1176+ fn job(name: &str, needs: &[&str], runs_on: &[&str]) -> Job {
1177+ Job {
1178+ name: name.to_string(),
1179+ spec: serde_json::json!({
1180+ "image": "alpine",
1181+ "run": "true",
1182+ "needs": needs,
1183+ "runs_on": runs_on,
1184+ })
1185+ .to_string(),
1186+ needs: needs.iter().map(|s| s.to_string()).collect(),
1187+ }
1188+ }
1189+
1190+ async fn create(
1191+ store: &Store,
1192+ repo_id: &str,
1193+ actor: &str,
1194+ jobs: &[Job],
1195+ approval: bool,
1196+ ) -> CiRun {
1197+ let new: Vec<NewJob<'_>> = jobs
1198+ .iter()
1199+ .map(|j| NewJob {
1200+ name: &j.name,
1201+ spec_json: &j.spec,
1202+ needs: &j.needs,
1203+ })
1204+ .collect();
1205+ store
1206+ .create_run(
1207+ repo_id,
1208+ "push",
1209+ "refs/heads/main",
1210+ "abc123",
1211+ None,
1212+ actor,
1213+ approval,
1214+ &new,
1215+ None,
1216+ )
1217+ .await
1218+ .unwrap()
1219+ }
1220+
1221+ async fn job_named(store: &Store, run_id: &str, name: &str) -> CiJob {
1222+ store
1223+ .jobs_for_run(run_id)
1224+ .await
1225+ .unwrap()
1226+ .into_iter()
1227+ .find(|j| j.name == name)
1228+ .unwrap()
1229+ }
1230+
1231+ async fn runner_with(store: &Store, actor: &str, labels: &[&str]) -> (CiRunner, String) {
1232+ let token = store.create_registration_token(actor, 1).await.unwrap();
1233+ let labels: Vec<String> = labels.iter().map(|s| s.to_string()).collect();
1234+ store
1235+ .register_runner(&token, "runner", &labels)
1236+ .await
1237+ .unwrap()
1238+ }
1239+
1240+ fn bare_runner(labels: &[&str]) -> CiRunner {
1241+ CiRunner {
1242+ id: new_id(),
1243+ name: "bare".into(),
1244+ labels: labels.iter().map(|s| s.to_string()).collect(),
1245+ created_at: now_stamp(),
1246+ last_seen_at: None,
1247+ disabled: false,
1248+ }
1249+ }
1250+
1251+ #[tokio::test]
1252+ async fn registration_token_is_single_use_and_expires() {
1253+ let fx = fixture().await;
1254+ let token = fx
1255+ .store
1256+ .create_registration_token(&fx.alice.id, 1)
1257+ .await
1258+ .unwrap();
1259+ // Only the hash is stored.
1260+ {
1261+ let conn = fx.store.inner.conn.lock().await;
1262+ let n = fetch_opt(
1263+ &conn,
1264+ "SELECT COUNT(*) FROM ci_registration_tokens WHERE token_hash = ?1",
1265+ (token.clone(),),
1266+ |row| col_int(row, 0),
1267+ )
1268+ .await
1269+ .unwrap()
1270+ .unwrap();
1271+ assert_eq!(n, 0);
1272+ }
1273+ let (runner, _secret) = fx
1274+ .store
1275+ .register_runner(&token, "build-1", &["linux".to_string()])
1276+ .await
1277+ .unwrap();
1278+ assert_eq!(runner.name, "build-1");
1279+ assert_eq!(runner.labels, vec!["linux".to_string()]);
1280+ let again = fx.store.register_runner(&token, "build-2", &[]).await;
1281+ assert!(matches!(again, Err(Error::Invalid(_))), "{again:?}");
1282+
1283+ let expired = fx
1284+ .store
1285+ .create_registration_token(&fx.alice.id, 1)
1286+ .await
1287+ .unwrap();
1288+ {
1289+ let conn = fx.store.inner.conn.lock().await;
1290+ conn.execute(
1291+ "UPDATE ci_registration_tokens SET expires_at = ?2 WHERE token_hash = ?1",
1292+ (hash_token(&expired), PAST.to_string()),
1293+ )
1294+ .await
1295+ .unwrap();
1296+ }
1297+ let late = fx.store.register_runner(&expired, "late", &[]).await;
1298+ assert!(matches!(late, Err(Error::Invalid(_))), "{late:?}");
1299+ assert!(matches!(
1300+ fx.store.create_registration_token(&fx.alice.id, 0).await,
1301+ Err(Error::Invalid(_))
1302+ ));
1303+ }
1304+
1305+ #[tokio::test]
1306+ async fn runner_secret_lookup_and_disable() {
1307+ let fx = fixture().await;
1308+ let (runner, secret) = runner_with(&fx.store, &fx.alice.id, &["linux"]).await;
1309+ let found = fx.store.runner_by_secret(&secret).await.unwrap().unwrap();
1310+ assert_eq!(found.id, runner.id);
1311+ assert!(found.last_seen_at.is_some());
1312+ assert!(
1313+ fx.store
1314+ .runner_by_secret("forge_wrong")
1315+ .await
1316+ .unwrap()
1317+ .is_none()
1318+ );
1319+ {
1320+ let conn = fx.store.inner.conn.lock().await;
1321+ let n = fetch_opt(
1322+ &conn,
1323+ "SELECT COUNT(*) FROM ci_runners WHERE secret_hash = ?1",
1324+ (secret.clone(),),
1325+ |row| col_int(row, 0),
1326+ )
1327+ .await
1328+ .unwrap()
1329+ .unwrap();
1330+ assert_eq!(n, 0, "the plaintext secret must not be stored");
1331+ }
1332+ fx.store
1333+ .set_runner_disabled(&runner.id, true)
1334+ .await
1335+ .unwrap();
1336+ let listed = fx.store.list_runners().await.unwrap();
1337+ assert_eq!(listed.len(), 1);
1338+ assert!(listed[0].disabled);
1339+ let disabled = fx.store.runner_by_secret(&secret).await.unwrap().unwrap();
1340+ assert!(disabled.disabled);
1341+ fx.store.delete_runner(&runner.id).await.unwrap();
1342+ assert!(fx.store.runner_by_secret(&secret).await.unwrap().is_none());
1343+ assert!(matches!(
1344+ fx.store.delete_runner(&runner.id).await,
1345+ Err(Error::NotFound)
1346+ ));
1347+ }
1348+
1349+ #[tokio::test]
1350+ async fn create_run_numbers_per_repo() {
1351+ let fx = fixture().await;
1352+ let other = fx
1353+ .store
1354+ .create_repo(&fx.alice.id, "other", "", Visibility::Public, "main")
1355+ .await
1356+ .unwrap();
1357+ let jobs = [job("build", &[], &[]), job("test", &["build"], &[])];
1358+ let r1 = create(&fx.store, &fx.repo.id, &fx.alice.id, &jobs, false).await;
1359+ let r2 = create(&fx.store, &fx.repo.id, &fx.alice.id, &jobs, false).await;
1360+ let o1 = create(&fx.store, &other.id, &fx.alice.id, &jobs, false).await;
1361+ assert_eq!((r1.number, r2.number, o1.number), (1, 2, 1));
1362+ assert_eq!(r1.state, RunState::Queued);
1363+ assert_eq!(
1364+ job_named(&fx.store, &r1.id, "build").await.state,
1365+ JobState::Queued
1366+ );
1367+ assert_eq!(
1368+ job_named(&fx.store, &r1.id, "test").await.state,
1369+ JobState::Blocked
1370+ );
1371+ assert_eq!(
1372+ fx.store
1373+ .run_by_number(&fx.repo.id, 2)
1374+ .await
1375+ .unwrap()
1376+ .unwrap()
1377+ .id,
1378+ r2.id
1379+ );
1380+
1381+ let broken = fx
1382+ .store
1383+ .create_run(
1384+ &fx.repo.id,
1385+ "push",
1386+ "refs/heads/main",
1387+ "abc123",
1388+ None,
1389+ &fx.alice.id,
1390+ false,
1391+ &[],
1392+ Some("line 3: unknown key"),
1393+ )
1394+ .await
1395+ .unwrap();
1396+ assert_eq!(broken.number, 3);
1397+ assert_eq!(broken.state, RunState::Error);
1398+ assert!(broken.finished_at.is_some());
1399+ assert!(fx.store.jobs_for_run(&broken.id).await.unwrap().is_empty());
1400+
1401+ let (page, total) = fx.store.list_runs(&fx.repo.id, 1, 2).await.unwrap();
1402+ assert_eq!(total, 3);
1403+ assert_eq!(
1404+ page.iter().map(|r| r.number).collect::<Vec<_>>(),
1405+ vec![3, 2]
1406+ );
1407+ let (page2, _) = fx.store.list_runs(&fx.repo.id, 2, 2).await.unwrap();
1408+ assert_eq!(page2.iter().map(|r| r.number).collect::<Vec<_>>(), vec![1]);
1409+ }
1410+
1411+ #[tokio::test]
1412+ async fn approval_flow() {
1413+ let fx = fixture().await;
1414+ let jobs = [job("build", &[], &[]), job("test", &["build"], &[])];
1415+ let run = create(&fx.store, &fx.repo.id, &fx.alice.id, &jobs, true).await;
1416+ assert_eq!(run.state, RunState::WaitingApproval);
1417+ assert_eq!(
1418+ job_named(&fx.store, &run.id, "build").await.state,
1419+ JobState::Blocked
1420+ );
1421+
1422+ fx.store.approve_run(&run.id).await.unwrap();
1423+ let after = fx.store.run_by_id(&run.id).await.unwrap().unwrap();
1424+ assert_eq!(after.state, RunState::Queued);
1425+ assert_eq!(
1426+ job_named(&fx.store, &run.id, "build").await.state,
1427+ JobState::Queued
1428+ );
1429+ assert_eq!(
1430+ job_named(&fx.store, &run.id, "test").await.state,
1431+ JobState::Blocked
1432+ );
1433+ assert!(matches!(
1434+ fx.store.approve_run(&run.id).await,
1435+ Err(Error::Conflict(_))
1436+ ));
1437+ assert!(matches!(
1438+ fx.store.approve_run("missing").await,
1439+ Err(Error::NotFound)
1440+ ));
1441+ }
1442+
1443+ #[tokio::test]
1444+ async fn claim_matches_labels_and_skips_disabled() {
1445+ let fx = fixture().await;
1446+ let run = create(
1447+ &fx.store,
1448+ &fx.repo.id,
1449+ &fx.alice.id,
1450+ &[
1451+ job("arm", &[], &["linux", "arm"]),
1452+ job("x86", &[], &["linux"]),
1453+ ],
1454+ false,
1455+ )
1456+ .await;
1457+ let linux_only = bare_runner(&["linux"]);
1458+ let claimed = fx.store.claim_job(&linux_only).await.unwrap().unwrap();
1459+ assert_eq!(claimed.0.name, "x86");
1460+ assert_eq!(claimed.0.state, JobState::Running);
1461+ assert_eq!(claimed.1.state, RunState::Running);
1462+ assert!(fx.store.claim_job(&linux_only).await.unwrap().is_none());
1463+
1464+ let job_token = claimed.2;
1465+ assert!(
1466+ fx.store
1467+ .job_by_token(&claimed.0.id, &job_token)
1468+ .await
1469+ .unwrap()
1470+ .is_some()
1471+ );
1472+ assert!(
1473+ fx.store
1474+ .job_by_token(&claimed.0.id, "forge_nope")
1475+ .await
1476+ .unwrap()
1477+ .is_none()
1478+ );
1479+
1480+ let mut disabled = bare_runner(&["linux", "arm"]);
1481+ disabled.disabled = true;
1482+ assert!(fx.store.claim_job(&disabled).await.unwrap().is_none());
1483+
1484+ let arm = fx
1485+ .store
1486+ .claim_job(&bare_runner(&["linux", "arm"]))
1487+ .await
1488+ .unwrap()
1489+ .unwrap();
1490+ assert_eq!(arm.0.name, "arm");
1491+ assert_eq!(
1492+ fx.store.run_by_id(&run.id).await.unwrap().unwrap().state,
1493+ RunState::Running
1494+ );
1495+ }
1496+
1497+ #[tokio::test]
1498+ async fn two_claims_one_winner() {
1499+ let fx = fixture().await;
1500+ create(
1501+ &fx.store,
1502+ &fx.repo.id,
1503+ &fx.alice.id,
1504+ &[job("only", &[], &[])],
1505+ false,
1506+ )
1507+ .await;
1508+ let (a, b) = (bare_runner(&[]), bare_runner(&[]));
1509+ let (ra, rb) = tokio::join!(fx.store.claim_job(&a), fx.store.claim_job(&b));
1510+ let winners = [ra.unwrap(), rb.unwrap()].into_iter().flatten().count();
1511+ assert_eq!(winners, 1);
1512+ }
1513+
1514+ #[tokio::test]
1515+ async fn finish_cascades_and_recomputes_run() {
1516+ let fx = fixture().await;
1517+ let run = create(
1518+ &fx.store,
1519+ &fx.repo.id,
1520+ &fx.alice.id,
1521+ &[
1522+ job("a", &[], &[]),
1523+ job("b", &["a"], &[]),
1524+ job("c", &["b"], &[]),
1525+ job("d", &[], &[]),
1526+ ],
1527+ false,
1528+ )
1529+ .await;
1530+ let runner = bare_runner(&[]);
1531+ let first = fx.store.claim_job(&runner).await.unwrap().unwrap();
1532+ let second = fx.store.claim_job(&runner).await.unwrap().unwrap();
1533+ let mut names = [first.0.name.clone(), second.0.name.clone()];
1534+ names.sort();
1535+ assert_eq!(names, ["a".to_string(), "d".to_string()]);
1536+ let a_id = if first.0.name == "a" {
1537+ first.0.id.clone()
1538+ } else {
1539+ second.0.id.clone()
1540+ };
1541+ let d_id = if first.0.name == "d" {
1542+ first.0.id.clone()
1543+ } else {
1544+ second.0.id.clone()
1545+ };
1546+
1547+ // Success unblocks b; c still waits on b.
1548+ let after_a = fx
1549+ .store
1550+ .finish_job(&a_id, JobState::Success, Some(0), None)
1551+ .await
1552+ .unwrap();
1553+ assert_eq!(after_a.state, RunState::Running);
1554+ assert_eq!(
1555+ job_named(&fx.store, &run.id, "b").await.state,
1556+ JobState::Queued
1557+ );
1558+ assert_eq!(
1559+ job_named(&fx.store, &run.id, "c").await.state,
1560+ JobState::Blocked
1561+ );
1562+
1563+ // Failure of b skips c transitively once b is done.
1564+ let b_claim = fx.store.claim_job(&runner).await.unwrap().unwrap();
1565+ assert_eq!(b_claim.0.name, "b");
1566+ fx.store
1567+ .finish_job(&b_claim.0.id, JobState::Failure, Some(1), Some("boom"))
1568+ .await
1569+ .unwrap();
1570+ let c = job_named(&fx.store, &run.id, "c").await;
1571+ assert_eq!(c.state, JobState::Skipped);
1572+ assert_eq!(
1573+ fx.store.run_by_id(&run.id).await.unwrap().unwrap().state,
1574+ RunState::Running,
1575+ "d is still running"
1576+ );
1577+
1578+ let done = fx
1579+ .store
1580+ .finish_job(&d_id, JobState::Success, Some(0), None)
1581+ .await
1582+ .unwrap();
1583+ assert_eq!(done.state, RunState::Failure);
1584+ assert!(done.finished_at.is_some());
1585+ assert!(matches!(
1586+ fx.store
1587+ .finish_job(&d_id, JobState::Success, None, None)
1588+ .await,
1589+ Err(Error::Conflict(_))
1590+ ));
1591+ assert!(matches!(
1592+ fx.store
1593+ .finish_job(&a_id, JobState::Running, None, None)
1594+ .await,
1595+ Err(Error::Invalid(_))
1596+ ));
1597+ }
1598+
1599+ #[tokio::test]
1600+ async fn failure_skips_dependants_transitively_and_cancelled_wins_over_success() {
1601+ let fx = fixture().await;
1602+ let run = create(
1603+ &fx.store,
1604+ &fx.repo.id,
1605+ &fx.alice.id,
1606+ &[
1607+ job("x", &[], &[]),
1608+ job("y", &["x"], &[]),
1609+ job("z", &["y"], &[]),
1610+ ],
1611+ false,
1612+ )
1613+ .await;
1614+ let runner = bare_runner(&[]);
1615+ let x = fx.store.claim_job(&runner).await.unwrap().unwrap();
1616+ let done = fx
1617+ .store
1618+ .finish_job(&x.0.id, JobState::Failure, Some(2), None)
1619+ .await
1620+ .unwrap();
1621+ assert_eq!(
1622+ job_named(&fx.store, &run.id, "y").await.state,
1623+ JobState::Skipped
1624+ );
1625+ assert_eq!(
1626+ job_named(&fx.store, &run.id, "z").await.state,
1627+ JobState::Skipped
1628+ );
1629+ assert_eq!(done.state, RunState::Failure);
1630+ }
1631+
1632+ #[tokio::test]
1633+ async fn cancel_queued_and_running_jobs() {
1634+ let fx = fixture().await;
1635+ let run = create(
1636+ &fx.store,
1637+ &fx.repo.id,
1638+ &fx.alice.id,
1639+ &[job("a", &[], &[]), job("b", &["a"], &[])],
1640+ false,
1641+ )
1642+ .await;
1643+ let runner = bare_runner(&[]);
1644+ let a = fx.store.claim_job(&runner).await.unwrap().unwrap();
1645+ fx.store.request_cancel(&run.id).await.unwrap();
1646+ let b = job_named(&fx.store, &run.id, "b").await;
1647+ assert_eq!(b.state, JobState::Cancelled);
1648+ let a_now = job_named(&fx.store, &run.id, "a").await;
1649+ assert_eq!(a_now.state, JobState::Running);
1650+ assert!(a_now.cancel_requested);
1651+ assert!(
1652+ fx.store.heartbeat(&a.0.id).await.unwrap(),
1653+ "runner sees the cancel"
1654+ );
1655+ assert_eq!(
1656+ fx.store.run_by_id(&run.id).await.unwrap().unwrap().state,
1657+ RunState::Running
1658+ );
1659+
1660+ let done = fx
1661+ .store
1662+ .finish_job(&a.0.id, JobState::Cancelled, None, None)
1663+ .await
1664+ .unwrap();
1665+ assert_eq!(done.state, RunState::Cancelled);
1666+ assert!(done.finished_at.is_some());
1667+ // A finished run does not change again.
1668+ fx.store.request_cancel(&run.id).await.unwrap();
1669+ assert_eq!(
1670+ fx.store.run_by_id(&run.id).await.unwrap().unwrap().state,
1671+ RunState::Cancelled
1672+ );
1673+ assert!(matches!(
1674+ fx.store.heartbeat(&a.0.id).await,
1675+ Err(Error::NotFound)
1676+ ));
1677+ }
1678+
1679+ #[tokio::test]
1680+ async fn rerun_copies_commit_and_specs() {
1681+ let fx = fixture().await;
1682+ let jobs = [job("build", &[], &["linux"]), job("test", &["build"], &[])];
1683+ let first = create(&fx.store, &fx.repo.id, &fx.alice.id, &jobs, false).await;
1684+ let again = fx.store.rerun(&first.id, &fx.alice.id).await.unwrap();
1685+ assert_eq!(again.number, 2);
1686+ assert_eq!(again.commit_oid, first.commit_oid);
1687+ assert_eq!(again.git_ref, first.git_ref);
1688+ assert_eq!(again.trigger, first.trigger);
1689+ assert_eq!(again.state, RunState::Queued);
1690+ let build = job_named(&fx.store, &again.id, "build").await;
1691+ assert_eq!(
1692+ build.spec_json,
1693+ job_named(&fx.store, &first.id, "build").await.spec_json
1694+ );
1695+ assert_eq!(build.state, JobState::Queued);
1696+ assert_eq!(
1697+ job_named(&fx.store, &again.id, "test").await.state,
1698+ JobState::Blocked
1699+ );
1700+ assert!(matches!(
1701+ fx.store.rerun("missing", &fx.alice.id).await,
1702+ Err(Error::NotFound)
1703+ ));
1704+ }
1705+
1706+ #[tokio::test]
1707+ async fn reap_lost_jobs_errors_stale_running_jobs() {
1708+ let fx = fixture().await;
1709+ let run = create(
1710+ &fx.store,
1711+ &fx.repo.id,
1712+ &fx.alice.id,
1713+ &[job("a", &[], &[]), job("b", &["a"], &[])],
1714+ false,
1715+ )
1716+ .await;
1717+ let claimed = fx
1718+ .store
1719+ .claim_job(&bare_runner(&[]))
1720+ .await
1721+ .unwrap()
1722+ .unwrap();
1723+ // Nothing is older than the epoch.
1724+ assert!(fx.store.reap_lost_jobs(DAWN).await.unwrap().is_empty());
1725+ let future = stamp_after(Duration::from_secs(3600));
1726+ let reaped = fx.store.reap_lost_jobs(&future).await.unwrap();
1727+ assert_eq!(reaped.len(), 1);
1728+ assert_eq!(reaped[0].id, claimed.0.id);
1729+ assert_eq!(reaped[0].state, JobState::Error);
1730+ assert_eq!(reaped[0].message.as_deref(), Some("runner lost"));
1731+ assert_eq!(
1732+ job_named(&fx.store, &run.id, "b").await.state,
1733+ JobState::Skipped
1734+ );
1735+ assert_eq!(
1736+ fx.store.run_by_id(&run.id).await.unwrap().unwrap().state,
1737+ RunState::Failure
1738+ );
1739+ assert!(
1740+ fx.store
1741+ .job_by_token(&claimed.0.id, &claimed.2)
1742+ .await
1743+ .unwrap()
1744+ .is_none()
1745+ );
1746+ }
1747+
1748+ #[tokio::test]
1749+ async fn purge_old_returns_logs_and_artifacts_once() {
1750+ let fx = fixture().await;
1751+ let run = create(
1752+ &fx.store,
1753+ &fx.repo.id,
1754+ &fx.alice.id,
1755+ &[job("a", &[], &[])],
1756+ false,
1757+ )
1758+ .await;
1759+ let a = job_named(&fx.store, &run.id, "a").await;
1760+ fx.store.add_log_bytes(&a.id, 120).await.unwrap();
1761+ fx.store
1762+ .add_artifact(&a.id, "dist.tar", 10, "ab")
1763+ .await
1764+ .unwrap();
1765+ fx.store.request_cancel(&run.id).await.unwrap();
1766+ let later = stamp_after(Duration::from_secs(3600));
1767+
1768+ assert_eq!(
1769+ fx.store.purge_old(DAWN, DAWN).await.unwrap(),
1770+ (vec![], vec![])
1771+ );
1772+ let (logs, artifacts) = fx.store.purge_old(&later, &later).await.unwrap();
1773+ assert_eq!(logs, vec![a.id.clone()]);
1774+ assert_eq!(artifacts.len(), 1);
1775+ assert_eq!(job_named(&fx.store, &run.id, "a").await.log_bytes, 0);
1776+ assert!(
1777+ fx.store
1778+ .artifact(&a.id, "dist.tar")
1779+ .await
1780+ .unwrap()
1781+ .is_none()
1782+ );
1783+ let (logs2, artifacts2) = fx.store.purge_old(&later, &later).await.unwrap();
1784+ assert!(logs2.is_empty() && artifacts2.is_empty());
1785+ }
1786+
1787+ #[tokio::test]
1788+ async fn artifacts_are_unique_per_job_and_listed() {
1789+ let fx = fixture().await;
1790+ let run = create(
1791+ &fx.store,
1792+ &fx.repo.id,
1793+ &fx.alice.id,
1794+ &[job("a", &[], &[])],
1795+ false,
1796+ )
1797+ .await;
1798+ let a = job_named(&fx.store, &run.id, "a").await;
1799+ let made = fx
1800+ .store
1801+ .add_artifact(&a.id, "report", 5, "cafe")
1802+ .await
1803+ .unwrap();
1804+ assert_eq!(made.size, 5);
1805+ let dup = fx.store.add_artifact(&a.id, "report", 6, "beef").await;
1806+ assert!(matches!(dup, Err(Error::Conflict(_))), "{dup:?}");
1807+ assert_eq!(fx.store.list_artifacts(&a.id).await.unwrap().len(), 1);
1808+ assert_eq!(
1809+ fx.store
1810+ .artifact(&a.id, "report")
1811+ .await
1812+ .unwrap()
1813+ .unwrap()
1814+ .sha256,
1815+ "cafe"
1816+ );
1817+ }
1818+
1819+ #[tokio::test]
1820+ async fn secrets_seal_round_trip_and_list_never_returns_values() {
1821+ let fx = fixture().await;
1822+ let plain = "hunter2-very-secret-value";
1823+ fx.store
1824+ .set_ci_secret(&fx.repo.id, "DEPLOY_KEY", plain.as_bytes())
1825+ .await
1826+ .unwrap();
1827+ fx.store
1828+ .set_ci_secret(&fx.repo.id, "TOKEN", b"second")
1829+ .await
1830+ .unwrap();
1831+
1832+ // Raw storage is sealed, not plaintext.
1833+ {
1834+ let conn = fx.store.inner.conn.lock().await;
1835+ let raw = fetch_opt(
1836+ &conn,
1837+ "SELECT sealed FROM ci_secrets WHERE repo_id = ?1 AND name = 'DEPLOY_KEY'",
1838+ (fx.repo.id.clone(),),
1839+ |row| col_blob(row, 0),
1840+ )
1841+ .await
1842+ .unwrap()
1843+ .unwrap();
1844+ assert!(!raw.windows(plain.len()).any(|w| w == plain.as_bytes()));
1845+ }
1846+
1847+ let names = fx.store.list_ci_secret_names(&fx.repo.id).await.unwrap();
1848+ assert_eq!(
1849+ names.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
1850+ vec!["DEPLOY_KEY", "TOKEN"]
1851+ );
1852+ for (_, stamp) in &names {
1853+ assert!(!stamp.contains(plain));
1854+ }
1855+
1856+ let values = fx
1857+ .store
1858+ .ci_secret_values(
1859+ &fx.repo.id,
1860+ &["DEPLOY_KEY".to_string(), "MISSING".to_string()],
1861+ )
1862+ .await
1863+ .unwrap();
1864+ assert_eq!(values.len(), 1);
1865+ assert_eq!(values.get("DEPLOY_KEY").map(String::as_str), Some(plain));
1866+
1867+ // Replacing keeps one row.
1868+ fx.store
1869+ .set_ci_secret(&fx.repo.id, "DEPLOY_KEY", b"rotated")
1870+ .await
1871+ .unwrap();
1872+ assert_eq!(
1873+ fx.store
1874+ .list_ci_secret_names(&fx.repo.id)
1875+ .await
1876+ .unwrap()
1877+ .len(),
1878+ 2
1879+ );
1880+ let rotated = fx
1881+ .store
1882+ .ci_secret_values(&fx.repo.id, &["DEPLOY_KEY".to_string()])
1883+ .await
1884+ .unwrap();
1885+ assert_eq!(
1886+ rotated.get("DEPLOY_KEY").map(String::as_str),
1887+ Some("rotated")
1888+ );
1889+
1890+ assert!(
1891+ fx.store
1892+ .delete_ci_secret(&fx.repo.id, "TOKEN")
1893+ .await
1894+ .unwrap()
1895+ );
1896+ assert!(
1897+ !fx.store
1898+ .delete_ci_secret(&fx.repo.id, "TOKEN")
1899+ .await
1900+ .unwrap()
1901+ );
1902+ assert!(matches!(
1903+ fx.store.set_ci_secret(&fx.repo.id, "lower", b"x").await,
1904+ Err(Error::Invalid(_))
1905+ ));
1906+ assert!(matches!(
1907+ fx.store
1908+ .set_ci_secret(&fx.repo.id, "BAD", &[0xff, 0xfe])
1909+ .await,
1910+ Err(Error::Invalid(_))
1911+ ));
1912+ }
1913+
1914+ #[tokio::test]
1915+ async fn secrets_need_the_key() {
1916+ let dir = tempfile::tempdir().unwrap();
1917+ let store = Store::open(&dir.path().join("forge.db")).await.unwrap();
1918+ let alice = store
1919+ .create_user("Alice", None, "hash", false)
1920+ .await
1921+ .unwrap();
1922+ let repo = store
1923+ .create_repo(&alice.id, "proj", "", Visibility::Public, "main")
1924+ .await
1925+ .unwrap();
1926+ let result = store.set_ci_secret(&repo.id, "KEY", b"x").await;
1927+ assert!(matches!(result, Err(Error::Crypto(_))), "{result:?}");
1928+ }
1929+
1930+ #[tokio::test]
1931+ async fn delete_repo_removes_ci_rows() {
1932+ let fx = fixture().await;
1933+ let run = create(
1934+ &fx.store,
1935+ &fx.repo.id,
1936+ &fx.alice.id,
1937+ &[job("a", &[], &[])],
1938+ false,
1939+ )
1940+ .await;
1941+ let a = job_named(&fx.store, &run.id, "a").await;
1942+ fx.store.add_artifact(&a.id, "out", 1, "00").await.unwrap();
1943+ fx.store
1944+ .set_ci_secret(&fx.repo.id, "KEY", b"value")
1945+ .await
1946+ .unwrap();
1947+
1948+ fx.store.delete_repo(&fx.repo.id).await.unwrap();
1949+
1950+ assert!(fx.store.run_by_id(&run.id).await.unwrap().is_none());
1951+ assert!(fx.store.job_by_id(&a.id).await.unwrap().is_none());
1952+ assert!(fx.store.list_artifacts(&a.id).await.unwrap().is_empty());
1953+ assert!(
1954+ fx.store
1955+ .list_ci_secret_names(&fx.repo.id)
1956+ .await
1957+ .unwrap()
1958+ .is_empty()
1959+ );
1960+ let conn = fx.store.inner.conn.lock().await;
1961+ let left = fetch_opt(
1962+ &conn,
1963+ "SELECT (SELECT COUNT(*) FROM ci_jobs) + (SELECT COUNT(*) FROM ci_artifacts)",
1964+ (),
1965+ |row| col_int(row, 0),
1966+ )
1967+ .await
1968+ .unwrap()
1969+ .unwrap();
1970+ assert_eq!(left, 0);
1971+ }
1972+
1973+ #[tokio::test]
1974+ async fn deleting_a_user_hands_ci_rows_to_ghost() {
1975+ let fx = fixture().await;
1976+ let bob = fx
1977+ .store
1978+ .create_user("Bob", None, "hash", false)
1979+ .await
1980+ .unwrap();
1981+ let run = create(
1982+ &fx.store,
1983+ &fx.repo.id,
1984+ &bob.id,
1985+ &[job("a", &[], &[])],
1986+ false,
1987+ )
1988+ .await;
1989+ let token = fx
1990+ .store
1991+ .create_registration_token(&bob.id, 1)
1992+ .await
1993+ .unwrap();
1994+ let ghost = fx.store.ensure_ghost_user().await.unwrap();
1995+
1996+ fx.store.delete_user(&bob.id, &ghost.id).await.unwrap();
1997+
1998+ assert_eq!(
1999+ fx.store.run_by_id(&run.id).await.unwrap().unwrap().actor_id,
2000+ ghost.id
crates/forge-core/src/store/commit_status.rsadded+529 -0
@@ -0,0 +1,529 @@
1+//! Commit statuses: one row per (repo, commit, context). Posting again for the same context
2+//! replaces the row, so the latest report wins. `combined_state` folds the rows of one commit
3+//! into the single state shown next to it.
4+
5+use std::collections::HashMap;
6+
7+use turso::{Row, Value};
8+
9+use super::{Store, col_opt_text, col_text, db_err, fetch_all, fetch_opt, missing_row, new_id};
10+use crate::error::{Error, Result};
11+use crate::time::now_stamp;
12+
13+/// Longest accepted context name, in characters.
14+pub const MAX_CONTEXT_CHARS: usize = 255;
15+/// Longest accepted description, in characters.
16+pub const MAX_DESCRIPTION_CHARS: usize = 1024;
17+/// Longest accepted target URL, in bytes.
18+pub const MAX_TARGET_URL_BYTES: usize = 2048;
19+/// Context used when a report does not name one.
20+pub const DEFAULT_CONTEXT: &str = "default";
21+
22+const STATUS_COLS: &str = "id, repo_id, sha, context, state, description, target_url, creator_id, \
23+ created_at, updated_at";
24+
25+/// The state a commit status reports.
26+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27+pub enum StatusState {
28+ Pending,
29+ Success,
30+ Failure,
31+ Error,
32+}
33+
34+impl StatusState {
35+ pub fn as_str(self) -> &'static str {
36+ match self {
37+ StatusState::Pending => "pending",
38+ StatusState::Success => "success",
39+ StatusState::Failure => "failure",
40+ StatusState::Error => "error",
41+ }
42+ }
43+
44+ pub fn parse(text: &str) -> Option<StatusState> {
45+ match text {
46+ "pending" => Some(StatusState::Pending),
47+ "success" => Some(StatusState::Success),
48+ "failure" => Some(StatusState::Failure),
49+ "error" => Some(StatusState::Error),
50+ _ => None,
51+ }
52+ }
53+}
54+
55+/// One status row as stored.
56+#[derive(Debug, Clone, PartialEq, Eq)]
57+pub struct CommitStatus {
58+ pub id: String,
59+ pub repo_id: String,
60+ pub sha: String,
61+ pub context: String,
62+ pub state: StatusState,
63+ pub description: Option<String>,
64+ pub target_url: Option<String>,
65+ pub creator_id: String,
66+ pub created_at: String,
67+ pub updated_at: String,
68+}
69+
70+/// Folds the states of one commit's statuses. Any failure or error makes the result failure, else
71+/// any pending makes it pending, else success. `None` when there are no statuses.
72+pub fn combine<I>(states: I) -> Option<StatusState>
73+where
74+ I: IntoIterator<Item = StatusState>,
75+{
76+ let mut seen = false;
77+ let mut pending = false;
78+ for state in states {
79+ seen = true;
80+ match state {
81+ StatusState::Failure | StatusState::Error => return Some(StatusState::Failure),
82+ StatusState::Pending => pending = true,
83+ StatusState::Success => {}
84+ }
85+ }
86+ match (seen, pending) {
87+ (false, _) => None,
88+ (true, true) => Some(StatusState::Pending),
89+ (true, false) => Some(StatusState::Success),
90+ }
91+}
92+
93+/// True for a full object id: 40 (SHA-1) or 64 (SHA-256) lowercase hex characters.
94+pub fn is_full_sha(sha: &str) -> bool {
95+ matches!(sha.len(), 40 | 64) && sha.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
96+}
97+
98+fn validate_context(context: &str) -> Result<String> {
99+ let trimmed = context.trim();
100+ if trimmed.is_empty() {
101+ return Err(Error::Invalid("context must not be empty".into()));
102+ }
103+ if trimmed.chars().count() > MAX_CONTEXT_CHARS {
104+ return Err(Error::Invalid(format!(
105+ "context must be at most {MAX_CONTEXT_CHARS} characters"
106+ )));
107+ }
108+ if trimmed.chars().any(char::is_control) {
109+ return Err(Error::Invalid(
110+ "context must not contain control characters".into(),
111+ ));
112+ }
113+ Ok(trimmed.to_string())
114+}
115+
116+fn validate_description(description: Option<&str>) -> Result<Option<String>> {
117+ let Some(text) = description.map(str::trim).filter(|t| !t.is_empty()) else {
118+ return Ok(None);
119+ };
120+ if text.chars().count() > MAX_DESCRIPTION_CHARS {
121+ return Err(Error::Invalid(format!(
122+ "description must be at most {MAX_DESCRIPTION_CHARS} characters"
123+ )));
124+ }
125+ Ok(Some(text.to_string()))
126+}
127+
128+fn validate_target_url(target_url: Option<&str>) -> Result<Option<String>> {
129+ let Some(url) = target_url.map(str::trim).filter(|t| !t.is_empty()) else {
130+ return Ok(None);
131+ };
132+ let lower = url.to_ascii_lowercase();
133+ if !(lower.starts_with("https://") || lower.starts_with("http://")) {
134+ return Err(Error::Invalid(
135+ "target_url must be an http or https URL".into(),
136+ ));
137+ }
138+ if url.len() > MAX_TARGET_URL_BYTES {
139+ return Err(Error::Invalid(format!(
140+ "target_url must be at most {MAX_TARGET_URL_BYTES} bytes"
141+ )));
142+ }
143+ if url.bytes().any(|b| b <= b' ' || b == 0x7f) {
144+ return Err(Error::Invalid(
145+ "target_url must not contain spaces or control characters".into(),
146+ ));
147+ }
148+ Ok(Some(url.to_string()))
149+}
150+
151+fn opt_value(text: Option<String>) -> Value {
152+ text.map_or(Value::Null, Value::Text)
153+}
154+
155+impl Store {
156+ /// Records `state` for `context` on the commit. An existing row for the same context is
157+ /// replaced, including its creator and description. The commit itself is not checked here;
158+ /// callers verify that it exists in the repo.
159+ #[allow(clippy::too_many_arguments)]
160+ pub async fn set_status(
161+ &self,
162+ repo_id: &str,
163+ sha: &str,
164+ context: &str,
165+ state: StatusState,
166+ description: Option<&str>,
167+ target_url: Option<&str>,
168+ creator_id: &str,
169+ ) -> Result<CommitStatus> {
170+ if !is_full_sha(sha) {
171+ return Err(Error::Invalid("sha must be a full lowercase hex id".into()));
172+ }
173+ let context = validate_context(context)?;
174+ let description = validate_description(description)?;
175+ let target_url = validate_target_url(target_url)?;
176+ let now = now_stamp();
177+
178+ let conn = self.inner.conn.lock().await;
179+ let params: Vec<Value> = vec![
180+ Value::Text(new_id()),
181+ Value::Text(repo_id.to_string()),
182+ Value::Text(sha.to_string()),
183+ Value::Text(context.clone()),
184+ Value::Text(state.as_str().to_string()),
185+ opt_value(description),
186+ opt_value(target_url),
187+ Value::Text(creator_id.to_string()),
188+ Value::Text(now.clone()),
189+ Value::Text(now),
190+ ];
191+ conn.execute(
192+ "INSERT INTO commit_statuses (id, repo_id, sha, context, state, description, target_url, \
193+ creator_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) \
194+ ON CONFLICT(repo_id, sha, context) DO UPDATE SET state = excluded.state, \
195+ description = excluded.description, target_url = excluded.target_url, \
196+ creator_id = excluded.creator_id, updated_at = excluded.updated_at",
197+ params,
198+ )
199+ .await
200+ .map_err(db_err)?;
201+
202+ fetch_opt(
203+ &conn,
204+ &format!(
205+ "SELECT {STATUS_COLS} FROM commit_statuses WHERE repo_id = ?1 AND sha = ?2 AND context = ?3"
206+ ),
207+ vec![
208+ Value::Text(repo_id.to_string()),
209+ Value::Text(sha.to_string()),
210+ Value::Text(context),
211+ ],
212+ map_status,
213+ )
214+ .await?
215+ .ok_or_else(missing_row)
216+ }
217+
218+ /// Every status of one commit, ordered by context.
219+ pub async fn statuses_for(&self, repo_id: &str, sha: &str) -> Result<Vec<CommitStatus>> {
220+ let conn = self.read_conn().await;
221+ fetch_all(
222+ &conn,
223+ &format!(
224+ "SELECT {STATUS_COLS} FROM commit_statuses WHERE repo_id = ?1 AND sha = ?2 \
225+ ORDER BY context"
226+ ),
227+ (repo_id.to_string(), sha.to_string()),
228+ map_status,
229+ )
230+ .await
231+ }
232+
233+ /// The combined state of one commit, or `None` when it has no statuses.
234+ pub async fn combined_state(&self, repo_id: &str, sha: &str) -> Result<Option<StatusState>> {
235+ let statuses = self.statuses_for(repo_id, sha).await?;
236+ Ok(combine(statuses.into_iter().map(|s| s.state)))
237+ }
238+
239+ /// Combined state for each of `shas` that has at least one status, in one query. Commits
240+ /// without statuses are absent from the map.
241+ pub async fn statuses_for_many(
242+ &self,
243+ repo_id: &str,
244+ shas: &[&str],
245+ ) -> Result<HashMap<String, StatusState>> {
246+ let rows = self.statuses_for_many_rows(repo_id, shas).await?;
247+ Ok(rows
248+ .into_iter()
249+ .filter_map(|(sha, statuses)| {
250+ combine(statuses.iter().map(|s| s.state)).map(|state| (sha, state))
251+ })
252+ .collect())
253+ }
254+
255+ /// Every status row of each of `shas`, grouped by commit, in one query. Commits without
256+ /// statuses are absent from the map. Used by the commit list to show each context.
257+ pub async fn statuses_for_many_rows(
258+ &self,
259+ repo_id: &str,
260+ shas: &[&str],
261+ ) -> Result<HashMap<String, Vec<CommitStatus>>> {
262+ if shas.is_empty() {
263+ return Ok(HashMap::new());
264+ }
265+ let placeholders: Vec<String> = (2..=shas.len() + 1).map(|i| format!("?{i}")).collect();
266+ let sql = format!(
267+ "SELECT {STATUS_COLS} FROM commit_statuses WHERE repo_id = ?1 AND sha IN ({}) \
268+ ORDER BY sha, context",
269+ placeholders.join(", ")
270+ );
271+ let mut params: Vec<Value> = Vec::with_capacity(shas.len() + 1);
272+ params.push(Value::Text(repo_id.to_string()));
273+ params.extend(shas.iter().map(|sha| Value::Text((*sha).to_string())));
274+
275+ let conn = self.read_conn().await;
276+ let rows = fetch_all(&conn, &sql, params, map_status).await?;
277+ let mut grouped: HashMap<String, Vec<CommitStatus>> = HashMap::new();
278+ for row in rows {
279+ grouped.entry(row.sha.clone()).or_default().push(row);
280+ }
281+ Ok(grouped)
282+ }
283+}
284+
285+fn parse_state(text: &str) -> Result<StatusState> {
286+ StatusState::parse(text)
287+ .ok_or_else(|| Error::Database(format!("unknown commit status state: {text}")))
288+}
289+
290+fn map_status(row: &Row) -> Result<CommitStatus> {
291+ let state = parse_state(&col_text(row, 4)?)?;
292+ Ok(CommitStatus {
293+ id: col_text(row, 0)?,
294+ repo_id: col_text(row, 1)?,
295+ sha: col_text(row, 2)?,
296+ context: col_text(row, 3)?,
297+ state,
298+ description: col_opt_text(row, 5)?,
299+ target_url: col_opt_text(row, 6)?,
300+ creator_id: col_text(row, 7)?,
301+ created_at: col_text(row, 8)?,
302+ updated_at: col_text(row, 9)?,
303+ })
304+}
305+
306+#[cfg(test)]
307+mod tests {
308+ use super::*;
309+
310+ #[test]
311+ fn combine_prefers_failure_then_pending() {
312+ use StatusState::*;
313+ assert_eq!(combine([]), None);
314+ assert_eq!(combine([Success, Success]), Some(Success));
315+ assert_eq!(combine([Success, Pending]), Some(Pending));
316+ assert_eq!(combine([Pending, Failure, Success]), Some(Failure));
317+ assert_eq!(combine([Error, Pending]), Some(Failure));
318+ }
319+
320+ #[test]
321+ fn full_sha_is_40_or_64_lowercase_hex() {
322+ assert!(is_full_sha(&"a".repeat(40)));
323+ assert!(is_full_sha(&"0".repeat(64)));
324+ assert!(!is_full_sha(&"a".repeat(39)));
325+ assert!(!is_full_sha(&"A".repeat(40)));
326+ assert!(!is_full_sha(&"g".repeat(40)));
327+ }
328+
329+ #[test]
330+ fn target_url_must_be_http_or_https() {
331+ assert!(validate_target_url(Some("https://ci.example/run/1")).is_ok());
332+ assert!(validate_target_url(Some("HTTP://ci.example")).is_ok());
333+ assert!(validate_target_url(Some("javascript:alert(1)")).is_err());
334+ assert!(validate_target_url(Some("https://ci.example/a b")).is_err());
335+ assert!(validate_target_url(None).is_ok_and(|v| v.is_none()));
336+ }
337+
338+ #[test]
339+ fn context_and_description_limits() {
340+ assert!(validate_context(" ").is_err());
341+ assert!(validate_context(&"c".repeat(MAX_CONTEXT_CHARS + 1)).is_err());
342+ assert!(validate_context("ci/build").is_ok());
343+ assert!(validate_description(Some(&"d".repeat(MAX_DESCRIPTION_CHARS + 1))).is_err());
344+ }
345+
346+ use crate::model::Visibility;
347+
348+ const SHA_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
349+ const SHA_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
350+
351+ async fn fixture() -> (tempfile::TempDir, Store, String, String) {
352+ let dir = tempfile::tempdir().expect("tempdir");
353+ let store = Store::open(&dir.path().join("forge.db"))
354+ .await
355+ .expect("store");
356+ let owner = store
357+ .create_user("alice", None, "not-a-real-hash", false)
358+ .await
359+ .expect("user");
360+ let repo = store
361+ .create_repo(&owner.id, "demo", "", Visibility::Public, "main")
362+ .await
363+ .expect("repo");
364+ (dir, store, repo.id, owner.id)
365+ }
366+
367+ #[tokio::test]
368+ async fn upsert_keeps_one_row_per_context_and_combines() {
369+ let (_dir, store, repo_id, user_id) = fixture().await;
370+ let set = |context: &'static str, state: StatusState| {
371+ let store = store.clone();
372+ let repo_id = repo_id.clone();
373+ let user_id = user_id.clone();
374+ async move {
375+ store
376+ .set_status(&repo_id, SHA_A, context, state, None, None, &user_id)
377+ .await
378+ .expect("set")
379+ }
380+ };
381+ set("a", StatusState::Pending).await;
382+ set("b", StatusState::Success).await;
383+ assert_eq!(
384+ store.combined_state(&repo_id, SHA_A).await.unwrap(),
385+ Some(StatusState::Pending)
386+ );
387+
388+ set("a", StatusState::Failure).await;
389+ assert_eq!(
390+ store.combined_state(&repo_id, SHA_A).await.unwrap(),
391+ Some(StatusState::Failure)
392+ );
393+
394+ // The latest report for "a" replaces the failure.
395+ let replaced = set("a", StatusState::Success).await;
396+ assert_eq!(replaced.state, StatusState::Success);
397+ assert_eq!(
398+ store.combined_state(&repo_id, SHA_A).await.unwrap(),
399+ Some(StatusState::Success)
400+ );
401+ let rows = store.statuses_for(&repo_id, SHA_A).await.unwrap();
402+ assert_eq!(
403+ rows.iter().map(|r| r.context.as_str()).collect::<Vec<_>>(),
404+ ["a", "b"]
405+ );
406+ assert_eq!(store.combined_state(&repo_id, SHA_B).await.unwrap(), None);
407+ }
408+
409+ #[tokio::test]
410+ async fn many_lookup_skips_commits_without_statuses() {
411+ let (_dir, store, repo_id, user_id) = fixture().await;
412+ store
413+ .set_status(
414+ &repo_id,
415+ SHA_A,
416+ "ci",
417+ StatusState::Error,
418+ Some("boom"),
419+ None,
420+ &user_id,
421+ )
422+ .await
423+ .expect("set");
424+ let found = store
425+ .statuses_for_many(&repo_id, &[SHA_A, SHA_B])
426+ .await
427+ .unwrap();
428+ assert_eq!(found.len(), 1);
429+ assert_eq!(found.get(SHA_A), Some(&StatusState::Failure));
430+ assert!(
431+ store
432+ .statuses_for_many(&repo_id, &[])
433+ .await
434+ .unwrap()
435+ .is_empty()
436+ );
437+ }
438+
439+ #[tokio::test]
440+ async fn invalid_input_is_refused_and_stores_nothing() {
441+ let (_dir, store, repo_id, user_id) = fixture().await;
442+ let bad_sha = store
443+ .set_status(
444+ &repo_id,
445+ "ABC",
446+ "ci",
447+ StatusState::Success,
448+ None,
449+ None,
450+ &user_id,
451+ )
452+ .await;
453+ assert!(matches!(bad_sha, Err(Error::Invalid(_))));
454+ let bad_url = store
455+ .set_status(
456+ &repo_id,
457+ SHA_A,
458+ "ci",
459+ StatusState::Success,
460+ None,
461+ Some("ftp://x"),
462+ &user_id,
463+ )
464+ .await;
465+ assert!(matches!(bad_url, Err(Error::Invalid(_))));
466+ assert!(
467+ store
468+ .statuses_for(&repo_id, SHA_A)
469+ .await
470+ .unwrap()
471+ .is_empty()
472+ );
473+ }
474+
475+ #[tokio::test]
476+ async fn deleting_the_repo_removes_its_statuses() {
477+ let (_dir, store, repo_id, user_id) = fixture().await;
478+ store
479+ .set_status(
480+ &repo_id,
481+ SHA_A,
482+ "ci",
483+ StatusState::Success,
484+ None,
485+ None,
486+ &user_id,
487+ )
488+ .await
489+ .expect("set");
490+ store.delete_repo(&repo_id).await.expect("delete");
491+ assert!(
492+ store
493+ .statuses_for(&repo_id, SHA_A)
494+ .await
495+ .unwrap()
496+ .is_empty()
497+ );
498+ }
499+
500+ #[tokio::test]
501+ async fn deleting_a_user_reassigns_their_statuses_to_ghost() {
502+ let (_dir, store, repo_id, _owner_id) = fixture().await;
503+ let reporter = store
504+ .create_user("ci-bot", None, "not-a-real-hash", false)
505+ .await
506+ .expect("user");
507+ store
508+ .set_status(
509+ &repo_id,
510+ SHA_A,
511+ "ci",
512+ StatusState::Failure,
513+ None,
514+ None,
515+ &reporter.id,
516+ )
517+ .await
518+ .expect("set");
519+ let ghost = store.ensure_ghost_user().await.expect("ghost");
520+ store
521+ .delete_user(&reporter.id, &ghost.id)
522+ .await
523+ .expect("delete user");
524+ let rows = store.statuses_for(&repo_id, SHA_A).await.unwrap();
525+ assert_eq!(rows.len(), 1);
526+ assert_eq!(rows[0].creator_id, ghost.id);
527+ assert_eq!(rows[0].state, StatusState::Failure);
528+ }
529+}
crates/forge-core/src/store/contributions.rsadded+146 -0
@@ -0,0 +1,146 @@
1+//! Daily contribution counts for one user, for the dashboard heatmap.
2+
3+use turso::Value;
4+
5+use super::push_recent::{ZERO_OID_SHA1, ZERO_OID_SHA256};
6+use super::{Store, col_int, col_text, fetch_all};
7+use crate::error::Result;
8+
9+impl Store {
10+ /// Contributions per UTC day by `user_id` at or after `since` (an RFC 3339 stamp), as
11+ /// `(YYYY-MM-DD, count)` sorted by day ascending; days with no activity are left out.
12+ /// A contribution is a push (ref deletions are left out), an issue or pull request opened
13+ /// (pull requests are `issues` rows with `kind = 'pull'`), or a comment.
14+ pub async fn contribution_counts(
15+ &self,
16+ user_id: &str,
17+ since: &str,
18+ ) -> Result<Vec<(String, u32)>> {
19+ // Parameters: ?1 user, ?2 since, ?3 and ?4 zero oids.
20+ let sql = "SELECT day, COUNT(*) FROM ( \
21+ SELECT substr(created_at, 1, 10) AS day FROM push_events \
22+ WHERE actor_id = ?1 AND created_at >= ?2 AND new_oid NOT IN (?3, ?4) \
23+ UNION ALL \
24+ SELECT substr(created_at, 1, 10) FROM issues \
25+ WHERE author_id = ?1 AND created_at >= ?2 AND kind IN ('issue', 'pull') \
26+ UNION ALL \
27+ SELECT substr(created_at, 1, 10) FROM comments \
28+ WHERE author_id = ?1 AND created_at >= ?2 \
29+ ) GROUP BY day ORDER BY day";
30+ let params = vec![
31+ Value::Text(user_id.to_string()),
32+ Value::Text(since.to_string()),
33+ Value::Text(ZERO_OID_SHA1.to_string()),
34+ Value::Text(ZERO_OID_SHA256.to_string()),
35+ ];
36+
37+ let conn = self.read_conn().await;
38+ let rows = fetch_all(&conn, sql, params, |row| {
39+ Ok((col_text(row, 0)?, col_int(row, 1)?))
40+ })
41+ .await?;
42+ Ok(rows
43+ .into_iter()
44+ .map(|(day, count)| (day, u32::try_from(count).unwrap_or(u32::MAX)))
45+ .collect())
46+ }
47+}
48+
49+#[cfg(test)]
50+mod tests {
51+ use crate::model::Visibility;
52+
53+ use super::*;
54+
55+ const OID: &str = "1111111111111111111111111111111111111111";
56+ const ZERO: &str = ZERO_OID_SHA1;
57+
58+ /// Backdates a row's `created_at`. Issues and comments are stamped "now" by the store.
59+ async fn set_created_at(store: &Store, table: &str, id: &str, at: &str) {
60+ let conn = store.inner.conn.lock().await;
61+ conn.execute(
62+ &format!("UPDATE {table} SET created_at = ?1 WHERE id = ?2"),
63+ (at.to_string(), id.to_string()),
64+ )
65+ .await
66+ .unwrap();
67+ }
68+
69+ #[tokio::test]
70+ async fn counts_pushes_issues_pulls_and_comments_per_day() {
71+ let dir = tempfile::tempdir().unwrap();
72+ let store = Store::open(&dir.path().join("forge.db")).await.unwrap();
73+ let alice = store.create_user("alice", None, "x", false).await.unwrap();
74+ let bob = store.create_user("bob", None, "x", false).await.unwrap();
75+ let demo = store
76+ .create_repo(&alice.id, "demo", "", Visibility::Public, "main")
77+ .await
78+ .unwrap();
79+
80+ // Day one: a push and an issue.
81+ let day1 = "2026-03-01T10:00:00.000000000Z";
82+ let day2 = "2026-03-02T10:00:00.000000000Z";
83+ let before = "2026-02-28T10:00:00.000000000Z";
84+ store
85+ .record_push_event_at(&demo.id, &alice.id, "refs/heads/main", ZERO, OID, day1)
86+ .await
87+ .unwrap();
88+ let issue = store
89+ .create_issue(&demo.id, &alice.id, "first", "")
90+ .await
91+ .unwrap();
92+ set_created_at(&store, "issues", &issue.id, day1).await;
93+
94+ // Day two: a branch update, a deletion (not counted), a pull request, a comment.
95+ store
96+ .record_push_event_at(&demo.id, &alice.id, "refs/heads/main", OID, OID, day2)
97+ .await
98+ .unwrap();
99+ store
100+ .record_push_event_at(&demo.id, &alice.id, "refs/heads/gone", OID, ZERO, day2)
101+ .await
102+ .unwrap();
103+ let pull = store
104+ .create_pull(
105+ &demo.id, &alice.id, "change", "", "main", &demo.id, "topic", OID, OID,
106+ )
107+ .await
108+ .unwrap();
109+ set_created_at(&store, "issues", &pull.issue.id, day2).await;
110+ let comment = store.add_comment(&issue.id, &alice.id, "hi").await.unwrap();
111+ set_created_at(&store, "comments", &comment.id, day2).await;
112+
113+ // Before `since`, and someone else's activity: left out.
114+ store
115+ .record_push_event_at(&demo.id, &alice.id, "refs/heads/main", OID, OID, before)
116+ .await
117+ .unwrap();
118+ store
119+ .record_push_event_at(&demo.id, &bob.id, "refs/heads/bobs", ZERO, OID, day2)
120+ .await
121+ .unwrap();
122+
123+ let since = "2026-03-01T00:00:00.000000000Z";
124+ let counts = store.contribution_counts(&alice.id, since).await.unwrap();
125+ assert_eq!(
126+ counts,
127+ vec![("2026-03-01".to_string(), 2), ("2026-03-02".to_string(), 3)]
128+ );
129+
130+ // `since` filters out the first day.
131+ let later = store
132+ .contribution_counts(&alice.id, "2026-03-02T00:00:00.000000000Z")
133+ .await
134+ .unwrap();
135+ assert_eq!(later, vec![("2026-03-02".to_string(), 3)]);
136+
137+ // A user with nothing in range gets an empty list.
138+ assert!(
139+ store
140+ .contribution_counts(&bob.id, "2026-03-03T00:00:00.000000000Z")
141+ .await
142+ .unwrap()
143+ .is_empty()
144+ );
145+ }
146+}
crates/forge-core/src/store/discussions.rsadded+1226 -0
@@ -0,0 +1,1226 @@
1+//! Discussions and their comments. Numbers come from `repos.next_issue_number`, the counter that
2+//! issues and pull requests share, in the same transaction as the insert. Comments nest one level.
3+//! Locking, author checks and archive checks are the pages' job, since they depend on the viewer.
4+
5+use turso::transaction::Transaction;
6+use turso::{Connection, Row, Value};
7+
8+use super::issues::finish;
9+use super::{
10+ Store, col_int, col_opt_text, col_text, db_err, fetch_all, fetch_opt, insert_err, new_id,
11+};
12+use crate::discussions::{
13+ Discussion, DiscussionCategory, DiscussionComment, DiscussionFilter, DiscussionListItem,
14+ DiscussionNotification, DiscussionReason, validate_comment, validate_discussion,
15+};
16+use crate::error::{Error, Result};
17+use crate::time::now_stamp;
18+
19+const DISCUSSION_COLS: &str = "d.id, d.repo_id, d.number, d.category, d.title, d.body, \
20+ d.author_id, d.answer_comment_id, d.locked, d.comment_count, d.created_at, d.updated_at";
21+const COMMENT_COLS: &str = "c.id, c.discussion_id, c.parent_id, c.author_id, c.body, \
22+ c.created_at, c.updated_at, c.deleted_at";
23+
24+fn map_discussion(row: &Row) -> Result<Discussion> {
25+ let category = col_text(row, 3)?;
26+ Ok(Discussion {
27+ id: col_text(row, 0)?,
28+ repo_id: col_text(row, 1)?,
29+ number: col_int(row, 2)?,
30+ category: DiscussionCategory::parse(&category)
31+ .ok_or_else(|| Error::Database(format!("stored category {category:?}")))?,
32+ title: col_text(row, 4)?,
33+ body: col_text(row, 5)?,
34+ author_id: col_text(row, 6)?,
35+ answer_comment_id: col_opt_text(row, 7)?,
36+ locked: col_int(row, 8)? != 0,
37+ comment_count: col_int(row, 9)?,
38+ created_at: col_text(row, 10)?,
39+ updated_at: col_text(row, 11)?,
40+ })
41+}
42+
43+/// Inbox rows of discussions joined with the discussion, its repo, owner and last actor. `?`
44+/// placeholders are appended by the callers.
45+const DISCUSSION_NOTE_SELECT: &str = "SELECT n.discussion_id, n.repo_id, o.username, r.name, \
46+ d.number, d.title, n.reason, a.username, n.unread, n.updated_at FROM discussion_notifications n \
47+ JOIN discussions d ON d.id = n.discussion_id \
48+ JOIN repos r ON r.id = n.repo_id \
49+ JOIN users o ON o.id = r.owner_id \
50+ LEFT JOIN users a ON a.id = n.last_actor_id";
51+
52+fn map_discussion_note(row: &Row) -> Result<DiscussionNotification> {
53+ let reason = col_text(row, 6)?;
54+ Ok(DiscussionNotification {
55+ discussion_id: col_text(row, 0)?,
56+ repo_id: col_text(row, 1)?,
57+ repo_owner: col_text(row, 2)?,
58+ repo_name: col_text(row, 3)?,
59+ number: col_int(row, 4)?,
60+ title: col_text(row, 5)?,
61+ reason: DiscussionReason::parse(&reason)
62+ .ok_or_else(|| Error::Database(format!("unknown discussion reason {reason:?}")))?,
63+ last_actor_name: col_opt_text(row, 7)?,
64+ unread: col_int(row, 8)? != 0,
65+ updated_at: col_text(row, 9)?,
66+ })
67+}
68+
69+fn map_comment(row: &Row) -> Result<DiscussionComment> {
70+ Ok(DiscussionComment {
71+ id: col_text(row, 0)?,
72+ discussion_id: col_text(row, 1)?,
73+ parent_id: col_opt_text(row, 2)?,
74+ author_id: col_text(row, 3)?,
75+ body: col_text(row, 4)?,
76+ created_at: col_text(row, 5)?,
77+ updated_at: col_text(row, 6)?,
78+ deleted: col_opt_text(row, 7)?.is_some(),
79+ })
80+}
81+
82+/// The discussion with this id, or `NotFound`.
83+async fn discussion_row(conn: &Connection, discussion_id: &str) -> Result<Discussion> {
84+ fetch_opt(
85+ conn,
86+ &format!("SELECT {DISCUSSION_COLS} FROM discussions d WHERE d.id = ?1 LIMIT 1"),
87+ (discussion_id.to_string(),),
88+ map_discussion,
89+ )
90+ .await?
91+ .ok_or(Error::NotFound)
92+}
93+
94+/// Removes the discussion rows of a repo, with their comments, reactions and inbox rows. Called
95+/// from `Store::delete_repo`, children first.
96+pub(super) async fn delete_repo_discussion_rows(tx: &Transaction<'_>, repo_id: &str) -> Result<()> {
97+ let id = repo_id.to_string();
98+ for sql in [
99+ "DELETE FROM reactions WHERE target_kind = 'discussion_comment' AND target_id IN \
100+ (SELECT c.id FROM discussion_comments c JOIN discussions d ON d.id = c.discussion_id \
101+ WHERE d.repo_id = ?1)",
102+ "DELETE FROM reactions WHERE target_kind = 'discussion' AND target_id IN \
103+ (SELECT id FROM discussions WHERE repo_id = ?1)",
104+ "DELETE FROM discussion_notifications WHERE repo_id = ?1",
105+ "DELETE FROM discussion_comments WHERE discussion_id IN \
106+ (SELECT id FROM discussions WHERE repo_id = ?1)",
107+ "DELETE FROM discussions WHERE repo_id = ?1",
108+ ] {
109+ tx.execute(sql, (id.clone(),)).await.map_err(db_err)?;
110+ }
111+ Ok(())
112+}
113+
114+impl Store {
115+ /// Whether the repo has discussions turned on. Off by default.
116+ pub async fn discussions_enabled(&self, repo_id: &str) -> Result<bool> {
117+ let conn = self.read_conn().await;
118+ let flag = fetch_opt(
119+ &conn,
120+ "SELECT discussions_enabled FROM repos WHERE id = ?1 LIMIT 1",
121+ (repo_id.to_string(),),
122+ |row| col_int(row, 0),
123+ )
124+ .await?
125+ .ok_or(Error::NotFound)?;
126+ Ok(flag != 0)
127+ }
128+
129+ /// Turns discussions on or off for the repo. Existing discussions are kept while it is off.
130+ pub async fn set_discussions_enabled(&self, repo_id: &str, enabled: bool) -> Result<()> {
131+ let conn = self.inner.conn.lock().await;
132+ let changed = conn
133+ .execute(
134+ "UPDATE repos SET discussions_enabled = ?2 WHERE id = ?1",
135+ (repo_id.to_string(), i64::from(enabled)),
136+ )
137+ .await
138+ .map_err(db_err)?;
139+ if changed == 0 {
140+ return Err(Error::NotFound);
141+ }
142+ Ok(())
143+ }
144+
145+ /// Creates a discussion with the repo's next number. Title and body are validated.
146+ pub async fn create_discussion(
147+ &self,
148+ repo_id: &str,
149+ author_id: &str,
150+ category: DiscussionCategory,
151+ title: &str,
152+ body: &str,
153+ ) -> Result<Discussion> {
154+ let title = validate_discussion(title, body)?;
155+ let conn = self.inner.conn.lock().await;
156+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
157+ let outcome: Result<Discussion> = async {
158+ let number = fetch_opt(
159+ &tx,
160+ "SELECT next_issue_number FROM repos WHERE id = ?1 LIMIT 1",
161+ (repo_id.to_string(),),
162+ |row| col_int(row, 0),
163+ )
164+ .await?
165+ .ok_or(Error::NotFound)?;
166+ let now = now_stamp();
167+ let id = new_id();
168+ tx.execute(
169+ "INSERT INTO discussions (id, repo_id, number, category, title, body, author_id, \
170+ locked, comment_count, created_at, updated_at) \
171+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0, 0, ?8, ?8)",
172+ (
173+ id.clone(),
174+ repo_id.to_string(),
175+ number,
176+ category.as_str().to_string(),
177+ title.clone(),
178+ body.to_string(),
179+ author_id.to_string(),
180+ now.clone(),
181+ ),
182+ )
183+ .await
184+ .map_err(|e| insert_err(e, "discussion number already taken"))?;
185+ tx.execute(
186+ "UPDATE repos SET next_issue_number = next_issue_number + 1 WHERE id = ?1",
187+ (repo_id.to_string(),),
188+ )
189+ .await
190+ .map_err(db_err)?;
191+ Ok(Discussion {
192+ id,
193+ repo_id: repo_id.to_string(),
194+ number,
195+ category,
196+ title,
197+ body: body.to_string(),
198+ author_id: author_id.to_string(),
199+ answer_comment_id: None,
200+ locked: false,
201+ comment_count: 0,
202+ created_at: now.clone(),
203+ updated_at: now,
204+ })
205+ }
206+ .await;
207+ finish(tx, outcome).await
208+ }
209+
210+ /// The discussion with this number in the repo, if any.
211+ pub async fn discussion_by_number(
212+ &self,
213+ repo_id: &str,
214+ number: i64,
215+ ) -> Result<Option<Discussion>> {
216+ let conn = self.read_conn().await;
217+ fetch_opt(
218+ &conn,
219+ &format!(
220+ "SELECT {DISCUSSION_COLS} FROM discussions d WHERE d.repo_id = ?1 AND d.number = ?2 \
221+ LIMIT 1"
222+ ),
223+ (repo_id.to_string(), number),
224+ map_discussion,
225+ )
226+ .await
227+ }
228+
229+ /// One page of discussions, most recently active first, plus the total number of matches.
230+ pub async fn list_discussions(
231+ &self,
232+ repo_id: &str,
233+ f: &DiscussionFilter,
234+ ) -> Result<(Vec<DiscussionListItem>, u64)> {
235+ let mut clauses: Vec<&str> = vec!["d.repo_id = ?"];
236+ let mut params: Vec<Value> = vec![Value::Text(repo_id.to_string())];
237+ if let Some(category) = f.category {
238+ clauses.push("d.category = ?");
239+ params.push(Value::Text(category.as_str().to_string()));
240+ }
241+ let where_sql = clauses.join(" AND ");
242+ let per_page = f.per_page.clamp(1, 100);
243+ let page = f.page.clamp(1, 1000);
244+ let offset = i64::from(page - 1) * i64::from(per_page);
245+
246+ let conn = self.read_conn().await;
247+ let total = fetch_opt(
248+ &conn,
249+ &format!("SELECT COUNT(*) FROM discussions d WHERE {where_sql}"),
250+ params.clone(),
251+ |row| col_int(row, 0),
252+ )
253+ .await?
254+ .unwrap_or(0);
255+
256+ let mut page_params = params;
257+ page_params.push(Value::Integer(i64::from(per_page)));
258+ page_params.push(Value::Integer(offset));
259+ let items = fetch_all(
260+ &conn,
261+ &format!(
262+ "SELECT {DISCUSSION_COLS}, u.username FROM discussions d \
263+ JOIN users u ON u.id = d.author_id WHERE {where_sql} \
264+ ORDER BY d.updated_at DESC, d.number DESC LIMIT ? OFFSET ?"
265+ ),
266+ page_params,
267+ |row| {
268+ Ok(DiscussionListItem {
269+ discussion: map_discussion(row)?,
270+ author: col_text(row, 12)?,
271+ })
272+ },
273+ )
274+ .await?;
275+ Ok((items, u64::try_from(total).unwrap_or(0)))
276+ }
277+
278+ /// Replaces the title and body of a discussion.
279+ pub async fn edit_discussion(
280+ &self,
281+ discussion_id: &str,
282+ title: &str,
283+ body: &str,
284+ ) -> Result<()> {
285+ let title = validate_discussion(title, body)?;
286+ let conn = self.inner.conn.lock().await;
287+ let changed = conn
288+ .execute(
289+ "UPDATE discussions SET title = ?2, body = ?3, updated_at = ?4 WHERE id = ?1",
290+ (
291+ discussion_id.to_string(),
292+ title,
293+ body.to_string(),
294+ now_stamp(),
295+ ),
296+ )
297+ .await
298+ .map_err(db_err)?;
299+ if changed == 0 {
300+ return Err(Error::NotFound);
301+ }
302+ Ok(())
303+ }
304+
305+ /// Locks or unlocks a discussion. Returns `false` when it was already in that state.
306+ pub async fn set_discussion_locked(&self, discussion_id: &str, locked: bool) -> Result<bool> {
307+ let conn = self.inner.conn.lock().await;
308+ let changed = conn
309+ .execute(
310+ "UPDATE discussions SET locked = ?2 WHERE id = ?1 AND locked != ?2",
311+ (discussion_id.to_string(), i64::from(locked)),
312+ )
313+ .await
314+ .map_err(db_err)?;
315+ if changed > 0 {
316+ return Ok(true);
317+ }
318+ discussion_row(&conn, discussion_id).await?;
319+ Ok(false)
320+ }
321+
322+ /// Marks a comment as the answer, or clears the mark with `None`. Only Q&A discussions have an
323+ /// answer, and the comment must be a live comment of that discussion.
324+ pub async fn set_discussion_answer(
325+ &self,
326+ discussion_id: &str,
327+ comment_id: Option<&str>,
328+ ) -> Result<()> {
329+ let conn = self.inner.conn.lock().await;
330+ let discussion = discussion_row(&conn, discussion_id).await?;
331+ if discussion.category != DiscussionCategory::Qa {
332+ return Err(Error::Invalid("only Q&A discussions have an answer".into()));
333+ }
334+ if let Some(comment_id) = comment_id {
335+ let live = fetch_opt(
336+ &conn,
337+ "SELECT 1 FROM discussion_comments WHERE id = ?1 AND discussion_id = ?2 \
338+ AND deleted_at IS NULL LIMIT 1",
339+ (comment_id.to_string(), discussion_id.to_string()),
340+ |row| col_int(row, 0),
341+ )
342+ .await?;
343+ if live.is_none() {
344+ return Err(Error::Invalid(
345+ "the answer must be a comment on this discussion".into(),
346+ ));
347+ }
348+ }
349+ conn.execute(
350+ "UPDATE discussions SET answer_comment_id = ?2 WHERE id = ?1",
351+ (discussion_id.to_string(), comment_id.map(str::to_string)),
352+ )
353+ .await
354+ .map_err(db_err)?;
355+ Ok(())
356+ }
357+
358+ /// Deletes a discussion with its comments, reactions and inbox rows.
359+ pub async fn delete_discussion(&self, discussion_id: &str) -> Result<()> {
360+ let conn = self.inner.conn.lock().await;
361+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
362+ let outcome: Result<()> = async {
363+ discussion_row(&tx, discussion_id).await?;
364+ let id = discussion_id.to_string();
365+ for sql in [
366+ "DELETE FROM reactions WHERE target_kind = 'discussion_comment' AND target_id IN \
367+ (SELECT id FROM discussion_comments WHERE discussion_id = ?1)",
368+ "DELETE FROM reactions WHERE target_kind = 'discussion' AND target_id = ?1",
369+ "DELETE FROM discussion_notifications WHERE discussion_id = ?1",
370+ "DELETE FROM discussion_comments WHERE discussion_id = ?1",
371+ "DELETE FROM discussions WHERE id = ?1",
372+ ] {
373+ tx.execute(sql, (id.clone(),)).await.map_err(db_err)?;
374+ }
375+ Ok(())
376+ }
377+ .await;
378+ finish(tx, outcome).await
379+ }
380+
381+ /// Adds a comment, or a reply when `parent_id` names a top-level live comment of the same
382+ /// discussion. A reply to a reply is refused. Bumps the discussion's count and `updated_at`.
383+ /// A locked discussion takes comments only from `triage` callers (Triage or higher). A comment
384+ /// by someone other than the author also writes an inbox row for the author, reason `reply`.
385+ pub async fn add_discussion_comment(
386+ &self,
387+ discussion_id: &str,
388+ author_id: &str,
389+ parent_id: Option<&str>,
390+ body: &str,
391+ triage: bool,
392+ ) -> Result<DiscussionComment> {
393+ validate_comment(body)?;
394+ let conn = self.inner.conn.lock().await;
395+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
396+ let outcome: Result<DiscussionComment> = async {
397+ let discussion = discussion_row(&tx, discussion_id).await?;
398+ if discussion.locked && !triage {
399+ return Err(Error::Forbidden("this discussion is locked".into()));
400+ }
401+ if let Some(parent) = parent_id {
402+ let row = fetch_opt(
403+ &tx,
404+ &format!(
405+ "SELECT {COMMENT_COLS} FROM discussion_comments c WHERE c.id = ?1 LIMIT 1"
406+ ),
407+ (parent.to_string(),),
408+ map_comment,
409+ )
410+ .await?
411+ .ok_or(Error::NotFound)?;
412+ if row.discussion_id != discussion_id {
413+ return Err(Error::NotFound);
414+ }
415+ if row.parent_id.is_some() {
416+ return Err(Error::Invalid("replies nest one level only".into()));
417+ }
418+ if row.deleted {
419+ return Err(Error::Invalid("this comment was deleted".into()));
420+ }
421+ }
422+ let now = now_stamp();
423+ let comment = DiscussionComment {
424+ id: new_id(),
425+ discussion_id: discussion_id.to_string(),
426+ parent_id: parent_id.map(str::to_string),
427+ author_id: author_id.to_string(),
428+ body: body.to_string(),
429+ created_at: now.clone(),
430+ updated_at: now.clone(),
431+ deleted: false,
432+ };
433+ tx.execute(
434+ "INSERT INTO discussion_comments (id, discussion_id, parent_id, author_id, body, \
435+ created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)",
436+ (
437+ comment.id.clone(),
438+ comment.discussion_id.clone(),
439+ comment.parent_id.clone(),
440+ comment.author_id.clone(),
441+ comment.body.clone(),
442+ now.clone(),
443+ ),
444+ )
445+ .await
446+ .map_err(db_err)?;
447+ tx.execute(
448+ "UPDATE discussions SET comment_count = comment_count + 1, updated_at = ?2 \
449+ WHERE id = ?1",
450+ (discussion_id.to_string(), now.clone()),
451+ )
452+ .await
453+ .map_err(db_err)?;
454+ if discussion.author_id != author_id {
455+ tx.execute(
456+ "INSERT INTO discussion_notifications (user_id, discussion_id, repo_id, reason, \
457+ last_actor_id, unread, updated_at) VALUES (?1, ?2, ?3, 'reply', ?4, 1, ?5) \
458+ ON CONFLICT (user_id, discussion_id) DO UPDATE SET \
459+ reason = CASE WHEN discussion_notifications.reason = 'mention' THEN 'mention' \
460+ ELSE 'reply' END, last_actor_id = excluded.last_actor_id, unread = 1, \
461+ updated_at = excluded.updated_at",
462+ (
463+ discussion.author_id.clone(),
464+ discussion_id.to_string(),
465+ discussion.repo_id.clone(),
466+ author_id.to_string(),
467+ now,
468+ ),
469+ )
470+ .await
471+ .map_err(db_err)?;
472+ }
473+ Ok(comment)
474+ }
475+ .await;
476+ finish(tx, outcome).await
477+ }
478+
479+ /// The comment with this id in this discussion, if any. Deleted comments are returned too.
480+ pub async fn discussion_comment(
481+ &self,
482+ discussion_id: &str,
483+ comment_id: &str,
484+ ) -> Result<Option<DiscussionComment>> {
485+ let conn = self.read_conn().await;
486+ fetch_opt(
487+ &conn,
488+ &format!(
489+ "SELECT {COMMENT_COLS} FROM discussion_comments c WHERE c.id = ?1 \
490+ AND c.discussion_id = ?2 LIMIT 1"
491+ ),
492+ (comment_id.to_string(), discussion_id.to_string()),
493+ map_comment,
494+ )
495+ .await
496+ }
497+
498+ /// Replaces the body of a live comment.
499+ pub async fn edit_discussion_comment(&self, comment_id: &str, body: &str) -> Result<()> {
500+ validate_comment(body)?;
501+ let conn = self.inner.conn.lock().await;
502+ let changed = conn
503+ .execute(
504+ "UPDATE discussion_comments SET body = ?2, updated_at = ?3 \
505+ WHERE id = ?1 AND deleted_at IS NULL",
506+ (comment_id.to_string(), body.to_string(), now_stamp()),
507+ )
508+ .await
509+ .map_err(db_err)?;
510+ if changed == 0 {
511+ return Err(Error::NotFound);
512+ }
513+ Ok(())
514+ }
515+
516+ /// Deletes a comment. The row stays, so replies keep their parent: the body is emptied and
517+ /// `deleted_at` is set. Its reactions go, and it stops being the answer. A comment that is
518+ /// already deleted is left as it is.
519+ pub async fn delete_discussion_comment(&self, comment_id: &str) -> Result<()> {
520+ let conn = self.inner.conn.lock().await;
521+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
522+ let outcome: Result<()> = async {
523+ let comment = fetch_opt(
524+ &tx,
525+ &format!("SELECT {COMMENT_COLS} FROM discussion_comments c WHERE c.id = ?1 LIMIT 1"),
526+ (comment_id.to_string(),),
527+ map_comment,
528+ )
529+ .await?
530+ .ok_or(Error::NotFound)?;
531+ if comment.deleted {
532+ return Ok(());
533+ }
534+ let now = now_stamp();
535+ tx.execute(
536+ "UPDATE discussion_comments SET body = '', deleted_at = ?2, updated_at = ?2 \
537+ WHERE id = ?1",
538+ (comment_id.to_string(), now.clone()),
539+ )
540+ .await
541+ .map_err(db_err)?;
542+ tx.execute(
543+ "DELETE FROM reactions WHERE target_kind = 'discussion_comment' AND target_id = ?1",
544+ (comment_id.to_string(),),
545+ )
546+ .await
547+ .map_err(db_err)?;
548+ tx.execute(
549+ "UPDATE discussions SET comment_count = CASE WHEN comment_count > 0 \
550+ THEN comment_count - 1 ELSE 0 END, updated_at = ?2 WHERE id = ?1",
551+ (comment.discussion_id.clone(), now),
552+ )
553+ .await
554+ .map_err(db_err)?;
555+ tx.execute(
556+ "UPDATE discussions SET answer_comment_id = NULL WHERE id = ?1 AND answer_comment_id = ?2",
557+ (comment.discussion_id, comment_id.to_string()),
558+ )
559+ .await
560+ .map_err(db_err)?;
561+ Ok(())
562+ }
563+ .await;
564+ finish(tx, outcome).await
565+ }
566+
567+ /// Every comment of a discussion, oldest first, replies included. The page groups replies
568+ /// under their parent.
569+ pub async fn list_discussion_comments(
570+ &self,
571+ discussion_id: &str,
572+ ) -> Result<Vec<DiscussionComment>> {
573+ let conn = self.read_conn().await;
574+ fetch_all(
575+ &conn,
576+ &format!(
577+ "SELECT {COMMENT_COLS} FROM discussion_comments c WHERE c.discussion_id = ?1 \
578+ ORDER BY c.created_at, c.id"
579+ ),
580+ (discussion_id.to_string(),),
581+ map_comment,
582+ )
583+ .await
584+ }
585+
586+ /// Adds or refreshes an inbox row for each user in `user_ids` (mentioned in a discussion). The
587+ /// row goes back to unread and records who mentioned them.
588+ pub async fn record_discussion_mentions(
589+ &self,
590+ repo_id: &str,
591+ discussion_id: &str,
592+ actor_id: &str,
593+ user_ids: &[String],
594+ ) -> Result<()> {
595+ if user_ids.is_empty() {
596+ return Ok(());
597+ }
598+ let conn = self.inner.conn.lock().await;
599+ let now = now_stamp();
600+ for user_id in user_ids {
601+ conn.execute(
602+ "INSERT INTO discussion_notifications (user_id, discussion_id, repo_id, reason, \
603+ last_actor_id, unread, updated_at) VALUES (?1, ?2, ?3, 'mention', ?4, 1, ?5) \
604+ ON CONFLICT (user_id, discussion_id) DO UPDATE SET reason = 'mention', \
605+ last_actor_id = excluded.last_actor_id, unread = 1, updated_at = excluded.updated_at",
606+ (
607+ user_id.clone(),
608+ discussion_id.to_string(),
609+ repo_id.to_string(),
610+ actor_id.to_string(),
611+ now.clone(),
612+ ),
613+ )
614+ .await
615+ .map_err(db_err)?;
616+ }
617+ Ok(())
618+ }
619+
620+ /// Unread inbox rows of a user for discussions: `(discussion_id, repo_id)` pairs, newest first.
621+ pub async fn unread_discussion_mentions(&self, user_id: &str) -> Result<Vec<(String, String)>> {
622+ let conn = self.read_conn().await;
623+ fetch_all(
624+ &conn,
625+ "SELECT discussion_id, repo_id FROM discussion_notifications \
626+ WHERE user_id = ?1 AND unread = 1 ORDER BY updated_at DESC",
627+ (user_id.to_string(),),
628+ |row| Ok((col_text(row, 0)?, col_text(row, 1)?)),
629+ )
630+ .await
631+ }
632+
633+ /// The user's inbox rows for discussions, newest first. `unread_only`, `before` and `limit`
634+ /// mean what they mean in `list_notifications`.
635+ pub async fn list_discussion_notifications(
636+ &self,
637+ user_id: &str,
638+ unread_only: bool,
639+ before: Option<&str>,
640+ limit: u32,
641+ ) -> Result<Vec<DiscussionNotification>> {
642+ let mut sql = format!("{DISCUSSION_NOTE_SELECT} WHERE n.user_id = ?");
643+ let mut params = vec![Value::Text(user_id.to_string())];
644+ if unread_only {
645+ sql.push_str(" AND n.unread = 1");
646+ }
647+ if let Some(stamp) = before {
648+ sql.push_str(" AND n.updated_at < ?");
649+ params.push(Value::Text(stamp.to_string()));
650+ }
651+ sql.push_str(" ORDER BY n.updated_at DESC, n.discussion_id DESC LIMIT ?");
652+ params.push(Value::Integer(i64::from(limit.clamp(1, 200))));
653+ let conn = self.read_conn().await;
654+ fetch_all(&conn, &sql, params, map_discussion_note).await
655+ }
656+
657+ /// The user's inbox row for one discussion, if there is one.
658+ pub async fn discussion_notification(
659+ &self,
660+ user_id: &str,
661+ discussion_id: &str,
662+ ) -> Result<Option<DiscussionNotification>> {
663+ let conn = self.read_conn().await;
664+ fetch_opt(
665+ &conn,
666+ &format!(
667+ "{DISCUSSION_NOTE_SELECT} WHERE n.user_id = ?1 AND n.discussion_id = ?2 LIMIT 1"
668+ ),
669+ (user_id.to_string(), discussion_id.to_string()),
670+ map_discussion_note,
671+ )
672+ .await
673+ }
674+
675+ /// Marks the user's inbox row for one discussion read. Another user's row changes nothing.
676+ pub async fn mark_discussion_notification_read(
677+ &self,
678+ user_id: &str,
679+ discussion_id: &str,
680+ ) -> Result<()> {
681+ let conn = self.inner.conn.lock().await;
682+ conn.execute(
683+ "UPDATE discussion_notifications SET unread = 0 WHERE user_id = ?1 AND discussion_id = ?2",
684+ (user_id.to_string(), discussion_id.to_string()),
685+ )
686+ .await
687+ .map_err(db_err)?;
688+ Ok(())
689+ }
690+
691+ /// Marks the user's unread discussion rows read, in one repo when `repo_id` is given, otherwise
692+ /// all of them.
693+ pub async fn mark_all_discussion_notifications_read(
694+ &self,
695+ user_id: &str,
696+ repo_id: Option<&str>,
697+ ) -> Result<()> {
698+ let conn = self.inner.conn.lock().await;
699+ let result = match repo_id {
700+ Some(repo) => {
701+ conn.execute(
702+ "UPDATE discussion_notifications SET unread = 0 WHERE user_id = ?1 AND repo_id = ?2 AND unread = 1",
703+ (user_id.to_string(), repo.to_string()),
704+ )
705+ .await
706+ }
707+ None => {
708+ conn.execute(
709+ "UPDATE discussion_notifications SET unread = 0 WHERE user_id = ?1 AND unread = 1",
710+ (user_id.to_string(),),
711+ )
712+ .await
713+ }
714+ };
715+ result.map_err(db_err)?;
716+ Ok(())
717+ }
718+}
719+
720+#[cfg(test)]
721+mod tests {
722+ use super::*;
723+ use crate::model::{User, Visibility};
724+
725+ struct Fixture {
726+ store: Store,
727+ alice: User,
728+ bob: User,
729+ repo_id: String,
730+ _dir: tempfile::TempDir,
731+ }
732+
733+ async fn fixture() -> Fixture {
734+ let dir = tempfile::tempdir().unwrap();
735+ let store = Store::open(&dir.path().join("forge.db")).await.unwrap();
736+ let alice = store
737+ .create_user("Alice", None, "hash", false)
738+ .await
739+ .unwrap();
740+ let bob = store.create_user("Bob", None, "hash", false).await.unwrap();
741+ let repo = store
742+ .create_repo(&alice.id, "proj", "", Visibility::Public, "main")
743+ .await
744+ .unwrap();
745+ Fixture {
746+ store,
747+ alice,
748+ bob,
749+ repo_id: repo.id,
750+ _dir: dir,
751+ }
752+ }
753+
754+ fn filter(category: Option<DiscussionCategory>) -> DiscussionFilter {
755+ DiscussionFilter {
756+ category,
757+ page: 1,
758+ per_page: 25,
759+ }
760+ }
761+
762+ #[tokio::test]
763+ async fn discussions_are_off_by_default_and_toggle() {
764+ let fx = fixture().await;
765+ assert!(!fx.store.discussions_enabled(&fx.repo_id).await.unwrap());
766+ fx.store
767+ .set_discussions_enabled(&fx.repo_id, true)
768+ .await
769+ .unwrap();
770+ assert!(fx.store.discussions_enabled(&fx.repo_id).await.unwrap());
771+ assert!(matches!(
772+ fx.store.set_discussions_enabled("missing", true).await,
773+ Err(Error::NotFound)
774+ ));
775+ }
776+
777+ #[tokio::test]
778+ async fn numbers_share_the_issue_counter() {
779+ let fx = fixture().await;
780+ let issue = fx
781+ .store
782+ .create_issue(&fx.repo_id, &fx.alice.id, "first", "")
783+ .await
784+ .unwrap();
785+ let d1 = fx
786+ .store
787+ .create_discussion(
788+ &fx.repo_id,
789+ &fx.alice.id,
790+ DiscussionCategory::General,
791+ "a",
792+ "",
793+ )
794+ .await
795+ .unwrap();
796+ let issue2 = fx
797+ .store
798+ .create_issue(&fx.repo_id, &fx.alice.id, "second", "")
799+ .await
800+ .unwrap();
801+ assert_eq!(issue.number, 1);
802+ assert_eq!(d1.number, 2);
803+ assert_eq!(issue2.number, 3);
804+ let found = fx.store.discussion_by_number(&fx.repo_id, 2).await.unwrap();
805+ assert_eq!(found.map(|d| d.title), Some("a".to_string()));
806+ }
807+
808+ #[tokio::test]
809+ async fn create_rejects_blank_title_and_lists_by_category() {
810+ let fx = fixture().await;
811+ assert!(matches!(
812+ fx.store
813+ .create_discussion(
814+ &fx.repo_id,
815+ &fx.alice.id,
816+ DiscussionCategory::Ideas,
817+ " ",
818+ ""
819+ )
820+ .await,
821+ Err(Error::Invalid(_))
822+ ));
823+ fx.store
824+ .create_discussion(
825+ &fx.repo_id,
826+ &fx.alice.id,
827+ DiscussionCategory::Ideas,
828+ "idea",
829+ "",
830+ )
831+ .await
832+ .unwrap();
833+ fx.store
834+ .create_discussion(
835+ &fx.repo_id,
836+ &fx.bob.id,
837+ DiscussionCategory::Qa,
838+ "question",
839+ "",
840+ )
841+ .await
842+ .unwrap();
843+ let (all, total) = fx
844+ .store
845+ .list_discussions(&fx.repo_id, &filter(None))
846+ .await
847+ .unwrap();
848+ assert_eq!((all.len(), total), (2, 2));
849+ let (qa, total) = fx
850+ .store
851+ .list_discussions(&fx.repo_id, &filter(Some(DiscussionCategory::Qa)))
852+ .await
853+ .unwrap();
854+ assert_eq!(total, 1);
855+ assert_eq!(qa[0].discussion.title, "question");
856+ assert_eq!(qa[0].author, "Bob");
857+ }
858+
859+ #[tokio::test]
860+ async fn comments_nest_one_level_and_count() {
861+ let fx = fixture().await;
862+ let d = fx
863+ .store
864+ .create_discussion(
865+ &fx.repo_id,
866+ &fx.alice.id,
867+ DiscussionCategory::General,
868+ "t",
869+ "",
870+ )
871+ .await
872+ .unwrap();
873+ let top = fx
874+ .store
875+ .add_discussion_comment(&d.id, &fx.bob.id, None, "hello", false)
876+ .await
877+ .unwrap();
878+ let reply = fx
879+ .store
880+ .add_discussion_comment(&d.id, &fx.alice.id, Some(&top.id), "hi back", false)
881+ .await
882+ .unwrap();
883+ assert_eq!(reply.parent_id.as_deref(), Some(top.id.as_str()));
884+ let nested = fx
885+ .store
886+ .add_discussion_comment(&d.id, &fx.bob.id, Some(&reply.id), "too deep", false)
887+ .await;
888+ assert!(matches!(nested, Err(Error::Invalid(_))));
889+ assert!(matches!(
890+ fx.store
891+ .add_discussion_comment(&d.id, &fx.bob.id, None, " ", false)
892+ .await,
893+ Err(Error::Invalid(_))
894+ ));
895+ let stored = fx
896+ .store
897+ .discussion_by_number(&fx.repo_id, d.number)
898+ .await
899+ .unwrap();
900+ assert_eq!(stored.map(|d| d.comment_count), Some(2));
901+ let all = fx.store.list_discussion_comments(&d.id).await.unwrap();
902+ assert_eq!(all.len(), 2);
903+ }
904+
905+ #[tokio::test]
906+ async fn answer_only_in_qa_and_only_a_live_comment_of_it() {
907+ let fx = fixture().await;
908+ let qa = fx
909+ .store
910+ .create_discussion(&fx.repo_id, &fx.alice.id, DiscussionCategory::Qa, "q", "")
911+ .await
912+ .unwrap();
913+ let general = fx
914+ .store
915+ .create_discussion(
916+ &fx.repo_id,
917+ &fx.alice.id,
918+ DiscussionCategory::General,
919+ "g",
920+ "",
921+ )
922+ .await
923+ .unwrap();
924+ let c = fx
925+ .store
926+ .add_discussion_comment(&qa.id, &fx.bob.id, None, "answer", false)
927+ .await
928+ .unwrap();
929+ let g = fx
930+ .store
931+ .add_discussion_comment(&general.id, &fx.bob.id, None, "x", false)
932+ .await
933+ .unwrap();
934+ assert!(matches!(
935+ fx.store
936+ .set_discussion_answer(&general.id, Some(&g.id))
937+ .await,
938+ Err(Error::Invalid(_))
939+ ));
940+ assert!(matches!(
941+ fx.store.set_discussion_answer(&qa.id, Some(&g.id)).await,
942+ Err(Error::Invalid(_))
943+ ));
944+ fx.store
945+ .set_discussion_answer(&qa.id, Some(&c.id))
946+ .await
947+ .unwrap();
948+ let stored = fx
949+ .store
950+ .discussion_by_number(&fx.repo_id, qa.number)
951+ .await
952+ .unwrap();
953+ assert_eq!(stored.and_then(|d| d.answer_comment_id), Some(c.id.clone()));
954+ fx.store.delete_discussion_comment(&c.id).await.unwrap();
955+ let stored = fx
956+ .store
957+ .discussion_by_number(&fx.repo_id, qa.number)
958+ .await
959+ .unwrap();
960+ assert_eq!(stored.and_then(|d| d.answer_comment_id), None);
961+ }
962+
963+ #[tokio::test]
964+ async fn lock_reports_changes_and_delete_comment_keeps_replies() {
965+ let fx = fixture().await;
966+ let d = fx
967+ .store
968+ .create_discussion(
969+ &fx.repo_id,
970+ &fx.alice.id,
971+ DiscussionCategory::General,
972+ "t",
973+ "",
974+ )
975+ .await
976+ .unwrap();
977+ let top = fx
978+ .store
979+ .add_discussion_comment(&d.id, &fx.bob.id, None, "gone soon", false)
980+ .await
981+ .unwrap();
982+ fx.store
983+ .add_discussion_comment(&d.id, &fx.alice.id, Some(&top.id), "reply stays", false)
984+ .await
985+ .unwrap();
986+ assert!(fx.store.set_discussion_locked(&d.id, true).await.unwrap());
987+ assert!(!fx.store.set_discussion_locked(&d.id, true).await.unwrap());
988+ assert!(matches!(
989+ fx.store.set_discussion_locked("missing", true).await,
990+ Err(Error::NotFound)
991+ ));
992+ fx.store.delete_discussion_comment(&top.id).await.unwrap();
993+ let gone = fx
994+ .store
995+ .discussion_comment(&d.id, &top.id)
996+ .await
997+ .unwrap()
998+ .unwrap();
999+ assert!(gone.deleted);
1000+ assert!(gone.body.is_empty());
1001+ let all = fx.store.list_discussion_comments(&d.id).await.unwrap();
1002+ assert_eq!(all.len(), 2);
1003+ assert!(all.iter().any(|c| c.body == "reply stays" && !c.deleted));
1004+ }
1005+
1006+ #[tokio::test]
1007+ async fn edit_and_delete_discussion_remove_its_rows() {
1008+ let fx = fixture().await;
1009+ let d = fx
1010+ .store
1011+ .create_discussion(
1012+ &fx.repo_id,
1013+ &fx.alice.id,
1014+ DiscussionCategory::Ideas,
1015+ "old",
1016+ "body",
1017+ )
1018+ .await
1019+ .unwrap();
1020+ fx.store
1021+ .edit_discussion(&d.id, "new", "changed")
1022+ .await
1023+ .unwrap();
1024+ let edited = fx
1025+ .store
1026+ .discussion_by_number(&fx.repo_id, d.number)
1027+ .await
1028+ .unwrap();
1029+ assert_eq!(edited.map(|d| d.title), Some("new".to_string()));
1030+ fx.store
1031+ .add_discussion_comment(&d.id, &fx.bob.id, None, "c", false)
1032+ .await
1033+ .unwrap();
1034+ fx.store.delete_discussion(&d.id).await.unwrap();
1035+ assert!(
1036+ fx.store
1037+ .discussion_by_number(&fx.repo_id, d.number)
1038+ .await
1039+ .unwrap()
1040+ .is_none()
1041+ );
1042+ assert!(
1043+ fx.store
1044+ .list_discussion_comments(&d.id)
1045+ .await
1046+ .unwrap()
1047+ .is_empty()
1048+ );
1049+ assert!(matches!(
1050+ fx.store.delete_discussion(&d.id).await,
1051+ Err(Error::NotFound)
1052+ ));
1053+ }
1054+
1055+ #[tokio::test]
1056+ async fn mentions_go_unread_and_refresh_on_repeat() {
1057+ let fx = fixture().await;
1058+ let d = fx
1059+ .store
1060+ .create_discussion(
1061+ &fx.repo_id,
1062+ &fx.alice.id,
1063+ DiscussionCategory::General,
1064+ "t",
1065+ "",
1066+ )
1067+ .await
1068+ .unwrap();
1069+ fx.store
1070+ .record_discussion_mentions(
1071+ &fx.repo_id,
1072+ &d.id,
1073+ &fx.alice.id,
1074+ std::slice::from_ref(&fx.bob.id),
1075+ )
1076+ .await
1077+ .unwrap();
1078+ fx.store
1079+ .record_discussion_mentions(
1080+ &fx.repo_id,
1081+ &d.id,
1082+ &fx.alice.id,
1083+ std::slice::from_ref(&fx.bob.id),
1084+ )
1085+ .await
1086+ .unwrap();
1087+ let rows = fx
1088+ .store
1089+ .unread_discussion_mentions(&fx.bob.id)
1090+ .await
1091+ .unwrap();
1092+ assert_eq!(rows, vec![(d.id.clone(), fx.repo_id.clone())]);
1093+ assert!(
1094+ fx.store
1095+ .unread_discussion_mentions(&fx.alice.id)
1096+ .await
1097+ .unwrap()
1098+ .is_empty()
1099+ );
1100+ }
1101+
1102+ #[tokio::test]
1103+ async fn locked_discussion_takes_comments_only_from_triage() {
1104+ let fx = fixture().await;
1105+ let d = fx
1106+ .store
1107+ .create_discussion(
1108+ &fx.repo_id,
1109+ &fx.alice.id,
1110+ DiscussionCategory::General,
1111+ "t",
1112+ "",
1113+ )
1114+ .await
1115+ .unwrap();
1116+ fx.store.set_discussion_locked(&d.id, true).await.unwrap();
1117+ let refused = fx
1118+ .store
1119+ .add_discussion_comment(&d.id, &fx.bob.id, None, "hi", false)
1120+ .await;
1121+ assert!(matches!(refused, Err(Error::Forbidden(_))));
1122+ let stored = fx
1123+ .store
1124+ .discussion_by_number(&fx.repo_id, d.number)
1125+ .await
1126+ .unwrap()
1127+ .unwrap();
1128+ assert_eq!(stored.comment_count, 0);
1129+ fx.store
1130+ .add_discussion_comment(&d.id, &fx.bob.id, None, "maintainer note", true)
1131+ .await
1132+ .unwrap();
1133+ fx.store.set_discussion_locked(&d.id, false).await.unwrap();
1134+ fx.store
1135+ .add_discussion_comment(&d.id, &fx.bob.id, None, "open again", false)
1136+ .await
1137+ .unwrap();
1138+ }
1139+
1140+ #[tokio::test]
1141+ async fn replies_and_mentions_reach_the_inbox() {
1142+ let fx = fixture().await;
1143+ let d = fx
1144+ .store
1145+ .create_discussion(&fx.repo_id, &fx.alice.id, DiscussionCategory::Qa, "Q", "")
1146+ .await
1147+ .unwrap();
1148+ // Bob's comment on Alice's discussion gives Alice a reply row.
1149+ fx.store
1150+ .add_discussion_comment(&d.id, &fx.bob.id, None, "answer", false)
1151+ .await
1152+ .unwrap();
1153+ // Alice's own comment gives her nothing.
1154+ fx.store
1155+ .add_discussion_comment(&d.id, &fx.alice.id, None, "thanks", false)
1156+ .await
1157+ .unwrap();
1158+ let rows = fx
1159+ .store
1160+ .list_discussion_notifications(&fx.alice.id, true, None, 50)
1161+ .await
1162+ .unwrap();
1163+ assert_eq!(rows.len(), 1);
1164+ assert_eq!(rows[0].reason, DiscussionReason::Reply);
1165+ assert_eq!(rows[0].title, "Q");
1166+ assert_eq!(rows[0].number, d.number);
1167+ assert_eq!(rows[0].repo_owner, "Alice");
1168+ assert_eq!(rows[0].last_actor_name.as_deref(), Some("Bob"));
1169+
1170+ // A mention upgrades the row, and a later reply does not downgrade it.
1171+ fx.store
1172+ .record_discussion_mentions(
1173+ &fx.repo_id,
1174+ &d.id,
1175+ &fx.bob.id,
1176+ std::slice::from_ref(&fx.alice.id),
1177+ )
1178+ .await
1179+ .unwrap();
1180+ fx.store
1181+ .add_discussion_comment(&d.id, &fx.bob.id, None, "again", false)
1182+ .await
1183+ .unwrap();
1184+ let rows = fx
1185+ .store
1186+ .list_discussion_notifications(&fx.alice.id, true, None, 50)
1187+ .await
1188+ .unwrap();
1189+ assert_eq!(rows.len(), 1);
1190+ assert_eq!(rows[0].reason, DiscussionReason::Mention);
1191+ assert_eq!(fx.store.unread_count(&fx.alice.id).await.unwrap(), 1);
1192+
1193+ // Another user's mark-read changes nothing.
1194+ fx.store
1195+ .mark_discussion_notification_read(&fx.bob.id, &d.id)
1196+ .await
1197+ .unwrap();
1198+ assert_eq!(fx.store.unread_count(&fx.alice.id).await.unwrap(), 1);
1199+ fx.store
1200+ .mark_all_discussion_notifications_read(&fx.alice.id, Some(&fx.repo_id))
1201+ .await
1202+ .unwrap();
1203+ assert_eq!(fx.store.unread_count(&fx.alice.id).await.unwrap(), 0);
1204+ assert!(
1205+ fx.store
1206+ .list_discussion_notifications(&fx.alice.id, true, None, 50)
1207+ .await
1208+ .unwrap()
1209+ .is_empty()
1210+ );
1211+ let all = fx
1212+ .store
1213+ .list_discussion_notifications(&fx.alice.id, false, None, 50)
1214+ .await
1215+ .unwrap();
1216+ assert_eq!(all.len(), 1);
1217+ assert!(!all[0].unread);
1218+ assert!(
1219+ fx.store
1220+ .discussion_notification(&fx.bob.id, &d.id)
1221+ .await
1222+ .unwrap()
1223+ .is_none()
1224+ );
1225+ }
1226+}
crates/forge-core/src/store/edit_history_read.rsadded+104 -0
@@ -0,0 +1,104 @@
1+//! Reads of the edit history written by `edit_issue`, `edit_comment` and `edit_review_comment`.
2+//! Each row holds the text an edit replaced, so a row's text is the version that was live before
3+//! `edited_at`, and its editor is the person who replaced it.
4+
5+use std::collections::HashMap;
6+
7+use super::{Store, col_int, col_opt_text, col_text, fetch_all, fetch_opt};
8+use crate::error::Result;
9+
10+/// Longest id list sent in one `IN (...)` query.
11+const CHUNK: usize = 500;
12+
13+/// One previous version of an issue, pull request, comment or review comment.
14+#[derive(Debug, Clone, PartialEq)]
15+pub struct EditVersion {
16+ pub id: String,
17+ /// The username of the person who made the edit that replaced this version.
18+ pub editor_name: String,
19+ pub edited_at: String,
20+ /// The title it had, for issues and pull requests.
21+ pub old_title: Option<String>,
22+ pub old_body: String,
23+}
24+
25+fn placeholders(n: usize) -> String {
26+ vec!["?"; n].join(",")
27+}
28+
29+impl Store {
30+ /// The previous versions of one target, newest first.
31+ pub async fn edit_history(
32+ &self,
33+ target_kind: &str,
34+ target_id: &str,
35+ ) -> Result<Vec<EditVersion>> {
36+ let conn = self.read_conn().await;
37+ fetch_all(
38+ &conn,
39+ "SELECT h.id, u.username, h.edited_at, h.old_title, h.old_body \
40+ FROM edit_history h JOIN users u ON u.id = h.editor_id \
41+ WHERE h.target_kind = ?1 AND h.target_id = ?2 \
42+ ORDER BY h.edited_at DESC, h.id DESC",
43+ (target_kind.to_string(), target_id.to_string()),
44+ |row| {
45+ Ok(EditVersion {
46+ id: col_text(row, 0)?,
47+ editor_name: col_text(row, 1)?,
48+ edited_at: col_text(row, 2)?,
49+ old_title: col_opt_text(row, 3)?,
50+ old_body: col_text(row, 4)?,
51+ })
52+ },
53+ )
54+ .await
55+ }
56+
57+ /// How many previous versions each target has, keyed by target id. Targets with no edits are
58+ /// absent from the map. One query per 500 ids.
59+ pub async fn edit_count(
60+ &self,
61+ target_kind: &str,
62+ target_ids: &[&str],
63+ ) -> Result<HashMap<String, u64>> {
64+ let mut out = HashMap::new();
65+ if target_ids.is_empty() {
66+ return Ok(out);
67+ }
68+ let conn = self.read_conn().await;
69+ for chunk in target_ids.chunks(CHUNK) {
70+ let sql = format!(
71+ "SELECT target_id, COUNT(*) FROM edit_history \
72+ WHERE target_kind = ?1 AND target_id IN ({}) GROUP BY target_id",
73+ placeholders(chunk.len())
74+ );
75+ let mut params: Vec<turso::Value> = Vec::with_capacity(chunk.len() + 1);
76+ params.push(turso::Value::Text(target_kind.to_string()));
77+ params.extend(chunk.iter().map(|id| turso::Value::Text((*id).to_string())));
78+ let rows = fetch_all(&conn, &sql, params, |row| {
79+ let count = u64::try_from(col_int(row, 1)?).unwrap_or(0);
80+ Ok((col_text(row, 0)?, count))
81+ })
82+ .await?;
83+ out.extend(rows);
84+ }
85+ Ok(out)
86+ }
87+
88+ /// The issue a comment, review comment or issue belongs to, by target id. `None` when the
89+ /// target does not exist (a deleted comment has no history either).
90+ pub async fn edit_target_issue(
91+ &self,
92+ target_kind: &str,
93+ target_id: &str,
94+ ) -> Result<Option<String>> {
95+ let sql = match target_kind {
96+ "issue" => "SELECT id FROM issues WHERE id = ?1 LIMIT 1",
97+ "comment" => "SELECT issue_id FROM comments WHERE id = ?1 LIMIT 1",
98+ "review_comment" => "SELECT issue_id FROM review_comments WHERE id = ?1 LIMIT 1",
99+ _ => return Ok(None),
100+ };
101+ let conn = self.read_conn().await;
102+ fetch_opt(&conn, sql, (target_id.to_string(),), |row| col_text(row, 0)).await
103+ }
104+}
crates/forge-core/src/store/issues.rsadded+1730 -0
@@ -0,0 +1,1730 @@
1+//! Issues, comments, timeline events and milestones. Every multi-row write runs in one
2+//! transaction. Numbers come from `repos.next_issue_number` in the same transaction as the insert.
3+
4+use std::collections::HashMap;
5+
6+use serde_json::{Map, Value as Json};
7+use turso::transaction::Transaction;
8+use turso::{Connection, Row, Value};
9+
10+use super::{
11+ Store, col_int, col_opt_text, col_text, db_err, fetch_all, fetch_opt, insert_err, missing_row,
12+ new_id,
13+};
14+use crate::error::{Error, Result};
15+use crate::issues::{
16+ Comment, Issue, IssueEvent, IssueListItem, IssueSort, IssueState, LOCK_REASONS, Label,
17+ StateReason, validate_body, validate_title,
18+};
19+use crate::names::normalize_name;
20+use crate::time::now_stamp;
21+
22+/// Event kinds the timeline knows. Anything else is rejected by `add_event`.
23+const EVENT_KINDS: &[&str] = &[
24+ "closed",
25+ "reopened",
26+ "labeled",
27+ "unlabeled",
28+ "assigned",
29+ "unassigned",
30+ "milestoned",
31+ "demilestoned",
32+ "renamed",
33+ "referenced",
34+ "locked",
35+ "unlocked",
36+ // Pull requests (store/pulls.rs).
37+ "merged",
38+ "head_updated",
39+ "base_changed",
40+ "head_deleted",
41+ "head_ref_deleted",
42+ "head_ref_restored",
43+ // Pull request reviews (store/reviews.rs).
44+ "review_dismissed",
45+ "thread_resolved",
46+ "thread_unresolved",
47+ // Pinned issues (store/pinned.rs).
48+ "pinned",
49+ "unpinned",
50+];
51+
52+pub(super) const ISSUE_COLS: &str = "i.id, i.repo_id, i.number, i.title, i.body, i.state, i.state_reason, \
53+ i.author_id, i.milestone_id, i.comment_count, i.created_at, i.updated_at, \
54+ i.closed_at, i.locked, i.lock_reason";
55+const COMMENT_COLS: &str = "id, issue_id, author_id, body, created_at, updated_at, edited";
56+const LABEL_COLS: &str = "l.id, l.repo_id, l.name, l.color, l.description";
57+
58+/// Caps the number of placeholders in one `IN (...)` list.
59+const IN_CHUNK: usize = 500;
60+
61+pub(super) fn parse_db<T: std::str::FromStr<Err = Error>>(s: &str) -> Result<T> {
62+ s.parse::<T>()
63+ .map_err(|e| Error::Database(format!("stored value {s:?}: {e}")))
64+}
65+
66+/// Reads `ISSUE_COLS` (columns 0 to 14).
67+pub(super) fn map_issue(row: &Row) -> Result<Issue> {
68+ Ok(Issue {
69+ id: col_text(row, 0)?,
70+ repo_id: col_text(row, 1)?,
71+ number: col_int(row, 2)?,
72+ title: col_text(row, 3)?,
73+ body: col_text(row, 4)?,
74+ state: parse_db(&col_text(row, 5)?)?,
75+ state_reason: match col_opt_text(row, 6)? {
76+ Some(r) => Some(parse_db::<StateReason>(&r)?),
77+ None => None,
78+ },
79+ author_id: col_text(row, 7)?,
80+ milestone_id: col_opt_text(row, 8)?,
81+ comment_count: col_int(row, 9)?,
82+ created_at: col_text(row, 10)?,
83+ updated_at: col_text(row, 11)?,
84+ closed_at: col_opt_text(row, 12)?,
85+ locked: col_int(row, 13)? != 0,
86+ lock_reason: col_opt_text(row, 14)?,
87+ })
88+}
89+
90+fn map_comment(row: &Row) -> Result<Comment> {
91+ Ok(Comment {
92+ id: col_text(row, 0)?,
93+ issue_id: col_text(row, 1)?,
94+ author_id: col_text(row, 2)?,
95+ body: col_text(row, 3)?,
96+ created_at: col_text(row, 4)?,
97+ updated_at: col_text(row, 5)?,
98+ edited: col_int(row, 6)? != 0,
99+ })
100+}
101+
102+fn map_event(row: &Row) -> Result<IssueEvent> {
103+ let raw = col_text(row, 4)?;
104+ let data = serde_json::from_str(&raw)
105+ .map_err(|e| Error::Database(format!("event data is not JSON: {e}")))?;
106+ Ok(IssueEvent {
107+ id: col_text(row, 0)?,
108+ issue_id: col_text(row, 1)?,
109+ actor_id: col_opt_text(row, 2)?,
110+ kind: col_text(row, 3)?,
111+ data,
112+ created_at: col_text(row, 5)?,
113+ })
114+}
115+
116+/// A label read from `LABEL_COLS` (or any five columns) starting at `base`.
117+fn map_label(row: &Row, base: usize) -> Result<Label> {
118+ Ok(Label {
119+ id: col_text(row, base)?,
120+ repo_id: col_text(row, base + 1)?,
121+ name: col_text(row, base + 2)?,
122+ color: col_text(row, base + 3)?,
123+ description: col_text(row, base + 4)?,
124+ })
125+}
126+
127+fn placeholders(n: usize) -> String {
128+ vec!["?"; n].join(",")
129+}
130+
131+pub(crate) fn like_pattern(query: &str) -> String {
132+ let mut out = String::with_capacity(query.len() + 2);
133+ out.push('%');
134+ for c in query.chars() {
135+ if matches!(c, '\\' | '%' | '_') {
136+ out.push('\\');
137+ }
138+ out.push(c);
139+ }
140+ out.push('%');
141+ out
142+}
143+
144+/// Validates a `YYYY-MM-DD` due date.
145+pub fn validate_due_on(due_on: &str) -> Result<()> {
146+ let format = time::macros::format_description!("[year]-[month]-[day]");
147+ match time::Date::parse(due_on, &format) {
148+ Ok(_) => Ok(()),
149+ Err(_) => Err(Error::Invalid(format!(
150+ "due date {due_on:?} is not YYYY-MM-DD"
151+ ))),
152+ }
153+}
154+
155+/// Runs `outcome` inside `tx`: commits on success, rolls back on error.
156+pub(super) async fn finish<T>(tx: Transaction<'_>, outcome: Result<T>) -> Result<T> {
157+ match outcome {
158+ Ok(value) => {
159+ tx.commit().await.map_err(db_err)?;
160+ Ok(value)
161+ }
162+ Err(e) => {
163+ let _ = tx.rollback().await;
164+ Err(e)
165+ }
166+ }
167+}
168+
169+/// Inserts a timeline event. `data` must be a JSON object or null (stored as `{}`).
170+pub(super) async fn insert_event(
171+ conn: &Connection,
172+ issue_id: &str,
173+ actor_id: Option<&str>,
174+ kind: &str,
175+ data: &Json,
176+) -> Result<()> {
177+ conn.execute(
178+ "INSERT INTO issue_events (id, issue_id, actor_id, kind, data, created_at) \
179+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
180+ (
181+ new_id(),
182+ issue_id.to_string(),
183+ actor_id.map(str::to_string),
184+ kind.to_string(),
185+ data.to_string(),
186+ now_stamp(),
187+ ),
188+ )
189+ .await
190+ .map_err(db_err)?;
191+ Ok(())
192+}
193+
194+/// The issue or pull request row by id, or `NotFound`. Pull requests share the issue write paths
195+/// (comments, edits, state, events), so both kinds are accepted here. Lookups by number stay
196+/// kind-specific.
197+pub(super) async fn load_issue(conn: &Connection, issue_id: &str) -> Result<Issue> {
198+ fetch_opt(
199+ conn,
200+ &format!("SELECT {ISSUE_COLS} FROM issues i WHERE i.id = ?1 AND i.kind IN ('issue', 'pull') LIMIT 1"),
201+ (issue_id.to_string(),),
202+ map_issue,
203+ )
204+ .await?
205+ .ok_or(Error::NotFound)
206+}
207+
208+/// Labels of an issue, read through `issue_labels`, keyed by issue id.
209+async fn labels_for_issues(
210+ conn: &Connection,
211+ issue_ids: &[String],
212+) -> Result<HashMap<String, Vec<Label>>> {
213+ let mut out: HashMap<String, Vec<Label>> = HashMap::new();
214+ for chunk in issue_ids.chunks(IN_CHUNK) {
215+ let sql = format!(
216+ "SELECT il.issue_id, {LABEL_COLS} FROM issue_labels il \
217+ JOIN labels l ON l.id = il.label_id WHERE il.issue_id IN ({}) \
218+ ORDER BY l.name_folded",
219+ placeholders(chunk.len())
220+ );
221+ let rows = fetch_all(conn, &sql, chunk.to_vec(), |row| {
222+ Ok((col_text(row, 0)?, map_label(row, 1)?))
223+ })
224+ .await?;
225+ for (issue_id, label) in rows {
226+ out.entry(issue_id).or_default().push(label);
227+ }
228+ }
229+ Ok(out)
230+}
231+
232+/// Display usernames of assignees, keyed by issue id.
233+async fn assignees_for_issues(
234+ conn: &Connection,
235+ issue_ids: &[String],
236+) -> Result<HashMap<String, Vec<String>>> {
237+ let mut out: HashMap<String, Vec<String>> = HashMap::new();
238+ for chunk in issue_ids.chunks(IN_CHUNK) {
239+ let sql = format!(
240+ "SELECT ia.issue_id, u.username FROM issue_assignees ia \
241+ JOIN users u ON u.id = ia.user_id WHERE ia.issue_id IN ({}) \
242+ ORDER BY u.username_folded",
243+ placeholders(chunk.len())
244+ );
245+ let rows = fetch_all(conn, &sql, chunk.to_vec(), |row| {
246+ Ok((col_text(row, 0)?, col_text(row, 1)?))
247+ })
248+ .await?;
249+ for (issue_id, name) in rows {
250+ out.entry(issue_id).or_default().push(name);
251+ }
252+ }
253+ Ok(out)
254+}
255+
256+// issues
257+impl Store {
258+ /// Creates an issue with the next number of the repo. The read of the counter, the insert
259+ /// and the increment run in one transaction, so numbers are unique and never reused.
260+ pub async fn create_issue(
261+ &self,
262+ repo_id: &str,
263+ author_id: &str,
264+ title: &str,
265+ body: &str,
266+ ) -> Result<Issue> {
267+ let title = validate_title(title)?;
268+ validate_body(body)?;
269+ let conn = self.inner.conn.lock().await;
270+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
271+ let outcome: Result<Issue> = async {
272+ let number = fetch_opt(
273+ &tx,
274+ "SELECT next_issue_number FROM repos WHERE id = ?1 LIMIT 1",
275+ (repo_id.to_string(),),
276+ |row| col_int(row, 0),
277+ )
278+ .await?
279+ .ok_or(Error::NotFound)?;
280+ let now = now_stamp();
281+ let id = new_id();
282+ tx.execute(
283+ "INSERT INTO issues (id, repo_id, number, kind, title, body, state, author_id, \
284+ comment_count, created_at, updated_at) \
285+ VALUES (?1, ?2, ?3, 'issue', ?4, ?5, 'open', ?6, 0, ?7, ?7)",
286+ (
287+ id.clone(),
288+ repo_id.to_string(),
289+ number,
290+ title.clone(),
291+ body.to_string(),
292+ author_id.to_string(),
293+ now.clone(),
294+ ),
295+ )
296+ .await
297+ .map_err(|e| insert_err(e, "issue number already taken"))?;
298+ tx.execute(
299+ "UPDATE repos SET next_issue_number = next_issue_number + 1 WHERE id = ?1",
300+ (repo_id.to_string(),),
301+ )
302+ .await
303+ .map_err(db_err)?;
304+ Ok(Issue {
305+ id,
306+ repo_id: repo_id.to_string(),
307+ number,
308+ title,
309+ body: body.to_string(),
310+ state: IssueState::Open,
311+ state_reason: None,
312+ author_id: author_id.to_string(),
313+ milestone_id: None,
314+ comment_count: 0,
315+ created_at: now.clone(),
316+ updated_at: now,
317+ closed_at: None,
318+ locked: false,
319+ lock_reason: None,
320+ })
321+ }
322+ .await;
323+ finish(tx, outcome).await
324+ }
325+
326+ pub async fn issue_by_number(&self, repo_id: &str, number: i64) -> Result<Option<Issue>> {
327+ let conn = self.read_conn().await;
328+ fetch_opt(
329+ &conn,
330+ &format!(
331+ "SELECT {ISSUE_COLS} FROM issues i WHERE i.repo_id = ?1 AND i.number = ?2 \
332+ AND i.kind = 'issue' LIMIT 1"
333+ ),
334+ (repo_id.to_string(), number),
335+ map_issue,
336+ )
337+ .await
338+ }
339+
340+ /// One page of issues matching `f`, plus the total number of matches.
341+ /// Every user value is a bound parameter. The clauses and the ORDER BY are fixed strings.
342+ pub async fn list_issues(
343+ &self,
344+ repo_id: &str,
345+ f: &crate::issues::IssueFilter,
346+ ) -> Result<(Vec<IssueListItem>, u64)> {
347+ self.list_of_kind(repo_id, f, "issue").await
348+ }
349+
350+ /// `list_issues` for any `kind` ('issue' or 'pull'). Pull request listing shares it.
351+ pub(super) async fn list_of_kind(
352+ &self,
353+ repo_id: &str,
354+ f: &crate::issues::IssueFilter,
355+ kind: &str,
356+ ) -> Result<(Vec<IssueListItem>, u64)> {
357+ let mut clauses: Vec<&str> = vec!["i.repo_id = ?", "i.kind = ?"];
358+ let mut params: Vec<Value> = vec![
359+ Value::Text(repo_id.to_string()),
360+ Value::Text(kind.to_string()),
361+ ];
362+
363+ if let Some(state) = f.state {
364+ clauses.push("i.state = ?");
365+ params.push(Value::Text(state.as_str().to_string()));
366+ }
367+ if let Some(label) = non_empty(f.label.as_deref()) {
368+ clauses.push(
369+ "EXISTS (SELECT 1 FROM issue_labels il JOIN labels l ON l.id = il.label_id \
370+ WHERE il.issue_id = i.id AND l.repo_id = i.repo_id AND l.name_folded = ?)",
371+ );
372+ params.push(Value::Text(normalize_name(label)));
373+ }
374+ if let Some(milestone) = non_empty(f.milestone.as_deref()) {
375+ clauses.push(
376+ "EXISTS (SELECT 1 FROM milestones m WHERE m.id = i.milestone_id \
377+ AND m.repo_id = i.repo_id AND m.title_folded = ?)",
378+ );
379+ params.push(Value::Text(normalize_name(milestone)));
380+ }
381+ if let Some(author) = non_empty(f.author.as_deref()) {
382+ clauses.push("u.username_folded = ?");
383+ params.push(Value::Text(normalize_name(author)));
384+ }
385+ if let Some(assignee) = non_empty(f.assignee.as_deref()) {
386+ clauses.push(
387+ "EXISTS (SELECT 1 FROM issue_assignees ia JOIN users ua ON ua.id = ia.user_id \
388+ WHERE ia.issue_id = i.id AND ua.username_folded = ?)",
389+ );
390+ params.push(Value::Text(normalize_name(assignee)));
391+ }
392+ if let Some(query) = non_empty(f.query.as_deref()) {
393+ // Escape the LIKE wildcards and the escape character itself.
394+ clauses.push("i.title LIKE ? ESCAPE '\\'");
395+ params.push(Value::Text(like_pattern(query)));
396+ }
397+ let where_sql = clauses.join(" AND ");
398+ let order = match f.sort {
399+ IssueSort::Newest => "i.number DESC",
400+ IssueSort::Oldest => "i.number ASC",
401+ IssueSort::RecentlyUpdated => "i.updated_at DESC, i.number DESC",
402+ };
403+ let per_page = f.per_page.clamp(1, 100);
404+ let page = f.page.clamp(1, 1000);
405+ let offset = i64::from(page - 1) * i64::from(per_page);
406+
407+ let conn = self.read_conn().await;
408+ let total = fetch_opt(
409+ &conn,
410+ &format!(
411+ "SELECT COUNT(*) FROM issues i JOIN users u ON u.id = i.author_id WHERE {where_sql}"
412+ ),
413+ params.clone(),
414+ |row| col_int(row, 0),
415+ )
416+ .await?
417+ .unwrap_or(0);
418+ let total = u64::try_from(total).unwrap_or(0);
419+
420+ let mut page_params = params;
421+ page_params.push(Value::Integer(i64::from(per_page)));
422+ page_params.push(Value::Integer(offset));
423+ let mut items = fetch_all(
424+ &conn,
425+ &format!(
426+ "SELECT {ISSUE_COLS}, u.username FROM issues i JOIN users u ON u.id = i.author_id \
427+ WHERE {where_sql} ORDER BY {order} LIMIT ? OFFSET ?"
428+ ),
429+ page_params,
430+ |row| {
431+ Ok(IssueListItem {
432+ issue: map_issue(row)?,
433+ author: col_text(row, 15)?,
434+ labels: Vec::new(),
435+ assignees: Vec::new(),
436+ })
437+ },
438+ )
439+ .await?;
440+
441+ let ids: Vec<String> = items.iter().map(|item| item.issue.id.clone()).collect();
442+ let mut labels = labels_for_issues(&conn, &ids).await?;
443+ let mut assignees = assignees_for_issues(&conn, &ids).await?;
444+ for item in &mut items {
445+ item.labels = labels.remove(&item.issue.id).unwrap_or_default();
446+ item.assignees = assignees.remove(&item.issue.id).unwrap_or_default();
447+ }
448+ Ok((items, total))
449+ }
450+
451+ /// Open and closed issue counts of the repo (issues only, not pulls).
452+ pub async fn issue_counts(&self, repo_id: &str) -> Result<(u64, u64)> {
453+ self.counts_of_kind(repo_id, "issue").await
454+ }
455+
456+ /// Open and closed counts for one `kind` ('issue' or 'pull').
457+ pub(super) async fn counts_of_kind(&self, repo_id: &str, kind: &str) -> Result<(u64, u64)> {
458+ let conn = self.read_conn().await;
459+ let row = fetch_opt(
460+ &conn,
461+ "SELECT COUNT(CASE WHEN state = 'open' THEN 1 END), \
462+ COUNT(CASE WHEN state = 'closed' THEN 1 END) \
463+ FROM issues WHERE repo_id = ?1 AND kind = ?2",
464+ (repo_id.to_string(), kind.to_string()),
465+ |row| Ok((col_int(row, 0)?, col_int(row, 1)?)),
466+ )
467+ .await?
468+ .ok_or_else(missing_row)?;
469+ Ok((
470+ u64::try_from(row.0).unwrap_or(0),
471+ u64::try_from(row.1).unwrap_or(0),
472+ ))
473+ }
474+
475+ /// Saves the previous title and body to the edit history. A title change also records a
476+ /// `renamed` event with `{"from", "to"}`.
477+ pub async fn edit_issue(
478+ &self,
479+ issue_id: &str,
480+ editor_id: &str,
481+ title: &str,
482+ body: &str,
483+ ) -> Result<()> {
484+ let title = validate_title(title)?;
485+ validate_body(body)?;
486+ let conn = self.inner.conn.lock().await;
487+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
488+ let outcome: Result<()> = async {
489+ let current = load_issue(&tx, issue_id).await?;
490+ let now = now_stamp();
491+ tx.execute(
492+ "INSERT INTO edit_history (id, target_kind, target_id, old_title, old_body, editor_id, edited_at) \
493+ VALUES (?1, 'issue', ?2, ?3, ?4, ?5, ?6)",
494+ (
495+ new_id(),
496+ issue_id.to_string(),
497+ current.title.clone(),
498+ current.body.clone(),
499+ editor_id.to_string(),
500+ now.clone(),
501+ ),
502+ )
503+ .await
504+ .map_err(db_err)?;
505+ tx.execute(
506+ "UPDATE issues SET title = ?2, body = ?3, updated_at = ?4 WHERE id = ?1",
507+ (
508+ issue_id.to_string(),
509+ title.clone(),
510+ body.to_string(),
511+ now,
512+ ),
513+ )
514+ .await
515+ .map_err(db_err)?;
516+ if current.title != title {
517+ let data = serde_json::json!({ "from": current.title, "to": title });
518+ insert_event(&tx, issue_id, Some(editor_id), "renamed", &data).await?;
519+ }
520+ Ok(())
521+ }
522+ .await;
523+ finish(tx, outcome).await
524+ }
525+
526+ /// Sets the state. Returns `false` and changes nothing if the issue is already in `state`.
527+ /// Closing sets `closed_at` and `state_reason`; reopening clears both. The closed or
528+ /// reopened event carries `data`, plus `"reason"` when closing with one.
529+ pub async fn set_issue_state(
530+ &self,
531+ issue_id: &str,
532+ actor_id: Option<&str>,
533+ state: IssueState,
534+ reason: Option<StateReason>,
535+ data: Json,
536+ ) -> Result<bool> {
537+ let mut data = match data {
538+ Json::Object(map) => map,
539+ Json::Null => Map::new(),
540+ _ => {
541+ return Err(Error::Invalid(
542+ "event data must be a JSON object".to_string(),
543+ ));
544+ }
545+ };
546+ let conn = self.inner.conn.lock().await;
547+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
548+ let outcome: Result<bool> = async {
549+ let current = load_issue(&tx, issue_id).await?;
550+ if current.state == state {
551+ return Ok(false);
552+ }
553+ let now = now_stamp();
554+ let (reason_value, closed_at, kind) = match state {
555+ IssueState::Open => (None, None, "reopened"),
556+ IssueState::Closed => {
557+ if let Some(r) = reason {
558+ data.insert("reason".into(), Json::from(r.as_str()));
559+ }
560+ (
561+ reason.map(|r| r.as_str().to_string()),
562+ Some(now.clone()),
563+ "closed",
564+ )
565+ }
566+ };
567+ tx.execute(
568+ "UPDATE issues SET state = ?2, state_reason = ?3, closed_at = ?4, updated_at = ?5 \
569+ WHERE id = ?1",
570+ (
571+ issue_id.to_string(),
572+ state.as_str().to_string(),
573+ reason_value,
574+ closed_at,
575+ now,
576+ ),
577+ )
578+ .await
579+ .map_err(db_err)?;
580+ insert_event(&tx, issue_id, actor_id, kind, &Json::Object(data)).await?;
581+ Ok(true)
582+ }
583+ .await;
584+ finish(tx, outcome).await
585+ }
586+
587+ /// Locks or unlocks a conversation. `reason` must be one of `LOCK_REASONS` or `None`, and is
588+ /// dropped on unlock. Returns `false` and changes nothing when the lock state and reason
589+ /// already match. The `locked` or `unlocked` event carries `"reason"` when one is given.
590+ pub async fn set_issue_locked(
591+ &self,
592+ issue_id: &str,
593+ actor_id: Option<&str>,
594+ locked: bool,
595+ reason: Option<&str>,
596+ ) -> Result<bool> {
597+ let reason = match reason {
598+ Some(r) if !r.is_empty() => {
599+ if !LOCK_REASONS.contains(&r) {
600+ return Err(Error::Invalid(format!("lock reason {r:?}")));
601+ }
602+ Some(r.to_string())
603+ }
604+ _ => None,
605+ };
606+ let reason = if locked { reason } else { None };
607+ let conn = self.inner.conn.lock().await;
608+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
609+ let outcome: Result<bool> = async {
610+ let current = load_issue(&tx, issue_id).await?;
611+ if current.locked == locked && current.lock_reason == reason {
612+ return Ok(false);
613+ }
614+ tx.execute(
615+ "UPDATE issues SET locked = ?2, lock_reason = ?3 WHERE id = ?1",
616+ (issue_id.to_string(), i64::from(locked), reason.clone()),
617+ )
618+ .await
619+ .map_err(db_err)?;
620+ let mut data = Map::new();
621+ let kind = if locked {
622+ if let Some(r) = &reason {
623+ data.insert("reason".into(), Json::from(r.as_str()));
624+ }
625+ "locked"
626+ } else {
627+ "unlocked"
628+ };
629+ insert_event(&tx, issue_id, actor_id, kind, &Json::Object(data)).await?;
630+ Ok(true)
631+ }
632+ .await;
633+ finish(tx, outcome).await
634+ }
635+
636+ // comments
637+
638+ /// Adds a comment, bumps the issue's `comment_count` and `updated_at`. Blank bodies are
639+ /// rejected.
640+ pub async fn add_comment(
641+ &self,
642+ issue_id: &str,
643+ author_id: &str,
644+ body: &str,
645+ ) -> Result<Comment> {
646+ validate_body(body)?;
647+ if body.trim().is_empty() {
648+ return Err(Error::Invalid("comment is empty".into()));
649+ }
650+ let conn = self.inner.conn.lock().await;
651+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
652+ let outcome: Result<Comment> = async {
653+ load_issue(&tx, issue_id).await?;
654+ let now = now_stamp();
655+ let comment = Comment {
656+ id: new_id(),
657+ issue_id: issue_id.to_string(),
658+ author_id: author_id.to_string(),
659+ body: body.to_string(),
660+ created_at: now.clone(),
661+ updated_at: now.clone(),
662+ edited: false,
663+ };
664+ tx.execute(
665+ "INSERT INTO comments (id, issue_id, author_id, body, created_at, updated_at, edited) \
666+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)",
667+ (
668+ comment.id.clone(),
669+ comment.issue_id.clone(),
670+ comment.author_id.clone(),
671+ comment.body.clone(),
672+ comment.created_at.clone(),
673+ comment.updated_at.clone(),
674+ ),
675+ )
676+ .await
677+ .map_err(db_err)?;
678+ tx.execute(
679+ "UPDATE issues SET comment_count = comment_count + 1, updated_at = ?2 WHERE id = ?1",
680+ (issue_id.to_string(), now),
681+ )
682+ .await
683+ .map_err(db_err)?;
684+ Ok(comment)
685+ }
686+ .await;
687+ finish(tx, outcome).await
688+ }
689+
690+ pub async fn comment_by_id(&self, issue_id: &str, comment_id: &str) -> Result<Option<Comment>> {
691+ let conn = self.read_conn().await;
692+ fetch_opt(
693+ &conn,
694+ &format!("SELECT {COMMENT_COLS} FROM comments WHERE id = ?1 AND issue_id = ?2 LIMIT 1"),
695+ (comment_id.to_string(), issue_id.to_string()),
696+ map_comment,
697+ )
698+ .await
699+ }
700+
701+ /// Saves the previous body to the edit history and marks the comment edited.
702+ pub async fn edit_comment(&self, comment_id: &str, editor_id: &str, body: &str) -> Result<()> {
703+ validate_body(body)?;
704+ if body.trim().is_empty() {
705+ return Err(Error::Invalid("comment is empty".into()));
706+ }
707+ let conn = self.inner.conn.lock().await;
708+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
709+ let outcome: Result<()> = async {
710+ let current = fetch_opt(
711+ &tx,
712+ &format!("SELECT {COMMENT_COLS} FROM comments WHERE id = ?1 LIMIT 1"),
713+ (comment_id.to_string(),),
714+ map_comment,
715+ )
716+ .await?
717+ .ok_or(Error::NotFound)?;
718+ let now = now_stamp();
719+ tx.execute(
720+ "INSERT INTO edit_history (id, target_kind, target_id, old_title, old_body, editor_id, edited_at) \
721+ VALUES (?1, 'comment', ?2, NULL, ?3, ?4, ?5)",
722+ (
723+ new_id(),
724+ comment_id.to_string(),
725+ current.body,
726+ editor_id.to_string(),
727+ now.clone(),
728+ ),
729+ )
730+ .await
731+ .map_err(db_err)?;
732+ tx.execute(
733+ "UPDATE comments SET body = ?2, edited = 1, updated_at = ?3 WHERE id = ?1",
734+ (comment_id.to_string(), body.to_string(), now),
735+ )
736+ .await
737+ .map_err(db_err)?;
738+ Ok(())
739+ }
740+ .await;
741+ finish(tx, outcome).await
742+ }
743+
744+ /// Deletes the comment with its edit history and decrements the issue's `comment_count`.
745+ pub async fn delete_comment(&self, comment_id: &str) -> Result<()> {
746+ let conn = self.inner.conn.lock().await;
747+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
748+ let outcome: Result<()> = async {
749+ let issue_id = fetch_opt(
750+ &tx,
751+ "SELECT issue_id FROM comments WHERE id = ?1 LIMIT 1",
752+ (comment_id.to_string(),),
753+ |row| col_text(row, 0),
754+ )
755+ .await?
756+ .ok_or(Error::NotFound)?;
757+ tx.execute(
758+ "DELETE FROM edit_history WHERE target_kind = 'comment' AND target_id = ?1",
759+ (comment_id.to_string(),),
760+ )
761+ .await
762+ .map_err(db_err)?;
763+ tx.execute(
764+ "DELETE FROM reactions WHERE target_kind = 'comment' AND target_id = ?1",
765+ (comment_id.to_string(),),
766+ )
767+ .await
768+ .map_err(db_err)?;
769+ tx.execute(
770+ "DELETE FROM comments WHERE id = ?1",
771+ (comment_id.to_string(),),
772+ )
773+ .await
774+ .map_err(db_err)?;
775+ tx.execute(
776+ "UPDATE issues SET comment_count = comment_count - 1 WHERE id = ?1 AND comment_count > 0",
777+ (issue_id,),
778+ )
779+ .await
780+ .map_err(db_err)?;
781+ Ok(())
782+ }
783+ .await;
784+ finish(tx, outcome).await
785+ }
786+
787+ pub async fn list_comments(&self, issue_id: &str) -> Result<Vec<Comment>> {
788+ let conn = self.read_conn().await;
789+ fetch_all(
790+ &conn,
791+ &format!(
792+ "SELECT {COMMENT_COLS} FROM comments WHERE issue_id = ?1 ORDER BY created_at, id"
793+ ),
794+ (issue_id.to_string(),),
795+ map_comment,
796+ )
797+ .await
798+ }
799+
800+ // events
801+
802+ pub async fn list_events(&self, issue_id: &str) -> Result<Vec<IssueEvent>> {
803+ let conn = self.read_conn().await;
804+ fetch_all(
805+ &conn,
806+ "SELECT id, issue_id, actor_id, kind, data, created_at FROM issue_events \
807+ WHERE issue_id = ?1 ORDER BY created_at, id",
808+ (issue_id.to_string(),),
809+ map_event,
810+ )
811+ .await
812+ }
813+
814+ /// Records a timeline event. `kind` must be one of the known event kinds, and `data` a JSON
815+ /// object or null.
816+ pub async fn add_event(
817+ &self,
818+ issue_id: &str,
819+ actor_id: Option<&str>,
820+ kind: &str,
821+ data: Json,
822+ ) -> Result<()> {
823+ if !EVENT_KINDS.contains(&kind) {
824+ return Err(Error::Invalid(format!("event kind {kind:?}")));
825+ }
826+ let data = match data {
827+ Json::Object(_) => data,
828+ Json::Null => Json::Object(Map::new()),
829+ _ => {
830+ return Err(Error::Invalid(
831+ "event data must be a JSON object".to_string(),
832+ ));
833+ }
834+ };
835+ let conn = self.inner.conn.lock().await;
836+ load_issue(&conn, issue_id).await?;
837+ insert_event(&conn, issue_id, actor_id, kind, &data).await
838+ }
839+
840+ // users
841+
842+ /// Display name and avatar hash for each id that exists. Unknown ids are left out.
843+ pub async fn usernames_by_ids(
844+ &self,
845+ ids: &[String],
846+ ) -> Result<HashMap<String, (String, Option<String>)>> {
847+ let mut out = HashMap::new();
848+ if ids.is_empty() {
849+ return Ok(out);
850+ }
851+ let conn = self.read_conn().await;
852+ for chunk in ids.chunks(IN_CHUNK) {
853+ let rows = fetch_all(
854+ &conn,
855+ &format!(
856+ "SELECT u.id, u.username, (SELECT hash FROM avatars WHERE avatars.user_id = u.id) \
857+ FROM users u WHERE u.id IN ({})",
858+ placeholders(chunk.len())
859+ ),
860+ chunk.to_vec(),
861+ |row| {
862+ Ok((
863+ col_text(row, 0)?,
864+ col_text(row, 1)?,
865+ col_opt_text(row, 2)?,
866+ ))
867+ },
868+ )
869+ .await?;
870+ for (id, name, avatar) in rows {
871+ out.insert(id, (name, avatar));
872+ }
873+ }
874+ Ok(out)
875+ }
876+}
877+
878+/// Repo deletion for the M3 tables. Called from `Store::delete_repo` inside its transaction.
879+pub(super) async fn delete_repo_issue_rows(tx: &Transaction<'_>, repo_id: &str) -> Result<()> {
880+ let id = repo_id.to_string();
881+ for sql in [
882+ "DELETE FROM reactions WHERE \
883+ (target_kind = 'issue' AND target_id IN (SELECT id FROM issues WHERE repo_id = ?1)) \
884+ OR (target_kind = 'comment' AND target_id IN \
885+ (SELECT c.id FROM comments c JOIN issues i ON i.id = c.issue_id WHERE i.repo_id = ?1))",
886+ "DELETE FROM issue_labels WHERE issue_id IN (SELECT id FROM issues WHERE repo_id = ?1)",
887+ "DELETE FROM issue_assignees WHERE issue_id IN (SELECT id FROM issues WHERE repo_id = ?1)",
888+ "DELETE FROM issue_events WHERE issue_id IN (SELECT id FROM issues WHERE repo_id = ?1)",
889+ "DELETE FROM edit_history WHERE \
890+ (target_kind = 'issue' AND target_id IN (SELECT id FROM issues WHERE repo_id = ?1)) \
891+ OR (target_kind = 'comment' AND target_id IN \
892+ (SELECT c.id FROM comments c JOIN issues i ON i.id = c.issue_id WHERE i.repo_id = ?1))",
893+ "DELETE FROM comments WHERE issue_id IN (SELECT id FROM issues WHERE repo_id = ?1)",
894+ "DELETE FROM pinned_issues WHERE repo_id = ?1",
895+ "DELETE FROM issues WHERE repo_id = ?1",
896+ "DELETE FROM labels WHERE repo_id = ?1",
897+ "DELETE FROM milestones WHERE repo_id = ?1",
898+ ] {
899+ tx.execute(sql, (id.clone(),)).await.map_err(db_err)?;
900+ }
901+ Ok(())
902+}
903+
904+fn non_empty(value: Option<&str>) -> Option<&str> {
905+ value.map(str::trim).filter(|s| !s.is_empty())
906+}
907+
908+#[cfg(test)]
909+mod tests {
910+ use super::*;
911+ use crate::issues::{IssueFilter, IssueSort, MAX_BODY};
912+ use crate::model::{User, Visibility};
913+
914+ struct Fixture {
915+ store: Store,
916+ alice: User,
917+ bob: User,
918+ repo_id: String,
919+ _dir: tempfile::TempDir,
920+ }
921+
922+ async fn fixture() -> Fixture {
923+ let dir = tempfile::tempdir().unwrap();
924+ let store = Store::open(&dir.path().join("forge.db")).await.unwrap();
925+ let alice = store
926+ .create_user("Alice", None, "hash", false)
927+ .await
928+ .unwrap();
929+ let bob = store.create_user("Bob", None, "hash", false).await.unwrap();
930+ let repo = store
931+ .create_repo(&alice.id, "proj", "", Visibility::Public, "main")
932+ .await
933+ .unwrap();
934+ Fixture {
935+ store,
936+ alice,
937+ bob,
938+ repo_id: repo.id,
939+ _dir: dir,
940+ }
941+ }
942+
943+ fn all() -> IssueFilter {
944+ IssueFilter {
945+ state: None,
946+ ..IssueFilter::default()
947+ }
948+ }
949+
950+ async fn titles(store: &Store, repo_id: &str, f: &IssueFilter) -> Vec<String> {
951+ let (items, _) = store.list_issues(repo_id, f).await.unwrap();
952+ items.into_iter().map(|i| i.issue.title).collect()
953+ }
954+
955+ #[tokio::test]
956+ async fn create_issue_numbers_start_at_one_and_advance() {
957+ let fx = fixture().await;
958+ let a = fx
959+ .store
960+ .create_issue(&fx.repo_id, &fx.alice.id, " First ", "body")
961+ .await
962+ .unwrap();
963+ let b = fx
964+ .store
965+ .create_issue(&fx.repo_id, &fx.alice.id, "Second", "")
966+ .await
967+ .unwrap();
968+ assert_eq!((a.number, b.number), (1, 2));
969+ assert_eq!(a.title, "First");
970+ assert_eq!(a.state, IssueState::Open);
971+ assert_eq!(a.comment_count, 0);
972+ let found = fx
973+ .store
974+ .issue_by_number(&fx.repo_id, 2)
975+ .await
976+ .unwrap()
977+ .unwrap();
978+ assert_eq!(found.id, b.id);
979+ assert!(
980+ fx.store
981+ .issue_by_number(&fx.repo_id, 3)
982+ .await
983+ .unwrap()
984+ .is_none()
985+ );
986+ }
987+
988+ #[tokio::test]
989+ async fn create_issue_validates_and_rejects_missing_repo() {
990+ let fx = fixture().await;
991+ assert!(matches!(
992+ fx.store
993+ .create_issue(&fx.repo_id, &fx.alice.id, " ", "")
994+ .await,
995+ Err(Error::Invalid(_))
996+ ));
997+ assert!(matches!(
998+ fx.store
999+ .create_issue(&fx.repo_id, &fx.alice.id, "t", &"x".repeat(MAX_BODY + 1))
1000+ .await,
1001+ Err(Error::Invalid(_))
1002+ ));
1003+ assert!(matches!(
1004+ fx.store
1005+ .create_issue("missing", &fx.alice.id, "t", "")
1006+ .await,
1007+ Err(Error::NotFound)
1008+ ));
1009+ // A failed create does not consume a number.
1010+ let ok = fx
1011+ .store
1012+ .create_issue(&fx.repo_id, &fx.alice.id, "t", "")
1013+ .await
1014+ .unwrap();
1015+ assert_eq!(ok.number, 1);
1016+ }
1017+
1018+ #[tokio::test]
1019+ async fn concurrent_creates_get_unique_numbers_one_to_twenty() {
1020+ let fx = fixture().await;
1021+ let mut handles = Vec::new();
1022+ for i in 0..20 {
1023+ let store = fx.store.clone();
1024+ let repo_id = fx.repo_id.clone();
1025+ let author = fx.alice.id.clone();
1026+ handles.push(tokio::spawn(async move {
1027+ store
1028+ .create_issue(&repo_id, &author, &format!("issue {i}"), "")
1029+ .await
1030+ }));
1031+ }
1032+ let mut numbers = Vec::new();
1033+ for h in handles {
1034+ numbers.push(h.await.unwrap().unwrap().number);
1035+ }
1036+ numbers.sort_unstable();
1037+ assert_eq!(numbers, (1..=20).collect::<Vec<i64>>());
1038+ let (open, closed) = fx.store.issue_counts(&fx.repo_id).await.unwrap();
1039+ assert_eq!((open, closed), (20, 0));
1040+ }
1041+
1042+ #[tokio::test]
1043+ async fn list_filters_state_author_and_query() {
1044+ let fx = fixture().await;
1045+ let a = fx
1046+ .store
1047+ .create_issue(&fx.repo_id, &fx.alice.id, "Crash on start", "")
1048+ .await
1049+ .unwrap();
1050+ fx.store
1051+ .create_issue(&fx.repo_id, &fx.bob.id, "Typo in docs", "")
1052+ .await
1053+ .unwrap();
1054+ fx.store
1055+ .set_issue_state(
1056+ &a.id,
1057+ Some(&fx.alice.id),
1058+ IssueState::Closed,
1059+ Some(StateReason::Completed),
1060+ Json::Null,
1061+ )
1062+ .await
1063+ .unwrap();
1064+
1065+ // Default filter (open only).
1066+ let open = IssueFilter::default();
1067+ assert_eq!(
1068+ titles(&fx.store, &fx.repo_id, &open).await,
1069+ vec!["Typo in docs"]
1070+ );
1071+ let (items, total) = fx.store.list_issues(&fx.repo_id, &all()).await.unwrap();
1072+ assert_eq!((items.len(), total), (2, 2));
1073+
1074+ let by_bob = IssueFilter {
1075+ author: Some("BOB".into()),
1076+ ..all()
1077+ };
1078+ assert_eq!(
1079+ titles(&fx.store, &fx.repo_id, &by_bob).await,
1080+ vec!["Typo in docs"]
1081+ );
1082+
1083+ let closed = IssueFilter {
1084+ state: Some(IssueState::Closed),
1085+ ..all()
1086+ };
1087+ assert_eq!(
1088+ titles(&fx.store, &fx.repo_id, &closed).await,
1089+ vec!["Crash on start"]
1090+ );
1091+
1092+ let q = IssueFilter {
1093+ query: Some("CRASH".into()),
1094+ ..all()
1095+ };
1096+ assert_eq!(
1097+ titles(&fx.store, &fx.repo_id, &q).await,
1098+ vec!["Crash on start"]
1099+ );
1100+ }
1101+
1102+ #[tokio::test]
1103+ async fn query_treats_wildcards_literally() {
1104+ let fx = fixture().await;
1105+ for t in [
1106+ "100% done",
1107+ "100 done",
1108+ "a_b",
1109+ "axb",
1110+ "back\\slash",
1111+ "backXslash",
1112+ ] {
1113+ fx.store
1114+ .create_issue(&fx.repo_id, &fx.alice.id, t, "")
1115+ .await
1116+ .unwrap();
1117+ }
1118+ let find = |q: &str| IssueFilter {
1119+ query: Some(q.into()),
1120+ ..all()
1121+ };
1122+ assert_eq!(
1123+ titles(&fx.store, &fx.repo_id, &find("100%")).await,
1124+ vec!["100% done"]
1125+ );
1126+ assert_eq!(
1127+ titles(&fx.store, &fx.repo_id, &find("a_b")).await,
1128+ vec!["a_b"]
1129+ );
1130+ assert_eq!(
1131+ titles(&fx.store, &fx.repo_id, &find("\\")).await,
1132+ vec!["back\\slash"]
1133+ );
1134+ assert_eq!(titles(&fx.store, &fx.repo_id, &find("%")).await.len(), 1);
1135+ assert_eq!(titles(&fx.store, &fx.repo_id, &find(" ")).await.len(), 6);
1136+ }
1137+
1138+ #[tokio::test]
1139+ async fn list_paginates_and_clamps() {
1140+ let fx = fixture().await;
1141+ for i in 0..30 {
1142+ fx.store
1143+ .create_issue(&fx.repo_id, &fx.alice.id, &format!("issue {i}"), "")
1144+ .await
1145+ .unwrap();
1146+ }
1147+ let page = |page: u32, per_page: u32| IssueFilter {
1148+ page,
1149+ per_page,
1150+ ..all()
1151+ };
1152+ let (first, total) = fx
1153+ .store
1154+ .list_issues(&fx.repo_id, &page(1, 25))
1155+ .await
1156+ .unwrap();
1157+ assert_eq!((first.len(), total), (25, 30));
1158+ assert_eq!(first[0].issue.number, 30, "newest first");
1159+ let (second, _) = fx
1160+ .store
1161+ .list_issues(&fx.repo_id, &page(2, 25))
1162+ .await
1163+ .unwrap();
1164+ assert_eq!(second.len(), 5);
1165+ assert_eq!(second[4].issue.number, 1);
1166+ // per_page clamps to 1..=100, page to 1..=1000.
1167+ let (one, _) = fx
1168+ .store
1169+ .list_issues(&fx.repo_id, &page(0, 0))
1170+ .await
1171+ .unwrap();
1172+ assert_eq!(one.len(), 1);
1173+ assert_eq!(one[0].issue.number, 30);
1174+ let (big, _) = fx
1175+ .store
1176+ .list_issues(&fx.repo_id, &page(5000, 1000))
1177+ .await
1178+ .unwrap();
1179+ assert!(big.is_empty());
1180+ }
1181+
1182+ #[tokio::test]
1183+ async fn list_sorts_by_number_and_update_time() {
1184+ let fx = fixture().await;
1185+ let first = fx
1186+ .store
1187+ .create_issue(&fx.repo_id, &fx.alice.id, "one", "")
1188+ .await
1189+ .unwrap();
1190+ fx.store
1191+ .create_issue(&fx.repo_id, &fx.alice.id, "two", "")
1192+ .await
1193+ .unwrap();
1194+ let oldest = IssueFilter {
1195+ sort: IssueSort::Oldest,
1196+ ..all()
1197+ };
1198+ assert_eq!(
1199+ titles(&fx.store, &fx.repo_id, &oldest).await,
1200+ vec!["one", "two"]
1201+ );
1202+ fx.store
1203+ .add_comment(&first.id, &fx.bob.id, "bump")
1204+ .await
1205+ .unwrap();
1206+ let recent = IssueFilter {
1207+ sort: IssueSort::RecentlyUpdated,
1208+ ..all()
1209+ };
1210+ assert_eq!(
1211+ titles(&fx.store, &fx.repo_id, &recent).await,
1212+ vec!["one", "two"]
1213+ );
1214+ }
1215+
1216+ #[tokio::test]
1217+ async fn list_attaches_labels_and_assignees_in_batches() {
1218+ let fx = fixture().await;
1219+ let issue = fx
1220+ .store
1221+ .create_issue(&fx.repo_id, &fx.alice.id, "labelled", "")
1222+ .await
1223+ .unwrap();
1224+ let other = fx
1225+ .store
1226+ .create_issue(&fx.repo_id, &fx.alice.id, "plain", "")
1227+ .await
1228+ .unwrap();
1229+ let bug = fx
1230+ .store
1231+ .create_label(&fx.repo_id, "Bug", "#FF0000", "")
1232+ .await
1233+ .unwrap();
1234+ fx.store
1235+ .set_issue_labels(&issue.id, &fx.alice.id, std::slice::from_ref(&bug.id))
1236+ .await
1237+ .unwrap();
1238+ fx.store
1239+ .set_issue_assignees(&issue.id, &fx.alice.id, &["bob".to_string()])
1240+ .await
1241+ .unwrap();
1242+
1243+ let (items, _) = fx.store.list_issues(&fx.repo_id, &all()).await.unwrap();
1244+ let labelled = items.iter().find(|i| i.issue.id == issue.id).unwrap();
1245+ assert_eq!(labelled.labels.len(), 1);
1246+ assert_eq!(labelled.labels[0].name, "Bug");
1247+ assert_eq!(labelled.assignees, vec!["Bob".to_string()]);
1248+ assert_eq!(labelled.author, "Alice");
1249+ let plain = items.iter().find(|i| i.issue.id == other.id).unwrap();
1250+ assert!(plain.labels.is_empty() && plain.assignees.is_empty());
1251+
1252+ // Label and assignee filters, by name and username.
1253+ let by_label = IssueFilter {
1254+ label: Some("bug".into()),
1255+ ..all()
1256+ };
1257+ assert_eq!(
1258+ titles(&fx.store, &fx.repo_id, &by_label).await,
1259+ vec!["labelled"]
1260+ );
1261+ let by_assignee = IssueFilter {
1262+ assignee: Some("BOB".into()),
1263+ ..all()
1264+ };
1265+ assert_eq!(
1266+ titles(&fx.store, &fx.repo_id, &by_assignee).await,
1267+ vec!["labelled"]
1268+ );
1269+ }
1270+
1271+ #[tokio::test]
1272+ async fn issue_counts_split_open_and_closed() {
1273+ let fx = fixture().await;
1274+ let a = fx
1275+ .store
1276+ .create_issue(&fx.repo_id, &fx.alice.id, "a", "")
1277+ .await
1278+ .unwrap();
1279+ fx.store
1280+ .create_issue(&fx.repo_id, &fx.alice.id, "b", "")
1281+ .await
1282+ .unwrap();
1283+ fx.store
1284+ .set_issue_state(&a.id, None, IssueState::Closed, None, Json::Null)
1285+ .await
1286+ .unwrap();
1287+ assert_eq!(fx.store.issue_counts(&fx.repo_id).await.unwrap(), (1, 1));
1288+ }
1289+
1290+ #[tokio::test]
1291+ async fn edit_issue_saves_history_and_records_rename() {
1292+ let fx = fixture().await;
1293+ let issue = fx
1294+ .store
1295+ .create_issue(&fx.repo_id, &fx.alice.id, "old", "old body")
1296+ .await
1297+ .unwrap();
1298+ fx.store
1299+ .edit_issue(&issue.id, &fx.bob.id, "new", "new body")
1300+ .await
1301+ .unwrap();
1302+ let edited = fx
1303+ .store
1304+ .issue_by_number(&fx.repo_id, 1)
1305+ .await
1306+ .unwrap()
1307+ .unwrap();
1308+ assert_eq!(
1309+ (edited.title.as_str(), edited.body.as_str()),
1310+ ("new", "new body")
1311+ );
1312+ assert_ne!(edited.updated_at, issue.created_at);
1313+
1314+ let conn = fx.store.inner.conn.lock().await;
1315+ let mut rows = conn
1316+ .query(
1317+ "SELECT old_title, old_body, editor_id FROM edit_history WHERE target_kind = 'issue' AND target_id = ?1",
1318+ (issue.id.clone(),),
1319+ )
1320+ .await
1321+ .unwrap();
1322+ let row = rows.next().await.unwrap().expect("history row");
1323+ assert_eq!(col_text(&row, 0).unwrap(), "old");
1324+ assert_eq!(col_text(&row, 1).unwrap(), "old body");
1325+ assert_eq!(col_text(&row, 2).unwrap(), fx.bob.id);
1326+ drop(rows);
1327+ drop(conn);
1328+
1329+ let events = fx.store.list_events(&issue.id).await.unwrap();
1330+ assert_eq!(events.len(), 1);
1331+ assert_eq!(events[0].kind, "renamed");
1332+ assert_eq!(
1333+ events[0].data,
1334+ serde_json::json!({"from": "old", "to": "new"})
1335+ );
1336+ assert_eq!(events[0].actor_id.as_deref(), Some(fx.bob.id.as_str()));
1337+
1338+ // Body-only edit: history yes, no rename event.
1339+ fx.store
1340+ .edit_issue(&issue.id, &fx.bob.id, "new", "newer")
1341+ .await
1342+ .unwrap();
1343+ assert_eq!(fx.store.list_events(&issue.id).await.unwrap().len(), 1);
1344+ assert!(matches!(
1345+ fx.store.edit_issue("missing", &fx.bob.id, "t", "").await,
1346+ Err(Error::NotFound)
1347+ ));
1348+ }
1349+
1350+ #[tokio::test]
1351+ async fn set_issue_locked_toggles_reason_and_records_events() {
1352+ let fx = fixture().await;
1353+ let issue = fx
1354+ .store
1355+ .create_issue(&fx.repo_id, &fx.alice.id, "t", "")
1356+ .await
1357+ .unwrap();
1358+ assert!(!issue.locked);
1359+ assert_eq!(issue.lock_reason, None);
1360+
1361+ assert!(
1362+ !fx.store
1363+ .set_issue_locked(&issue.id, None, false, None)
1364+ .await
1365+ .unwrap()
1366+ );
1367+ assert!(
1368+ fx.store
1369+ .set_issue_locked(&issue.id, Some(&fx.alice.id), true, Some("spam"))
1370+ .await
1371+ .unwrap()
1372+ );
1373+ let locked = fx
1374+ .store
1375+ .issue_by_number(&fx.repo_id, 1)
1376+ .await
1377+ .unwrap()
1378+ .unwrap();
1379+ assert!(locked.locked);
1380+ assert_eq!(locked.lock_reason.as_deref(), Some("spam"));
1381+
1382+ assert!(
1383+ !fx.store
1384+ .set_issue_locked(&issue.id, None, true, Some("spam"))
1385+ .await
1386+ .unwrap()
1387+ );
1388+ assert!(
1389+ fx.store
1390+ .set_issue_locked(&issue.id, Some(&fx.alice.id), false, Some("spam"))
1391+ .await
1392+ .unwrap()
1393+ );
1394+ let open = fx
1395+ .store
1396+ .issue_by_number(&fx.repo_id, 1)
1397+ .await
1398+ .unwrap()
1399+ .unwrap();
1400+ assert!(!open.locked);
1401+ assert_eq!(open.lock_reason, None);
1402+
1403+ let err = fx
1404+ .store
1405+ .set_issue_locked(&issue.id, None, true, Some("bogus"))
1406+ .await;
1407+ assert!(matches!(err, Err(Error::Invalid(_))));
1408+
1409+ let kinds: Vec<String> = fx
1410+ .store
1411+ .list_events(&issue.id)
1412+ .await
1413+ .unwrap()
1414+ .into_iter()
1415+ .map(|e| e.kind)
1416+ .collect();
1417+ assert_eq!(kinds, vec!["locked", "unlocked"]);
1418+ }
1419+
1420+ #[tokio::test]
1421+ async fn set_issue_state_closes_reopens_and_records_events() {
1422+ let fx = fixture().await;
1423+ let issue = fx
1424+ .store
1425+ .create_issue(&fx.repo_id, &fx.alice.id, "t", "")
1426+ .await
1427+ .unwrap();
1428+
1429+ assert!(
1430+ !fx.store
1431+ .set_issue_state(&issue.id, None, IssueState::Open, None, Json::Null)
1432+ .await
1433+ .unwrap()
1434+ );
1435+ assert!(
1436+ fx.store
1437+ .set_issue_state(
1438+ &issue.id,
1439+ Some(&fx.alice.id),
1440+ IssueState::Closed,
1441+ Some(StateReason::NotPlanned),
1442+ serde_json::json!({"x": 1})
1443+ )
1444+ .await
1445+ .unwrap()
1446+ );
1447+ assert!(
1448+ !fx.store
1449+ .set_issue_state(
1450+ &issue.id,
1451+ None,
1452+ IssueState::Closed,
1453+ Some(StateReason::Completed),
1454+ Json::Null
1455+ )
1456+ .await
1457+ .unwrap()
1458+ );
1459+
1460+ let closed = fx
1461+ .store
1462+ .issue_by_number(&fx.repo_id, 1)
1463+ .await
1464+ .unwrap()
1465+ .unwrap();
1466+ assert_eq!(closed.state, IssueState::Closed);
1467+ assert_eq!(closed.state_reason, Some(StateReason::NotPlanned));
1468+ assert!(closed.closed_at.is_some());
1469+
1470+ assert!(
1471+ fx.store
1472+ .set_issue_state(&issue.id, None, IssueState::Open, None, Json::Null)
1473+ .await
1474+ .unwrap()
1475+ );
1476+ let reopened = fx
1477+ .store
1478+ .issue_by_number(&fx.repo_id, 1)
1479+ .await
1480+ .unwrap()
1481+ .unwrap();
1482+ assert_eq!(reopened.state, IssueState::Open);
1483+ assert_eq!(reopened.state_reason, None);
1484+ assert_eq!(reopened.closed_at, None);
1485+
1486+ let events = fx.store.list_events(&issue.id).await.unwrap();
1487+ let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
1488+ assert_eq!(kinds, vec!["closed", "reopened"]);
1489+ assert_eq!(
1490+ events[0].data,
1491+ serde_json::json!({"x": 1, "reason": "not_planned"})
1492+ );
1493+ assert_eq!(events[1].data, serde_json::json!({}));
1494+ assert!(events[1].actor_id.is_none());
1495+
1496+ assert!(matches!(
1497+ fx.store
1498+ .set_issue_state("missing", None, IssueState::Closed, None, Json::Null)
1499+ .await,
1500+ Err(Error::NotFound)
1501+ ));
1502+ assert!(matches!(
1503+ fx.store
1504+ .set_issue_state(&issue.id, None, IssueState::Closed, None, Json::from(5))
1505+ .await,
1506+ Err(Error::Invalid(_))
1507+ ));
1508+ }
1509+
1510+ #[tokio::test]
1511+ async fn comments_maintain_count_and_timestamps() {
1512+ let fx = fixture().await;
1513+ let issue = fx
1514+ .store
1515+ .create_issue(&fx.repo_id, &fx.alice.id, "t", "")
1516+ .await
1517+ .unwrap();
1518+ let c1 = fx
1519+ .store
1520+ .add_comment(&issue.id, &fx.bob.id, "first")
1521+ .await
1522+ .unwrap();
1523+ let c2 = fx
1524+ .store
1525+ .add_comment(&issue.id, &fx.alice.id, "second")
1526+ .await
1527+ .unwrap();
1528+ assert!(!c1.edited);
1529+ let after_add = fx
1530+ .store
1531+ .issue_by_number(&fx.repo_id, 1)
1532+ .await
1533+ .unwrap()
1534+ .unwrap();
1535+ assert_eq!(after_add.comment_count, 2);
1536+ assert_ne!(after_add.updated_at, issue.updated_at);
1537+
1538+ assert!(matches!(
1539+ fx.store.add_comment(&issue.id, &fx.bob.id, " ").await,
1540+ Err(Error::Invalid(_))
1541+ ));
1542+ assert!(matches!(
1543+ fx.store.add_comment("missing", &fx.bob.id, "x").await,
1544+ Err(Error::NotFound)
1545+ ));
1546+
1547+ let found = fx
1548+ .store
1549+ .comment_by_id(&issue.id, &c1.id)
1550+ .await
1551+ .unwrap()
1552+ .unwrap();
1553+ assert_eq!(found.body, "first");
1554+ assert!(
1555+ fx.store
1556+ .comment_by_id("other", &c1.id)
1557+ .await
1558+ .unwrap()
1559+ .is_none()
1560+ );
1561+
1562+ fx.store
1563+ .edit_comment(&c1.id, &fx.bob.id, "edited")
1564+ .await
1565+ .unwrap();
1566+ let edited = fx
1567+ .store
1568+ .comment_by_id(&issue.id, &c1.id)
1569+ .await
1570+ .unwrap()
1571+ .unwrap();
1572+ assert_eq!(edited.body, "edited");
1573+ assert!(edited.edited);
1574+ assert!(matches!(
1575+ fx.store.edit_comment("missing", &fx.bob.id, "x").await,
1576+ Err(Error::NotFound)
1577+ ));
1578+ assert!(matches!(
1579+ fx.store.edit_comment(&c1.id, &fx.bob.id, "").await,
1580+ Err(Error::Invalid(_))
1581+ ));
1582+
1583+ let comments = fx.store.list_comments(&issue.id).await.unwrap();
1584+ let bodies: Vec<&str> = comments.iter().map(|c| c.body.as_str()).collect();
1585+ assert_eq!(bodies, vec!["edited", "second"]);
1586+
1587+ fx.store.delete_comment(&c2.id).await.unwrap();
1588+ let after_delete = fx
1589+ .store
1590+ .issue_by_number(&fx.repo_id, 1)
1591+ .await
1592+ .unwrap()
1593+ .unwrap();
1594+ assert_eq!(after_delete.comment_count, 1);
1595+ assert!(
1596+ fx.store
1597+ .comment_by_id(&issue.id, &c2.id)
1598+ .await
1599+ .unwrap()
1600+ .is_none()
1601+ );
1602+ assert!(matches!(
1603+ fx.store.delete_comment(&c2.id).await,
1604+ Err(Error::NotFound)
1605+ ));
1606+ }
1607+
1608+ #[tokio::test]
1609+ async fn add_event_validates_kind_and_issue() {
1610+ let fx = fixture().await;
1611+ let issue = fx
1612+ .store
1613+ .create_issue(&fx.repo_id, &fx.alice.id, "t", "")
1614+ .await
1615+ .unwrap();
1616+ fx.store
1617+ .add_event(
1618+ &issue.id,
1619+ None,
1620+ "referenced",
1621+ serde_json::json!({"commit": "abc"}),
1622+ )
1623+ .await
1624+ .unwrap();
1625+ fx.store
1626+ .add_event(&issue.id, Some(&fx.bob.id), "referenced", Json::Null)
1627+ .await
1628+ .unwrap();
1629+ let events = fx.store.list_events(&issue.id).await.unwrap();
1630+ assert_eq!(events.len(), 2);
1631+ assert_eq!(events[0].data, serde_json::json!({"commit": "abc"}));
1632+ assert_eq!(events[1].data, serde_json::json!({}));
1633+ assert!(matches!(
1634+ fx.store
1635+ .add_event(&issue.id, None, "exploded", Json::Null)
1636+ .await,
1637+ Err(Error::Invalid(_))
1638+ ));
1639+ assert!(matches!(
1640+ fx.store
1641+ .add_event(&issue.id, None, "referenced", Json::from(true))
1642+ .await,
1643+ Err(Error::Invalid(_))
1644+ ));
1645+ assert!(matches!(
1646+ fx.store
1647+ .add_event("missing", None, "referenced", Json::Null)
1648+ .await,
1649+ Err(Error::NotFound)
1650+ ));
1651+ }
1652+
1653+ #[tokio::test]
1654+ async fn usernames_by_ids_is_batched_and_skips_unknown() {
1655+ let fx = fixture().await;
1656+ let ids = vec![fx.alice.id.clone(), fx.bob.id.clone(), "nobody".to_string()];
1657+ let map = fx.store.usernames_by_ids(&ids).await.unwrap();
1658+ assert_eq!(map.len(), 2);
1659+ assert_eq!(map[&fx.alice.id].0, "Alice");
1660+ assert_eq!(map[&fx.bob.id].1, None);
1661+ assert!(fx.store.usernames_by_ids(&[]).await.unwrap().is_empty());
1662+ }
1663+
1664+ #[tokio::test]
1665+ async fn delete_repo_leaves_no_m3_rows() {
1666+ let fx = fixture().await;
1667+ let issue = fx
1668+ .store
1669+ .create_issue(&fx.repo_id, &fx.alice.id, "t", "")
1670+ .await
1671+ .unwrap();
1672+ let comment = fx
1673+ .store
1674+ .add_comment(&issue.id, &fx.bob.id, "c")
1675+ .await
1676+ .unwrap();
1677+ fx.store
1678+ .edit_comment(&comment.id, &fx.bob.id, "c2")
1679+ .await
1680+ .unwrap();
1681+ fx.store
1682+ .edit_issue(&issue.id, &fx.alice.id, "t2", "")
1683+ .await
1684+ .unwrap();
1685+ let label = fx
1686+ .store
1687+ .create_label(&fx.repo_id, "bug", "#000000", "")
1688+ .await
1689+ .unwrap();
1690+ fx.store
1691+ .set_issue_labels(&issue.id, &fx.alice.id, std::slice::from_ref(&label.id))
1692+ .await
1693+ .unwrap();
1694+ fx.store
1695+ .set_issue_assignees(&issue.id, &fx.alice.id, &["bob".into()])
1696+ .await
1697+ .unwrap();
1698+ let ms = fx
1699+ .store
1700+ .create_milestone(&fx.repo_id, "v1", "", Some("2030-01-01"))
1701+ .await
1702+ .unwrap();
1703+ fx.store
1704+ .set_issue_milestone(&issue.id, &fx.alice.id, Some(&ms.id))
1705+ .await
1706+ .unwrap();
1707+
1708+ fx.store.delete_repo(&fx.repo_id).await.unwrap();
1709+
1710+ let conn = fx.store.inner.conn.lock().await;
1711+ for table in [
1712+ "issues",
1713+ "comments",
1714+ "edit_history",
1715+ "issue_events",
1716+ "labels",
1717+ "issue_labels",
1718+ "issue_assignees",
1719+ "milestones",
1720+ "reactions",
1721+ ] {
1722+ let mut rows = conn
1723+ .query(format!("SELECT COUNT(*) FROM {table}"), ())
1724+ .await
1725+ .unwrap();
1726+ let row = rows.next().await.unwrap().unwrap();
1727+ assert_eq!(col_int(&row, 0).unwrap(), 0, "{table} still has rows");
1728+ }
1729+ }
1730+}
crates/forge-core/src/store/labels.rsadded+1338 -0

Diff too large; view file

@@ -0,0 +1,1338 @@
1+//! Labels, issue label/assignee/milestone links, and milestones. Every function is scoped by
2+//! repo: an id that belongs to another repo is `NotFound` or `Invalid`, never acted on.
3+
4+use std::collections::{BTreeMap, BTreeSet};
5+
6+use jiff::civil::Date;
7+use serde_json::{Value, json};
8+use turso::transaction::Transaction;
9+use turso::{Connection, Row};
10+
11+use super::{
12+ Store, col_int, col_opt_text, col_text, db_err, fetch_all, fetch_opt, insert_err, new_id,
13+};
14+use crate::authz::{Action, authorize};
15+use crate::error::{Error, Result};
16+use crate::issues::{IssueState, Label, Milestone, validate_body, validate_label, validate_title};
17+use crate::model::User;
18+use crate::names::normalize_name;
19+use crate::time::now_stamp;
20+
21+/// Most assignees one issue can have.
22+pub const MAX_ASSIGNEES: usize = 10;
23+
24+const LABEL_TAKEN: &str = "a label with that name already exists";
25+const MILESTONE_TAKEN: &str = "a milestone with that title already exists";
26+
27+/// Labels a new repo starts with: (name, color, description).
28+pub const DEFAULT_LABELS: [(&str, &str, &str); 9] = [
29+ ("bug", "#d73a4a", "Something isn't working"),
30+ (
31+ "documentation",
32+ "#0075ca",
33+ "Improvements or additions to documentation",
34+ ),
35+ (
36+ "duplicate",
37+ "#cfd3d7",
38+ "This issue or pull request already exists",
39+ ),
40+ ("enhancement", "#a2eeef", "New feature or request"),
41+ ("good first issue", "#7057ff", "Good for newcomers"),
42+ ("help wanted", "#008672", "Extra attention is needed"),
43+ ("invalid", "#e4e669", "This doesn't seem right"),
44+ ("question", "#d876e3", "Further information is requested"),
45+ ("wontfix", "#ffffff", "This will not be worked on"),
46+];
47+
48+const LABEL_COLS: &str = "id, repo_id, name, color, description";
49+/// One grouped query: each milestone with its open and closed issue counts. Joining on
50+/// `repo_id` as well lets the lookup use the issues index.
51+const MILESTONE_SELECT: &str = "SELECT m.id, m.repo_id, m.title, m.description, m.due_on, m.state, \
52+ m.created_at, m.closed_at, \
53+ COUNT(CASE WHEN i.state = 'open' THEN 1 END), COUNT(CASE WHEN i.state = 'closed' THEN 1 END) \
54+ FROM milestones m LEFT JOIN issues i ON i.repo_id = m.repo_id AND i.milestone_id = m.id";
55+
56+// labels
57+
58+impl Store {
59+ /// The repo's labels, ordered by case-folded name.
60+ pub async fn list_labels(&self, repo_id: &str) -> Result<Vec<Label>> {
61+ let conn = self.read_conn().await;
62+ fetch_all(
63+ &conn,
64+ &format!("SELECT {LABEL_COLS} FROM labels WHERE repo_id = ?1 ORDER BY name_folded"),
65+ (repo_id.to_string(),),
66+ map_label,
67+ )
68+ .await
69+ }
70+
71+ /// Open issue count per label of `repo_id`, as `(label_id, count)`, in one grouped query.
72+ /// Labels with no open issues are included with 0.
73+ pub async fn label_open_counts(&self, repo_id: &str) -> Result<Vec<(String, i64)>> {
74+ let conn = self.read_conn().await;
75+ fetch_all(
76+ &conn,
77+ "SELECT l.id, COUNT(i.id) FROM labels l \
78+ LEFT JOIN issue_labels il ON il.label_id = l.id \
79+ LEFT JOIN issues i ON i.id = il.issue_id AND i.state = 'open' AND i.kind = 'issue' \
80+ WHERE l.repo_id = ?1 GROUP BY l.id",
81+ (repo_id.to_string(),),
82+ |row| Ok((col_text(row, 0)?, col_int(row, 1)?)),
83+ )
84+ .await
85+ }
86+
87+ /// Creates a label. A name that folds to an existing one in the same repo is `Conflict`.
88+ pub async fn create_label(
89+ &self,
90+ repo_id: &str,
91+ name: &str,
92+ color: &str,
93+ description: &str,
94+ ) -> Result<Label> {
95+ let (name, color) = validate_label(name, color)?;
96+ let folded = normalize_name(&name);
97+ let label = Label {
98+ id: new_id(),
99+ repo_id: repo_id.to_string(),
100+ name,
101+ color,
102+ description: description.to_string(),
103+ };
104+ let conn = self.inner.conn.lock().await;
105+ conn.execute(
106+ "INSERT INTO labels (id, repo_id, name, name_folded, color, description) \
107+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
108+ (
109+ label.id.clone(),
110+ label.repo_id.clone(),
111+ label.name.clone(),
112+ folded,
113+ label.color.clone(),
114+ label.description.clone(),
115+ ),
116+ )
117+ .await
118+ .map_err(|e| insert_err(e, LABEL_TAKEN))?;
119+ Ok(label)
120+ }
121+
122+ /// Adds the default labels to `repo_id`, skipping any whose name the repo already has.
123+ /// Returns how many were created. Running it again creates nothing.
124+ pub async fn create_default_labels(&self, repo_id: &str) -> Result<usize> {
125+ let mut created = 0;
126+ for (name, color, description) in DEFAULT_LABELS {
127+ match self.create_label(repo_id, name, color, description).await {
128+ Ok(_) => created += 1,
129+ Err(Error::Conflict(_)) => {}
130+ Err(e) => return Err(e),
131+ }
132+ }
133+ Ok(created)
134+ }
135+
136+ /// Updates a label in place. `NotFound` if the label is not in `repo_id`.
137+ pub async fn update_label(
138+ &self,
139+ repo_id: &str,
140+ label_id: &str,
141+ name: &str,
142+ color: &str,
143+ description: &str,
144+ ) -> Result<()> {
145+ let (name, color) = validate_label(name, color)?;
146+ let folded = normalize_name(&name);
147+ let conn = self.inner.conn.lock().await;
148+ let n = conn
149+ .execute(
150+ "UPDATE labels SET name = ?3, name_folded = ?4, color = ?5, description = ?6 \
151+ WHERE id = ?1 AND repo_id = ?2",
152+ (
153+ label_id.to_string(),
154+ repo_id.to_string(),
155+ name,
156+ folded,
157+ color,
158+ description.to_string(),
159+ ),
160+ )
161+ .await
162+ .map_err(|e| insert_err(e, LABEL_TAKEN))?;
163+ if n == 0 {
164+ return Err(Error::NotFound);
165+ }
166+ Ok(())
167+ }
168+
169+ /// Deletes a label and its issue links in one transaction. `NotFound` if the label is not
170+ /// in `repo_id`; nothing is changed then.
171+ pub async fn delete_label(&self, repo_id: &str, label_id: &str) -> Result<()> {
172+ let conn = self.inner.conn.lock().await;
173+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
174+ let outcome: Result<()> = async {
175+ let n = tx
176+ .execute(
177+ "DELETE FROM labels WHERE id = ?1 AND repo_id = ?2",
178+ (label_id.to_string(), repo_id.to_string()),
179+ )
180+ .await
181+ .map_err(db_err)?;
182+ if n == 0 {
183+ return Err(Error::NotFound);
184+ }
185+ tx.execute(
186+ "DELETE FROM issue_labels WHERE label_id = ?1",
187+ (label_id.to_string(),),
188+ )
189+ .await
190+ .map_err(db_err)?;
191+ Ok(())
192+ }
193+ .await;
194+ commit_or_rollback(tx, outcome).await
195+ }
196+
197+ /// The labels on one issue, ordered by case-folded name.
198+ pub async fn issue_labels(&self, issue_id: &str) -> Result<Vec<Label>> {
199+ let conn = self.read_conn().await;
200+ fetch_all(
201+ &conn,
202+ "SELECT l.id, l.repo_id, l.name, l.color, l.description FROM issue_labels il \
203+ JOIN labels l ON l.id = il.label_id WHERE il.issue_id = ?1 ORDER BY l.name_folded",
204+ (issue_id.to_string(),),
205+ map_label,
206+ )
207+ .await
208+ }
209+
210+ /// Replaces the issue's label set. Adds and removes happen in one transaction, with one
211+ /// `labeled` or `unlabeled` event per change. Every id must be a label of the issue's repo;
212+ /// otherwise `Invalid` and nothing changes.
213+ pub async fn set_issue_labels(
214+ &self,
215+ issue_id: &str,
216+ actor_id: &str,
217+ label_ids: &[String],
218+ ) -> Result<()> {
219+ let requested: BTreeSet<&str> = label_ids.iter().map(String::as_str).collect();
220+ let conn = self.inner.conn.lock().await;
221+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
222+ let outcome: Result<()> = async {
223+ let repo_id = issue_repo_id(&tx, issue_id).await?;
224+ let repo_labels: BTreeMap<String, String> = fetch_all(
225+ &tx,
226+ "SELECT id, name FROM labels WHERE repo_id = ?1",
227+ (repo_id,),
228+ |row| Ok((col_text(row, 0)?, col_text(row, 1)?)),
229+ )
230+ .await?
231+ .into_iter()
232+ .collect();
233+ if let Some(bad) = requested.iter().find(|id| !repo_labels.contains_key(**id)) {
234+ return Err(Error::Invalid(format!(
235+ "label {bad} is not in this repository"
236+ )));
237+ }
238+
239+ let current: BTreeMap<String, String> = fetch_all(
240+ &tx,
241+ "SELECT il.label_id, l.name FROM issue_labels il \
242+ JOIN labels l ON l.id = il.label_id WHERE il.issue_id = ?1",
243+ (issue_id.to_string(),),
244+ |row| Ok((col_text(row, 0)?, col_text(row, 1)?)),
245+ )
246+ .await?
247+ .into_iter()
248+ .collect();
249+
250+ let mut removed: Vec<(&String, &String)> = current
251+ .iter()
252+ .filter(|(id, _)| !requested.contains(id.as_str()))
253+ .collect();
254+ removed.sort_by(|a, b| a.1.cmp(b.1));
255+ for (id, name) in removed {
256+ tx.execute(
257+ "DELETE FROM issue_labels WHERE issue_id = ?1 AND label_id = ?2",
258+ (issue_id.to_string(), id.clone()),
259+ )
260+ .await
261+ .map_err(db_err)?;
262+ insert_event(
263+ &tx,
264+ issue_id,
265+ Some(actor_id),
266+ "unlabeled",
267+ &json!({ "label": name }),
268+ )
269+ .await?;
270+ }
271+
272+ let mut added: Vec<(&str, &String)> = requested
273+ .iter()
274+ .filter(|id| !current.contains_key(**id))
275+ .map(|id| (*id, &repo_labels[*id]))
276+ .collect();
277+ added.sort_by(|a, b| a.1.cmp(b.1));
278+ for (id, name) in added {
279+ tx.execute(
280+ "INSERT INTO issue_labels (issue_id, label_id) VALUES (?1, ?2)",
281+ (issue_id.to_string(), id.to_string()),
282+ )
283+ .await
284+ .map_err(db_err)?;
285+ insert_event(
286+ &tx,
287+ issue_id,
288+ Some(actor_id),
289+ "labeled",
290+ &json!({ "label": name }),
291+ )
292+ .await?;
293+ }
294+ Ok(())
295+ }
296+ .await;
297+ commit_or_rollback(tx, outcome).await
298+ }
299+
300+ /// The issue's assignees as `(user id, display username)`, ordered by name.
301+ pub async fn issue_assignees(&self, issue_id: &str) -> Result<Vec<(String, String)>> {
302+ let conn = self.read_conn().await;
303+ fetch_all(
304+ &conn,
305+ "SELECT u.id, u.username FROM issue_assignees a JOIN users u ON u.id = a.user_id \
306+ WHERE a.issue_id = ?1 ORDER BY u.username_folded",
307+ (issue_id.to_string(),),
308+ |row| Ok((col_text(row, 0)?, col_text(row, 1)?)),
309+ )
310+ .await
311+ }
312+
313+ /// Replaces the issue's assignees. Usernames are trimmed and deduplicated case-insensitively
314+ /// (the first spelling wins). At most `MAX_ASSIGNEES`. Each user must be able to Read the
315+ /// repo, otherwise `Invalid`. Records `assigned` and `unassigned` events.
316+ pub async fn set_issue_assignees(
317+ &self,
318+ issue_id: &str,
319+ actor_id: &str,
320+ usernames: &[String],
321+ ) -> Result<()> {
322+ let mut seen = BTreeSet::new();
323+ let mut names: Vec<&str> = Vec::new();
324+ for raw in usernames {
325+ let name = raw.trim();
326+ if !name.is_empty() && seen.insert(normalize_name(name)) {
327+ names.push(name);
328+ }
329+ }
330+ if names.len() > MAX_ASSIGNEES {
331+ return Err(Error::Invalid(format!(
332+ "an issue can have at most {MAX_ASSIGNEES} assignees"
333+ )));
334+ }
335+
336+ // These helpers take the connection lock themselves, so they run before it is held.
337+ let repo_id = {
338+ let conn = self.inner.conn.lock().await;
339+ issue_repo_id(&conn, issue_id).await?
340+ };
341+ let repo = self.repo_by_id(&repo_id).await?.ok_or(Error::NotFound)?;
342+ let mut assignees: Vec<User> = Vec::with_capacity(names.len());
343+ for name in names {
344+ let user = self
345+ .user_by_username(name)
346+ .await?
347+ .ok_or_else(|| Error::Invalid(format!("no such user: {name}")))?;
348+ let role = self.collaborator_role(&repo_id, &user.id).await?;
349+ let org_role = self
350+ .org_repo_role(&repo.owner_id, &user.id, &repo_id)
351+ .await?;
352+ authorize(Some(&user), &repo, role, org_role, Action::Read).map_err(|_| {
353+ Error::Invalid(format!("{} cannot read this repository", user.username))
354+ })?;
355+ assignees.push(user);
356+ }
357+
358+ let conn = self.inner.conn.lock().await;
359+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
360+ let outcome: Result<()> = async {
361+ issue_repo_id(&tx, issue_id).await?;
362+ let current: Vec<(String, String)> = fetch_all(
363+ &tx,
364+ "SELECT a.user_id, u.username FROM issue_assignees a \
365+ JOIN users u ON u.id = a.user_id WHERE a.issue_id = ?1 ORDER BY u.username_folded",
366+ (issue_id.to_string(),),
367+ |row| Ok((col_text(row, 0)?, col_text(row, 1)?)),
368+ )
369+ .await?;
370+ let wanted: BTreeSet<&str> = assignees.iter().map(|u| u.id.as_str()).collect();
371+ let have: BTreeSet<&str> = current.iter().map(|(id, _)| id.as_str()).collect();
372+
373+ for (id, display) in &current {
374+ if wanted.contains(id.as_str()) {
375+ continue;
376+ }
377+ tx.execute(
378+ "DELETE FROM issue_assignees WHERE issue_id = ?1 AND user_id = ?2",
379+ (issue_id.to_string(), id.clone()),
380+ )
381+ .await
382+ .map_err(db_err)?;
383+ insert_event(
384+ &tx,
385+ issue_id,
386+ Some(actor_id),
387+ "unassigned",
388+ &json!({ "user": display }),
389+ )
390+ .await?;
391+ }
392+ for user in &assignees {
393+ if have.contains(user.id.as_str()) {
394+ continue;
395+ }
396+ tx.execute(
397+ "INSERT INTO issue_assignees (issue_id, user_id) VALUES (?1, ?2)",
398+ (issue_id.to_string(), user.id.clone()),
399+ )
400+ .await
401+ .map_err(db_err)?;
402+ insert_event(
403+ &tx,
404+ issue_id,
405+ Some(actor_id),
406+ "assigned",
407+ &json!({ "user": user.username }),
408+ )
409+ .await?;
410+ }
411+ Ok(())
412+ }
413+ .await;
414+ commit_or_rollback(tx, outcome).await
415+ }
416+
417+ // milestones
418+
419+ /// The repo's milestones ordered by title. `state` filters when given.
420+ pub async fn list_milestones(
421+ &self,
422+ repo_id: &str,
423+ state: Option<IssueState>,
424+ ) -> Result<Vec<Milestone>> {
425+ let state = state.map(|s| s.as_str().to_string());
426+ let conn = self.read_conn().await;
427+ fetch_all(
428+ &conn,
429+ &format!(
430+ "{MILESTONE_SELECT} WHERE m.repo_id = ?1 AND (?2 IS NULL OR m.state = ?2) \
431+ GROUP BY m.id ORDER BY m.title_folded"
432+ ),
433+ (repo_id.to_string(), state),
434+ map_milestone,
435+ )
436+ .await
437+ }
438+
439+ /// One milestone with its issue counts, or `None` if it is not in `repo_id`.
440+ pub async fn milestone_by_id(&self, repo_id: &str, id: &str) -> Result<Option<Milestone>> {
441+ let conn = self.read_conn().await;
442+ fetch_opt(
443+ &conn,
444+ &format!("{MILESTONE_SELECT} WHERE m.repo_id = ?1 AND m.id = ?2 GROUP BY m.id"),
445+ (repo_id.to_string(), id.to_string()),
446+ map_milestone,
447+ )
448+ .await
449+ }
450+
451+ /// Creates an open milestone. `due_on` must be `YYYY-MM-DD` when given.
452+ pub async fn create_milestone(
453+ &self,
454+ repo_id: &str,
455+ title: &str,
456+ description: &str,
457+ due_on: Option<&str>,
458+ ) -> Result<Milestone> {
459+ let title = validate_title(title)?;
460+ validate_body(description)?;
461+ validate_due_on(due_on)?;
462+ let milestone = Milestone {
463+ id: new_id(),
464+ repo_id: repo_id.to_string(),
465+ title,
466+ description: description.to_string(),
467+ due_on: due_on.map(str::to_owned),
468+ state: IssueState::Open,
469+ created_at: now_stamp(),
470+ closed_at: None,
471+ open_issues: 0,
472+ closed_issues: 0,
473+ };
474+ let conn = self.inner.conn.lock().await;
475+ conn.execute(
476+ "INSERT INTO milestones (id, repo_id, title, title_folded, description, due_on, state, created_at) \
477+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'open', ?7)",
478+ (
479+ milestone.id.clone(),
480+ milestone.repo_id.clone(),
481+ milestone.title.clone(),
482+ normalize_name(&milestone.title),
483+ milestone.description.clone(),
484+ milestone.due_on.clone(),
485+ milestone.created_at.clone(),
486+ ),
487+ )
488+ .await
489+ .map_err(|e| insert_err(e, MILESTONE_TAKEN))?;
490+ Ok(milestone)
491+ }
492+
493+ /// Updates a milestone. `closed_at` is set when it becomes closed and cleared when reopened.
494+ /// `NotFound` if the milestone is not in `repo_id`.
495+ pub async fn update_milestone(
496+ &self,
497+ repo_id: &str,
498+ id: &str,
499+ title: &str,
500+ description: &str,
501+ due_on: Option<&str>,
502+ state: IssueState,
503+ ) -> Result<()> {
504+ let title = validate_title(title)?;
505+ validate_body(description)?;
506+ validate_due_on(due_on)?;
507+ let conn = self.inner.conn.lock().await;
508+ let n = conn
509+ .execute(
510+ "UPDATE milestones SET title = ?3, title_folded = ?4, description = ?5, due_on = ?6, \
511+ state = ?7, closed_at = CASE WHEN ?7 = 'closed' THEN COALESCE(closed_at, ?8) END \
512+ WHERE id = ?1 AND repo_id = ?2",
513+ (
514+ id.to_string(),
515+ repo_id.to_string(),
516+ normalize_name(&title),
517+ title,
518+ description.to_string(),
519+ due_on.map(str::to_owned),
520+ state.as_str().to_string(),
521+ now_stamp(),
522+ ),
523+ )
524+ .await
525+ .map_err(|e| insert_err(e, MILESTONE_TAKEN))?;
526+ if n == 0 {
527+ return Err(Error::NotFound);
528+ }
529+ Ok(())
530+ }
531+
532+ /// Deletes a milestone. Its issues stay, with `milestone_id` set to NULL first. `NotFound`
533+ /// (and nothing changed) if the milestone is not in `repo_id`.
534+ pub async fn delete_milestone(&self, repo_id: &str, id: &str) -> Result<()> {
535+ let conn = self.inner.conn.lock().await;
536+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
537+ let outcome: Result<()> = async {
538+ tx.execute(
539+ "UPDATE issues SET milestone_id = NULL WHERE milestone_id = ?1 AND repo_id = ?2",
540+ (id.to_string(), repo_id.to_string()),
541+ )
542+ .await
543+ .map_err(db_err)?;
544+ let n = tx
545+ .execute(
546+ "DELETE FROM milestones WHERE id = ?1 AND repo_id = ?2",
547+ (id.to_string(), repo_id.to_string()),
548+ )
549+ .await
550+ .map_err(db_err)?;
551+ if n == 0 {
552+ return Err(Error::NotFound);
553+ }
554+ Ok(())
555+ }
556+ .await;
557+ commit_or_rollback(tx, outcome).await
558+ }
559+
560+ /// Sets or clears the issue's milestone. The milestone must belong to the issue's repo
561+ /// (`Invalid` otherwise). Records `demilestoned` for the old one and `milestoned` for the
562+ /// new one, each with `{"title": ...}`. Setting the same value again changes nothing.
563+ pub async fn set_issue_milestone(
564+ &self,
565+ issue_id: &str,
566+ actor_id: &str,
567+ milestone_id: Option<&str>,
568+ ) -> Result<()> {
569+ let conn = self.inner.conn.lock().await;
570+ let tx = conn.unchecked_transaction().await.map_err(db_err)?;
571+ let outcome: Result<()> = async {
572+ let (repo_id, current_id, current_title) = fetch_opt(
573+ &tx,
574+ "SELECT i.repo_id, i.milestone_id, m.title FROM issues i \
575+ LEFT JOIN milestones m ON m.id = i.milestone_id WHERE i.id = ?1",
576+ (issue_id.to_string(),),
577+ |row| {
578+ Ok((
579+ col_text(row, 0)?,
580+ col_opt_text(row, 1)?,
581+ col_opt_text(row, 2)?,
582+ ))
583+ },
584+ )
585+ .await?
586+ .ok_or(Error::NotFound)?;
587+ if current_id.as_deref() == milestone_id {
588+ return Ok(());
589+ }
590+
591+ let next_title = match milestone_id {
592+ None => None,
593+ Some(mid) => Some(
594+ fetch_opt(
595+ &tx,
596+ "SELECT title FROM milestones WHERE id = ?1 AND repo_id = ?2",
597+ (mid.to_string(), repo_id),
598+ |row| col_text(row, 0),
599+ )
600+ .await?
601+ .ok_or_else(|| Error::Invalid("milestone is not in this repository".into()))?,
602+ ),
603+ };
604+
605+ tx.execute(
606+ "UPDATE issues SET milestone_id = ?2 WHERE id = ?1",
607+ (issue_id.to_string(), milestone_id.map(str::to_owned)),
608+ )
609+ .await
610+ .map_err(db_err)?;
611+ if let Some(title) = current_title {
612+ insert_event(
613+ &tx,
614+ issue_id,
615+ Some(actor_id),
616+ "demilestoned",
617+ &json!({ "title": title }),
618+ )
619+ .await?;
620+ }
621+ if let Some(title) = next_title {
622+ insert_event(
623+ &tx,
624+ issue_id,
625+ Some(actor_id),
626+ "milestoned",
627+ &json!({ "title": title }),
628+ )
629+ .await?;
630+ }
631+ Ok(())
632+ }
633+ .await;
634+ commit_or_rollback(tx, outcome).await
635+ }
636+}
637+
638+// helpers
639+
640+fn map_label(row: &Row) -> Result<Label> {
641+ Ok(Label {
642+ id: col_text(row, 0)?,
643+ repo_id: col_text(row, 1)?,
644+ name: col_text(row, 2)?,
645+ color: col_text(row, 3)?,
646+ description: col_text(row, 4)?,
647+ })
648+}
649+
650+fn map_milestone(row: &Row) -> Result<Milestone> {
651+ Ok(Milestone {
652+ id: col_text(row, 0)?,
653+ repo_id: col_text(row, 1)?,
654+ title: col_text(row, 2)?,
655+ description: col_text(row, 3)?,
656+ due_on: col_opt_text(row, 4)?,
657+ state: col_text(row, 5)?.parse()?,
658+ created_at: col_text(row, 6)?,
659+ closed_at: col_opt_text(row, 7)?,
660+ open_issues: col_int(row, 8)?,
661+ closed_issues: col_int(row, 9)?,
662+ })
663+}
664+
665+/// `YYYY-MM-DD` and a real calendar date. The shape check rejects inputs such as a full
666+/// datetime that `Date::from_str` might otherwise accept.
667+fn validate_due_on(due_on: Option<&str>) -> Result<()> {
668+ let Some(s) = due_on else {
669+ return Ok(());
670+ };
671+ let shaped = s.len() == 10
672+ && s.bytes().enumerate().all(|(i, b)| {
673+ if i == 4 || i == 7 {
674+ b == b'-'
675+ } else {
676+ b.is_ascii_digit()
677+ }
678+ });
679+ if shaped && s.parse::<Date>().is_ok() {
680+ Ok(())
681+ } else {
682+ Err(Error::Invalid("due date must be YYYY-MM-DD".into()))
683+ }
684+}
685+
686+/// The repo of an issue, or `NotFound`.
687+async fn issue_repo_id(conn: &Connection, issue_id: &str) -> Result<String> {
688+ fetch_opt(
689+ conn,
690+ "SELECT repo_id FROM issues WHERE id = ?1",
691+ (issue_id.to_string(),),
692+ |row| col_text(row, 0),
693+ )
694+ .await?
695+ .ok_or(Error::NotFound)
696+}
697+
698+/// Inserts an issue event on the given connection or transaction. Used inside transactions,
699+/// where `Store::add_event` would deadlock on the connection lock.
700+async fn insert_event(
701+ conn: &Connection,
702+ issue_id: &str,
703+ actor_id: Option<&str>,
704+ kind: &str,
705+ data: &Value,
706+) -> Result<()> {
707+ conn.execute(
708+ "INSERT INTO issue_events (id, issue_id, actor_id, kind, data, created_at) \
709+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
710+ (
711+ new_id(),
712+ issue_id.to_string(),
713+ actor_id.map(str::to_owned),
714+ kind.to_string(),
715+ data.to_string(),
716+ now_stamp(),
717+ ),
718+ )
719+ .await
720+ .map_err(db_err)?;
721+ Ok(())
722+}
723+
724+/// Commits when the work succeeded, otherwise rolls back and returns the error.
725+async fn commit_or_rollback<T>(tx: Transaction<'_>, outcome: Result<T>) -> Result<T> {
726+ match outcome {
727+ Ok(value) => {
728+ tx.commit().await.map_err(db_err)?;
729+ Ok(value)
730+ }
731+ Err(e) => {
732+ let _ = tx.rollback().await;
733+ Err(e)
734+ }
735+ }
736+}
737+
738+#[cfg(test)]
739+mod tests {
740+ use super::*;
741+ use crate::issues::{IssueEvent, StateReason};
742+ use crate::model::{Repo, Role, Visibility};
743+
744+ struct Fixture {
745+ _dir: tempfile::TempDir,
746+ store: Store,
747+ alice: User,
748+ bob: User,
749+ repo: Repo,
750+ other: Repo,
751+ }
752+
753+ async fn fixture() -> Fixture {
754+ let dir = tempfile::tempdir().unwrap();
755+ let store = Store::open(&dir.path().join("forge.db")).await.unwrap();
756+ let alice = store
757+ .create_user("Alice", None, "hash", false)
758+ .await
759+ .unwrap();
760+ let bob = store.create_user("Bob", None, "hash", false).await.unwrap();
761+ let repo = store
762+ .create_repo(&alice.id, "repo", "", Visibility::Public, "main")
763+ .await
764+ .unwrap();
765+ let other = store
766+ .create_repo(&alice.id, "other", "", Visibility::Public, "main")
767+ .await
768+ .unwrap();
769+ Fixture {
770+ _dir: dir,
771+ store,
772+ alice,
773+ bob,
774+ repo,
775+ other,
776+ }
777+ }
778+
779+ /// Data of every event of `kind`.
780+ fn data_of(events: &[IssueEvent], kind: &str) -> Vec<Value> {
781+ events
782+ .iter()
783+ .filter(|e| e.kind == kind)
784+ .map(|e| e.data.clone())
785+ .collect()
786+ }
787+
788+ fn names(labels: &[Label]) -> Vec<&str> {
789+ labels.iter().map(|l| l.name.as_str()).collect()
790+ }
791+
792+ #[tokio::test]
793+ async fn create_label_normalizes_and_rejects_duplicates() {
794+ let fx = fixture().await;
795+ let label = fx
796+ .store
797+ .create_label(&fx.repo.id, " Bug ", "#FF0000", "broken")
798+ .await
799+ .unwrap();
800+ assert_eq!(label.name, "Bug");
801+ assert_eq!(label.color, "#ff0000");
802+ assert_eq!(label.description, "broken");
803+ assert!(matches!(
804+ fx.store
805+ .create_label(&fx.repo.id, "BUG", "#00ff00", "")
806+ .await,
807+ Err(Error::Conflict(_))
808+ ));
809+ assert!(
810+ fx.store
811+ .create_label(&fx.repo.id, "bad", "red", "")
812+ .await
813+ .is_err()
814+ );
815+ // The same name in another repo is a different label.
816+ fx.store
817+ .create_label(&fx.other.id, "bug", "#000000", "")
818+ .await
819+ .unwrap();
820+ }
821+
822+ #[tokio::test]
823+ async fn create_default_labels_adds_nine_and_is_idempotent() {
824+ let fx = fixture().await;
825+ let s = &fx.store;
826+ assert_eq!(s.create_default_labels(&fx.repo.id).await.unwrap(), 9);
827+ let labels = s.list_labels(&fx.repo.id).await.unwrap();
828+ assert_eq!(labels.len(), 9);
829+ let bug = labels.iter().find(|l| l.name == "bug").unwrap();
830+ assert_eq!(bug.color, "#d73a4a");
831+ assert_eq!(bug.description, "Something isn't working");
832+ // A second run creates nothing and keeps the existing rows.
833+ assert_eq!(s.create_default_labels(&fx.repo.id).await.unwrap(), 0);
834+ assert_eq!(s.list_labels(&fx.repo.id).await.unwrap().len(), 9);
835+ }
836+
837+ #[tokio::test]
838+ async fn create_default_labels_skips_names_already_taken() {
839+ let fx = fixture().await;
840+ let s = &fx.store;
841+ s.create_label(&fx.repo.id, "BUG", "#000000", "mine")
842+ .await
843+ .unwrap();
844+ assert_eq!(s.create_default_labels(&fx.repo.id).await.unwrap(), 8);
845+ let labels = s.list_labels(&fx.repo.id).await.unwrap();
846+ assert_eq!(labels.len(), 9);
847+ let bug = labels.iter().find(|l| l.name == "BUG").unwrap();
848+ assert_eq!(bug.color, "#000000");
849+ assert_eq!(bug.description, "mine");
850+ }
851+
852+ #[tokio::test]
853+ async fn list_labels_is_sorted_and_scoped() {
854+ let fx = fixture().await;
855+ let s = &fx.store;
856+ s.create_label(&fx.repo.id, "b", "#111111", "")
857+ .await
858+ .unwrap();
859+ s.create_label(&fx.repo.id, "A", "#111111", "")
860+ .await
861+ .unwrap();
862+ s.create_label(&fx.other.id, "c", "#111111", "")
863+ .await
864+ .unwrap();
865+ assert_eq!(
866+ names(&s.list_labels(&fx.repo.id).await.unwrap()),
867+ ["A", "b"]
868+ );
869+ assert_eq!(names(&s.list_labels(&fx.other.id).await.unwrap()), ["c"]);
870+ }
871+
872+ #[tokio::test]
873+ async fn update_label_checks_conflicts_and_scope() {
874+ let fx = fixture().await;
875+ let s = &fx.store;
876+ let bug = s
877+ .create_label(&fx.repo.id, "bug", "#111111", "")
878+ .await
879+ .unwrap();
880+ s.create_label(&fx.repo.id, "docs", "#222222", "")
881+ .await
882+ .unwrap();
883+ let foreign = s
884+ .create_label(&fx.other.id, "x", "#333333", "")
885+ .await
886+ .unwrap();
887+
888+ assert!(matches!(
889+ s.update_label(&fx.repo.id, &bug.id, "DOCS", "#111111", "")
890+ .await,
891+ Err(Error::Conflict(_))
892+ ));
893+ assert!(matches!(
894+ s.update_label(&fx.repo.id, &foreign.id, "y", "#111111", "")
895+ .await,
896+ Err(Error::NotFound)
897+ ));
898+ s.update_label(&fx.repo.id, &bug.id, "Defect", "#00FF00", "renamed")
899+ .await
900+ .unwrap();
901+ let labels = s.list_labels(&fx.repo.id).await.unwrap();
902+ assert_eq!(names(&labels), ["Defect", "docs"]);
903+ assert_eq!(labels[0].color, "#00ff00");
904+ assert_eq!(labels[0].description, "renamed");
905+ // The foreign label was not touched.
906+ assert_eq!(names(&s.list_labels(&fx.other.id).await.unwrap()), ["x"]);
907+ }
908+
909+ #[tokio::test]
910+ async fn delete_label_removes_links_and_is_scoped() {
911+ let fx = fixture().await;
912+ let s = &fx.store;
913+ let bug = s
914+ .create_label(&fx.repo.id, "bug", "#111111", "")
915+ .await
916+ .unwrap();
917+ let issue = s
918+ .create_issue(&fx.repo.id, &fx.alice.id, "t", "")
919+ .await
920+ .unwrap();
921+ s.set_issue_labels(&issue.id, &fx.alice.id, std::slice::from_ref(&bug.id))
922+ .await
923+ .unwrap();
924+
925+ assert!(matches!(
926+ s.delete_label(&fx.other.id, &bug.id).await,
927+ Err(Error::NotFound)
928+ ));
929+ assert_eq!(s.issue_labels(&issue.id).await.unwrap().len(), 1);
930+
931+ s.delete_label(&fx.repo.id, &bug.id).await.unwrap();
932+ assert!(s.issue_labels(&issue.id).await.unwrap().is_empty());
933+ assert!(s.list_labels(&fx.repo.id).await.unwrap().is_empty());
934+ }
935+
936+ #[tokio::test]
937+ async fn set_issue_labels_diffs_and_records_events() {
938+ let fx = fixture().await;
939+ let s = &fx.store;
940+ let issue = s
941+ .create_issue(&fx.repo.id, &fx.alice.id, "t", "")
942+ .await
943+ .unwrap();
944+ let a = s
945+ .create_label(&fx.repo.id, "a", "#111111", "")
946+ .await
947+ .unwrap();
948+ let b = s
949+ .create_label(&fx.repo.id, "b", "#111111", "")
950+ .await
951+ .unwrap();
952+ let c = s
953+ .create_label(&fx.repo.id, "c", "#111111", "")
954+ .await
955+ .unwrap();
956+
957+ s.set_issue_labels(&issue.id, &fx.alice.id, &[a.id.clone(), b.id.clone()])
958+ .await
959+ .unwrap();
960+ let events = s.list_events(&issue.id).await.unwrap();
961+ let labeled = data_of(&events, "labeled");
962+ assert_eq!(labeled.len(), 2);
963+ assert!(labeled.contains(&json!({"label": "a"})));
964+ assert!(labeled.contains(&json!({"label": "b"})));
965+ assert!(data_of(&events, "unlabeled").is_empty());
966+
967+ s.set_issue_labels(&issue.id, &fx.alice.id, &[b.id.clone(), c.id.clone()])
968+ .await
969+ .unwrap();
970+ let events = s.list_events(&issue.id).await.unwrap();
971+ assert_eq!(data_of(&events, "unlabeled"), [json!({"label": "a"})]);
972+ assert_eq!(data_of(&events, "labeled").len(), 3);
973+ assert_eq!(names(&s.issue_labels(&issue.id).await.unwrap()), ["b", "c"]);
974+
975+ // Setting the same set again changes nothing and records nothing.
976+ s.set_issue_labels(&issue.id, &fx.alice.id, &[c.id.clone(), b.id.clone()])
977+ .await
978+ .unwrap();
979+ let events = s.list_events(&issue.id).await.unwrap();
980+ assert_eq!(data_of(&events, "labeled").len(), 3);
981+ assert_eq!(data_of(&events, "unlabeled").len(), 1);
982+ }
983+
984+ #[tokio::test]
985+ async fn set_issue_labels_rejects_foreign_label_without_partial_write() {
986+ let fx = fixture().await;
987+ let s = &fx.store;
988+ let issue = s
989+ .create_issue(&fx.repo.id, &fx.alice.id, "t", "")
990+ .await
991+ .unwrap();
992+ let a = s
993+ .create_label(&fx.repo.id, "a", "#111111", "")
994+ .await
995+ .unwrap();
996+ let b = s
997+ .create_label(&fx.repo.id, "b", "#111111", "")
998+ .await
999+ .unwrap();
1000+ let foreign = s
1001+ .create_label(&fx.other.id, "x", "#111111", "")
1002+ .await
1003+ .unwrap();
1004+ s.set_issue_labels(&issue.id, &fx.alice.id, std::slice::from_ref(&a.id))
1005+ .await
1006+ .unwrap();
1007+
1008+ let err = s
1009+ .set_issue_labels(&issue.id, &fx.alice.id, &[b.id.clone(), foreign.id.clone()])
1010+ .await;
1011+ assert!(matches!(err, Err(Error::Invalid(_))));
1012+ assert_eq!(names(&s.issue_labels(&issue.id).await.unwrap()), ["a"]);
1013+ let events = s.list_events(&issue.id).await.unwrap();
1014+ assert_eq!(data_of(&events, "labeled").len(), 1);
1015+
1016+ assert!(matches!(
1017+ s.set_issue_labels("missing", &fx.alice.id, &[]).await,
1018+ Err(Error::NotFound)
1019+ ));
1020+ }
1021+
1022+ #[tokio::test]
1023+ async fn set_issue_assignees_dedups_resolves_and_records_events() {
1024+ let fx = fixture().await;
1025+ let s = &fx.store;
1026+ let issue = s
1027+ .create_issue(&fx.repo.id, &fx.alice.id, "t", "")
1028+ .await
1029+ .unwrap();
1030+ let input = ["bob".to_string(), "BOB".to_string(), " Alice ".to_string()];
1031+ s.set_issue_assignees(&issue.id, &fx.alice.id, &input)
1032+ .await
1033+ .unwrap();
1034+
1035+ let assignees = s.issue_assignees(&issue.id).await.unwrap();
1036+ let display: Vec<&str> = assignees.iter().map(|(_, n)| n.as_str()).collect();
1037+ assert_eq!(display, ["Alice", "Bob"]);
1038+ let events = s.list_events(&issue.id).await.unwrap();
1039+ assert_eq!(data_of(&events, "assigned").len(), 2);
1040+ assert!(data_of(&events, "assigned").contains(&json!({"user": "Bob"})));
1041+
1042+ s.set_issue_assignees(&issue.id, &fx.alice.id, &["alice".to_string()])
1043+ .await
1044+ .unwrap();
1045+ let assignees = s.issue_assignees(&issue.id).await.unwrap();
1046+ assert_eq!(assignees.len(), 1);
1047+ assert_eq!(assignees[0].0, fx.alice.id);
1048+ let events = s.list_events(&issue.id).await.unwrap();
1049+ assert_eq!(data_of(&events, "unassigned"), [json!({"user": "Bob"})]);
1050+ }
1051+
1052+ #[tokio::test]
1053+ async fn set_issue_assignees_enforces_limit_existence_and_read_access() {
1054+ let fx = fixture().await;
1055+ let s = &fx.store;
1056+ let issue = s
1057+ .create_issue(&fx.repo.id, &fx.alice.id, "t", "")
1058+ .await
1059+ .unwrap();
1060+
1061+ let mut usernames = Vec::new();
1062+ for i in 0..11 {
1063+ let name = format!("user{i}");
1064+ s.create_user(&name, None, "hash", false).await.unwrap();
1065+ usernames.push(name);
1066+ }
1067+ assert!(matches!(
1068+ s.set_issue_assignees(&issue.id, &fx.alice.id, &usernames)
1069+ .await,
1070+ Err(Error::Invalid(_))
1071+ ));
1072+ s.set_issue_assignees(&issue.id, &fx.alice.id, &usernames[..10])
1073+ .await
1074+ .unwrap();
1075+ assert_eq!(s.issue_assignees(&issue.id).await.unwrap().len(), 10);
1076+
1077+ assert!(matches!(
1078+ s.set_issue_assignees(&issue.id, &fx.alice.id, &["ghost".to_string()])
1079+ .await,
1080+ Err(Error::Invalid(_))
1081+ ));
1082+
1083+ // Bob has no role on a private repo, so he cannot be assigned there.
1084+ let secret = s
1085+ .create_repo(&fx.alice.id, "secret", "", Visibility::Private, "main")
1086+ .await
1087+ .unwrap();
1088+ let secret_issue = s
1089+ .create_issue(&secret.id, &fx.alice.id, "t", "")
1090+ .await
1091+ .unwrap();
1092+ let bob = ["bob".to_string()];
1093+ assert!(matches!(
1094+ s.set_issue_assignees(&secret_issue.id, &fx.alice.id, &bob)
1095+ .await,
1096+ Err(Error::Invalid(_))
1097+ ));
1098+ s.set_collaborator(&secret.id, &fx.bob.id, Role::Read)
1099+ .await
1100+ .unwrap();
1101+ s.set_issue_assignees(&secret_issue.id, &fx.alice.id, &bob)
1102+ .await
1103+ .unwrap();
1104+ assert_eq!(s.issue_assignees(&secret_issue.id).await.unwrap().len(), 1);
1105+ }
1106+
1107+ #[tokio::test]
1108+ async fn milestones_create_validate_and_count_in_one_listing() {
1109+ let fx = fixture().await;
1110+ let s = &fx.store;
1111+ let v1 = s
1112+ .create_milestone(&fx.repo.id, "v1", "first", Some("2026-12-31"))
1113+ .await
1114+ .unwrap();
1115+ assert_eq!(v1.due_on.as_deref(), Some("2026-12-31"));
1116+ for bad in ["2026/12/31", "2026-02-30", "2026-1-5", "2026-12-31T00:00"] {
1117+ assert!(matches!(
1118+ s.create_milestone(&fx.repo.id, "x", "", Some(bad)).await,
1119+ Err(Error::Invalid(_))
1120+ ));
1121+ }
1122+ assert!(matches!(
1123+ s.create_milestone(&fx.repo.id, "V1", "", None).await,
1124+ Err(Error::Conflict(_))
1125+ ));
1126+
1127+ let i1 = s
1128+ .create_issue(&fx.repo.id, &fx.alice.id, "a", "")
1129+ .await
1130+ .unwrap();
1131+ let i2 = s
1132+ .create_issue(&fx.repo.id, &fx.alice.id, "b", "")
1133+ .await
1134+ .unwrap();
1135+ let i3 = s
1136+ .create_issue(&fx.repo.id, &fx.alice.id, "c", "")
1137+ .await
1138+ .unwrap();
1139+ s.set_issue_milestone(&i1.id, &fx.alice.id, Some(&v1.id))
1140+ .await
1141+ .unwrap();
1142+ s.set_issue_milestone(&i2.id, &fx.alice.id, Some(&v1.id))
1143+ .await
1144+ .unwrap();
1145+ s.set_issue_milestone(&i3.id, &fx.alice.id, None)
1146+ .await
1147+ .unwrap();
1148+ s.set_issue_state(
1149+ &i1.id,
1150+ Some(&fx.alice.id),
1151+ IssueState::Closed,
1152+ Some(StateReason::Completed),
1153+ json!({}),
1154+ )
1155+ .await
1156+ .unwrap();
1157+
1158+ let all = s.list_milestones(&fx.repo.id, None).await.unwrap();
1159+ assert_eq!(all.len(), 1);
1160+ assert_eq!((all[0].open_issues, all[0].closed_issues), (1, 1));
1161+ assert!(
1162+ s.list_milestones(&fx.repo.id, Some(IssueState::Closed))
1163+ .await
1164+ .unwrap()
1165+ .is_empty()
1166+ );
1167+ assert_eq!(
1168+ s.list_milestones(&fx.repo.id, Some(IssueState::Open))
1169+ .await
1170+ .unwrap()
1171+ .len(),
1172+ 1
1173+ );
1174+
1175+ let got = s
1176+ .milestone_by_id(&fx.repo.id, &v1.id)
1177+ .await
1178+ .unwrap()
1179+ .unwrap();
1180+ assert_eq!((got.open_issues, got.closed_issues), (1, 1));
1181+ assert!(
1182+ s.milestone_by_id(&fx.other.id, &v1.id)
1183+ .await
1184+ .unwrap()
1185+ .is_none()
1186+ );
1187+ }
1188+
1189+ #[tokio::test]
1190+ async fn update_milestone_sets_closed_at_and_is_scoped() {
1191+ let fx = fixture().await;
1192+ let s = &fx.store;
1193+ let v1 = s
1194+ .create_milestone(&fx.repo.id, "v1", "", Some("2026-12-31"))
1195+ .await
1196+ .unwrap();
1197+ assert!(matches!(
1198+ s.update_milestone(&fx.other.id, &v1.id, "v1", "", None, IssueState::Open)
1199+ .await,
1200+ Err(Error::NotFound)
1201+ ));
1202+
1203+ s.update_milestone(
1204+ &fx.repo.id,
1205+ &v1.id,
1206+ "v1.0",
1207+ "done",
1208+ None,
1209+ IssueState::Closed,
1210+ )
1211+ .await
1212+ .unwrap();
1213+ let closed = s
1214+ .milestone_by_id(&fx.repo.id, &v1.id)
1215+ .await
1216+ .unwrap()
1217+ .unwrap();
1218+ assert_eq!(closed.title, "v1.0");
1219+ assert_eq!(closed.description, "done");
1220+ assert!(closed.due_on.is_none());
1221+ assert!(matches!(closed.state, IssueState::Closed));
1222+ assert!(closed.closed_at.is_some());
1223+
1224+ s.update_milestone(&fx.repo.id, &v1.id, "v1.0", "done", None, IssueState::Open)
1225+ .await
1226+ .unwrap();
1227+ let reopened = s
1228+ .milestone_by_id(&fx.repo.id, &v1.id)
1229+ .await
1230+ .unwrap()
1231+ .unwrap();
1232+ assert!(matches!(reopened.state, IssueState::Open));
1233+ assert!(reopened.closed_at.is_none());
1234+ }
1235+
1236+ #[tokio::test]
1237+ async fn set_issue_milestone_checks_repo_and_records_events() {
1238+ let fx = fixture().await;
1239+ let s = &fx.store;
1240+ let issue = s
1241+ .create_issue(&fx.repo.id, &fx.alice.id, "t", "")
1242+ .await
1243+ .unwrap();
1244+ let v1 = s
1245+ .create_milestone(&fx.repo.id, "v1", "", None)
1246+ .await
1247+ .unwrap();
1248+ let v2 = s
1249+ .create_milestone(&fx.repo.id, "v2", "", None)
1250+ .await
1251+ .unwrap();
1252+ let foreign = s
1253+ .create_milestone(&fx.other.id, "v9", "", None)
1254+ .await
1255+ .unwrap();
1256+
1257+ assert!(matches!(
1258+ s.set_issue_milestone(&issue.id, &fx.alice.id, Some(&foreign.id))
1259+ .await,
1260+ Err(Error::Invalid(_))
1261+ ));
1262+
1263+ s.set_issue_milestone(&issue.id, &fx.alice.id, Some(&v1.id))
1264+ .await
1265+ .unwrap();
1266+ let events = s.list_events(&issue.id).await.unwrap();
1267+ assert_eq!(data_of(&events, "milestoned"), [json!({"title": "v1"})]);
1268+
1269+ s.set_issue_milestone(&issue.id, &fx.alice.id, Some(&v2.id))
1270+ .await
1271+ .unwrap();
1272+ let events = s.list_events(&issue.id).await.unwrap();
1273+ assert_eq!(data_of(&events, "demilestoned"), [json!({"title": "v1"})]);
1274+ assert_eq!(data_of(&events, "milestoned").len(), 2);
1275+
1276+ s.set_issue_milestone(&issue.id, &fx.alice.id, None)
1277+ .await
1278+ .unwrap();
1279+ let events = s.list_events(&issue.id).await.unwrap();
1280+ assert_eq!(data_of(&events, "demilestoned").len(), 2);
1281+ assert!(data_of(&events, "demilestoned").contains(&json!({"title": "v2"})));
1282+
1283+ // Clearing again is a no-op.
1284+ s.set_issue_milestone(&issue.id, &fx.alice.id, None)
1285+ .await
1286+ .unwrap();
1287+ let events = s.list_events(&issue.id).await.unwrap();
1288+ assert_eq!(data_of(&events, "demilestoned").len(), 2);
1289+ let stored = s
1290+ .issue_by_number(&fx.repo.id, issue.number)
1291+ .await
1292+ .unwrap()
1293+ .unwrap();
1294+ assert!(stored.milestone_id.is_none());
1295+ }
1296+
1297+ #[tokio::test]
1298+ async fn delete_milestone_clears_issue_links_and_is_scoped() {
1299+ let fx = fixture().await;
1300+ let s = &fx.store;
1301+ let v1 = s
1302+ .create_milestone(&fx.repo.id, "v1", "", None)
1303+ .await
1304+ .unwrap();
1305+ let issue = s
1306+ .create_issue(&fx.repo.id, &fx.alice.id, "t", "")
1307+ .await
1308+ .unwrap();
1309+ s.set_issue_milestone(&issue.id, &fx.alice.id, Some(&v1.id))
1310+ .await
1311+ .unwrap();
1312+
1313+ assert!(matches!(
1314+ s.delete_milestone(&fx.other.id, &v1.id).await,
1315+ Err(Error::NotFound)
1316+ ));
1317+ let kept = s
1318+ .issue_by_number(&fx.repo.id, issue.number)
1319+ .await
1320+ .unwrap()
1321+ .unwrap();
1322+ assert_eq!(kept.milestone_id.as_deref(), Some(v1.id.as_str()));
1323+
crates/forge-core/src/store/lfs.rsadded+97 -0

Diff too large; view file

crates/forge-core/src/store/mail.rsadded+541 -0

Diff too large; view file

crates/forge-core/src/store/migrate.rsadded+356 -0

Diff too large; view file

crates/forge-core/src/store/mirrors.rsadded+691 -0

Diff too large; view file

crates/forge-core/src/store/mod.rsadded+2456 -0

Diff too large; view file

crates/forge-core/src/store/notifications.rsadded+923 -0

Diff too large; view file

crates/forge-core/src/store/notifications_extra.rsadded+45 -0

Diff too large; view file

crates/forge-core/src/store/orgs.rsadded+1125 -0

Diff too large; view file

crates/forge-core/src/store/pinned.rsadded+227 -0

Diff too large; view file

crates/forge-core/src/store/pulls.rsadded+1047 -0

Diff too large; view file

crates/forge-core/src/store/pulls_list_extra.rsadded+32 -0

Diff too large; view file

crates/forge-core/src/store/push_recent.rsadded+191 -0

Diff too large; view file

crates/forge-core/src/store/reactions.rsadded+378 -0

Diff too large; view file

crates/forge-core/src/store/releases.rsadded+461 -0

Diff too large; view file

crates/forge-core/src/store/replication.rsadded+203 -0

Diff too large; view file

crates/forge-core/src/store/reviews.rsadded+1646 -0

Diff too large; view file

crates/forge-core/src/store/signatures.rsadded+418 -0

Diff too large; view file

crates/forge-core/src/store/snapshot.rsadded+281 -0

Diff too large; view file

crates/forge-core/src/store/snippets.rsadded+590 -0

Diff too large; view file

crates/forge-core/src/store/transfers.rsadded+491 -0

Diff too large; view file

crates/forge-core/src/store/ui_prefs.rsadded+116 -0

Diff too large; view file

crates/forge-core/src/store/user_delete.rsadded+692 -0

Diff too large; view file

crates/forge-core/src/store/webhooks.rsadded+1260 -0

Diff too large; view file

crates/forge-core/src/store/wiki.rsadded+80 -0

Diff too large; view file

crates/forge-core/src/time.rsadded+81 -0

Diff too large; view file

crates/forge-core/src/token.rsadded+75 -0

Diff too large; view file

crates/forge-core/src/totp.rsadded+258 -0

Diff too large; view file

crates/forge-core/src/tz.rsadded+93 -0

Diff too large; view file

crates/forge-core/src/webhooks.rsadded+150 -0

Diff too large; view file

crates/forge-core/tests/audit_store.rsadded+161 -0

Diff too large; view file

crates/forge-core/tests/read_pool.rsadded+149 -0

Diff too large; view file

crates/forge-core/tests/stars_store.rsadded+246 -0

Diff too large; view file

crates/forge-core/tests/store_basic.rsadded+542 -0

Diff too large; view file

crates/forge-git/Cargo.tomladded+19 -0

Diff too large; view file

crates/forge-git/src/backup.rsadded+242 -0

Diff too large; view file

crates/forge-git/src/browse.rsadded+441 -0

Diff too large; view file

crates/forge-git/src/command.rsadded+322 -0

Diff too large; view file

crates/forge-git/src/error.rsadded+26 -0

Diff too large; view file

crates/forge-git/src/fsck.rsadded+89 -0

Diff too large; view file

crates/forge-git/src/history.rsadded+746 -0

Diff too large; view file

crates/forge-git/src/lib.rsadded+24 -0

Diff too large; view file

crates/forge-git/src/maintenance.rsadded+112 -0

Diff too large; view file

crates/forge-git/src/mirror.rsadded+733 -0

Diff too large; view file

crates/forge-git/src/pktline.rsadded+23 -0

Diff too large; view file

crates/forge-git/src/pulls.rsadded+1075 -0

Diff too large; view file

crates/forge-git/src/repo.rsadded+488 -0

Diff too large; view file

crates/forge-git/src/search.rsadded+430 -0

Diff too large; view file

crates/forge-git/src/service.rsadded+64 -0

Diff too large; view file

crates/forge-git/src/setup.rsadded+126 -0

Diff too large; view file

crates/forge-git/src/tags.rsadded+67 -0

Diff too large; view file

crates/forge-git/src/verify.rsadded+535 -0

Diff too large; view file

crates/forge-git/src/version.rsadded+47 -0

Diff too large; view file

crates/forge-git/src/wiki.rsadded+370 -0

Diff too large; view file

crates/forge-git/tests/browse.rsadded+318 -0

Diff too large; view file

crates/forge-git/tests/forge_git.rsadded+136 -0

Diff too large; view file

crates/forge-git/tests/history.rsadded+387 -0

Diff too large; view file

crates/forge-runner/Cargo.tomladded+46 -0

Diff too large; view file

crates/forge-runner/src/artifacts.rsadded+274 -0

Diff too large; view file

crates/forge-runner/src/check.rsadded+517 -0

Diff too large; view file

crates/forge-runner/src/cli.rsadded+324 -0

Diff too large; view file

crates/forge-runner/src/client.rsadded+226 -0

Diff too large; view file

crates/forge-runner/src/config.rsadded+408 -0

Diff too large; view file

crates/forge-runner/src/executor.rsadded+913 -0

Diff too large; view file

crates/forge-runner/src/lib.rsadded+17 -0

Diff too large; view file

crates/forge-runner/src/logs.rsadded+432 -0

Diff too large; view file

crates/forge-runner/src/main.rsadded+13 -0

Diff too large; view file

crates/forge-runner/src/oci/helper.rsadded+86 -0

Diff too large; view file

crates/forge-runner/src/oci/mod.rsadded+74 -0

Diff too large; view file

crates/forge-runner/src/oci/pull.rsadded+445 -0

Diff too large; view file

crates/forge-runner/src/oci/store.rsadded+480 -0

Diff too large; view file

crates/forge-runner/src/oci/unpack.rsadded+889 -0

Diff too large; view file

crates/forge-runner/src/runner.rsadded+323 -0

Diff too large; view file

crates/forge-runner/src/sandbox/exec.rsadded+63 -0

Diff too large; view file

crates/forge-runner/src/sandbox/helper.rsadded+527 -0

Diff too large; view file

crates/forge-runner/src/sandbox/idmap.rsadded+609 -0

Diff too large; view file

crates/forge-runner/src/sandbox/mod.rsadded+72 -0

Diff too large; view file

crates/forge-runner/src/sandbox/seccomp.rsadded+191 -0

Diff too large; view file

crates/forge-runner/src/sandbox/spec.rsadded+754 -0

Diff too large; view file

crates/forge-runner/src/workspace.rsadded+239 -0

Diff too large; view file

crates/forge-runner/tests/builtin.rsadded+322 -0

Diff too large; view file

crates/forge-runner/tests/flow.rsadded+548 -0

Diff too large; view file

crates/forge-runner/tests/idmap.rsadded+54 -0

Diff too large; view file

crates/forge-runner/tests/sandbox.rsadded+505 -0

Diff too large; view file

crates/forge-runner/tests/unpack_subuid.rsadded+136 -0

Diff too large; view file

crates/forge-ssh/Cargo.tomladded+19 -0

Diff too large; view file

crates/forge-ssh/src/backend.rsadded+37 -0

Diff too large; view file

crates/forge-ssh/src/error.rsadded+11 -0

Diff too large; view file

crates/forge-ssh/src/lib.rsadded+11 -0

Diff too large; view file

crates/forge-ssh/src/parse.rsadded+428 -0

Diff too large; view file

crates/forge-ssh/src/server.rsadded+610 -0

Diff too large; view file

crates/forge-ssh/tests/ssh_e2e.rsadded+308 -0

Diff too large; view file

crates/forge-web/Cargo.tomladded+66 -0

Diff too large; view file

crates/forge-web/assets/nav.jsadded+305 -0

Diff too large; view file

crates/forge-web/build.rsadded+39 -0

Diff too large; view file

crates/forge-web/src/access.rsadded+183 -0

Diff too large; view file

crates/forge-web/src/admin_cli.rsadded+763 -0

Diff too large; view file

crates/forge-web/src/admin_client.rsadded+231 -0

Diff too large; view file

crates/forge-web/src/advisories/mod.rsadded+6 -0

Diff too large; view file

crates/forge-web/src/advisories/scan.rsadded+150 -0

Diff too large; view file

crates/forge-web/src/advisories/worker.rsadded+191 -0

Diff too large; view file

crates/forge-web/src/api/mod.rsadded+268 -0

Diff too large; view file

crates/forge-web/src/api/statuses.rsadded+567 -0

Diff too large; view file

crates/forge-web/src/audit.rsadded+54 -0

Diff too large; view file

crates/forge-web/src/auth.rsadded+449 -0

Diff too large; view file

crates/forge-web/src/backup/create.rsadded+308 -0

Diff too large; view file

crates/forge-web/src/backup/fsck.rsadded+126 -0

Diff too large; view file

crates/forge-web/src/backup/healthcheck.rsadded+108 -0

Diff too large; view file

crates/forge-web/src/backup/mod.rsadded+264 -0

Diff too large; view file

crates/forge-web/src/backup/restore.rsadded+625 -0

Diff too large; view file

crates/forge-web/src/backup/retention.rsadded+161 -0

Diff too large; view file

crates/forge-web/src/backup/scheduler.rsadded+524 -0

Diff too large; view file

crates/forge-web/src/backup/tests.rsadded+456 -0

Diff too large; view file

crates/forge-web/src/backup/verify.rsadded+306 -0

Diff too large; view file

crates/forge-web/src/blobs.rsadded+143 -0

Diff too large; view file

crates/forge-web/src/boot.rsadded+280 -0

Diff too large; view file

crates/forge-web/src/ci/api.rsadded+639 -0

Diff too large; view file

crates/forge-web/src/ci/api_tests.rsadded+771 -0

Diff too large; view file

crates/forge-web/src/ci/mod.rsadded+197 -0

Diff too large; view file

crates/forge-web/src/ci/reaper.rsadded+124 -0

Diff too large; view file

crates/forge-web/src/ci/schedule.rsadded+892 -0

Diff too large; view file

crates/forge-web/src/client_ip.rsadded+156 -0

Diff too large; view file

crates/forge-web/src/compression.rsadded+288 -0

Diff too large; view file

crates/forge-web/src/git_auth.rsadded+175 -0

Diff too large; view file

crates/forge-web/src/git_http.rsadded+608 -0

Diff too large; view file

crates/forge-web/src/hook_cmd.rsadded+236 -0

Diff too large; view file

crates/forge-web/src/i18n/admin.rsadded+819 -0

Diff too large; view file

crates/forge-web/src/i18n/code.rsadded+624 -0

Diff too large; view file

crates/forge-web/src/i18n/community.rsadded+575 -0

Diff too large; view file

crates/forge-web/src/i18n/integrations.rsadded+660 -0

Diff too large; view file

crates/forge-web/src/i18n/issues.rsadded+1039 -0

Diff too large; view file

crates/forge-web/src/i18n/misc.rsadded+776 -0

Diff too large; view file

crates/forge-web/src/i18n/mod.rsadded+1267 -0

Diff too large; view file

crates/forge-web/src/i18n/orgs.rsadded+617 -0

Diff too large; view file

crates/forge-web/src/i18n/pulls.rsadded+380 -0

Diff too large; view file

crates/forge-web/src/i18n/pulls_review.rsadded+441 -0

Diff too large; view file

crates/forge-web/src/i18n/releases_ci.rsadded+565 -0

Diff too large; view file

crates/forge-web/src/i18n/repo_admin.rsadded+783 -0

Diff too large; view file

crates/forge-web/src/internal.rsadded+1082 -0

Diff too large; view file

crates/forge-web/src/lfs/gc.rsadded+247 -0

Diff too large; view file

crates/forge-web/src/lfs/mod.rsadded+82 -0

Diff too large; view file

crates/forge-web/src/lfs/route_tests.rsadded+626 -0

Diff too large; view file

crates/forge-web/src/lfs/routes.rsadded+512 -0

Diff too large; view file

crates/forge-web/src/lfs_pointer.rsadded+148 -0

Diff too large; view file

crates/forge-web/src/lib.rsadded+1 -0

Diff too large; view file

crates/forge-web/src/mail/account.rsadded+71 -0

Diff too large; view file

crates/forge-web/src/mail/log.rsadded+44 -0

Diff too large; view file

crates/forge-web/src/mail/mod.rsadded+109 -0

Diff too large; view file

crates/forge-web/src/mail/smtp.rsadded+161 -0

Diff too large; view file

crates/forge-web/src/mail/templates.rsadded+244 -0

Diff too large; view file

crates/forge-web/src/mail/worker.rsadded+266 -0

Diff too large; view file

crates/forge-web/src/main.rsadded+218 -0

Diff too large; view file

crates/forge-web/src/mirrors/mod.rsadded+457 -0

Diff too large; view file

crates/forge-web/src/mirrors/worker.rsadded+253 -0

Diff too large; view file

crates/forge-web/src/notify.rsadded+1189 -0

Diff too large; view file

crates/forge-web/src/pages/admin_backups.rsadded+551 -0

Diff too large; view file

crates/forge-web/src/pages/admin_mail.rsadded+588 -0

Diff too large; view file

crates/forge-web/src/pages/admin_replication.rsadded+544 -0

Diff too large; view file

crates/forge-web/src/pages/admin_runners.rsadded+564 -0

Diff too large; view file

crates/forge-web/src/pages/admin_users.rsadded+400 -0

Diff too large; view file

crates/forge-web/src/pages/archive.rsadded+192 -0

Diff too large; view file

crates/forge-web/src/pages/assets.rsadded+294 -0

Diff too large; view file

crates/forge-web/src/pages/audit_log.rsadded+448 -0

Diff too large; view file

crates/forge-web/src/pages/avatars.rsadded+161 -0

Diff too large; view file

crates/forge-web/src/pages/blob.rsadded+300 -0

Diff too large; view file

crates/forge-web/src/pages/ci_job.rsadded+390 -0

Diff too large; view file

crates/forge-web/src/pages/ci_runs.rsadded+799 -0

Diff too large; view file

crates/forge-web/src/pages/ci_tests.rsadded+581 -0

Diff too large; view file

crates/forge-web/src/pages/code_search.rsadded+466 -0

Diff too large; view file

crates/forge-web/src/pages/commit.rsadded+345 -0

Diff too large; view file

crates/forge-web/src/pages/commits.rsadded+685 -0

Diff too large; view file

crates/forge-web/src/pages/compare.rsadded+1013 -0

Diff too large; view file

crates/forge-web/src/pages/dashboard.rsadded+216 -0

Diff too large; view file

crates/forge-web/src/pages/discussion_actions.rsadded+389 -0

Diff too large; view file

crates/forge-web/src/pages/discussion_settings.rsadded+93 -0

Diff too large; view file

crates/forge-web/src/pages/discussion_view.rsadded+484 -0

Diff too large; view file

crates/forge-web/src/pages/discussions.rsadded+527 -0

Diff too large; view file

crates/forge-web/src/pages/edit_history.rsadded+607 -0

Diff too large; view file

crates/forge-web/src/pages/explore.rsadded+264 -0

Diff too large; view file

crates/forge-web/src/pages/forgot.rsadded+384 -0

Diff too large; view file

crates/forge-web/src/pages/fork.rsadded+529 -0

Diff too large; view file

crates/forge-web/src/pages/invites.rsadded+147 -0

Diff too large; view file

crates/forge-web/src/pages/issue_meta.rsadded+421 -0

Diff too large; view file

crates/forge-web/src/pages/issue_meta_tests.rsadded+769 -0

Diff too large; view file

crates/forge-web/src/pages/issue_parts.rsadded+1331 -0

Diff too large; view file

crates/forge-web/src/pages/issue_pin.rsadded+110 -0

Diff too large; view file

crates/forge-web/src/pages/issue_pin_tests.rsadded+342 -0

Diff too large; view file

crates/forge-web/src/pages/issue_view.rsadded+1027 -0

Diff too large; view file

crates/forge-web/src/pages/issues_list.rsadded+1347 -0

Diff too large; view file

crates/forge-web/src/pages/labels_css.rsadded+242 -0

Diff too large; view file

crates/forge-web/src/pages/labels_page.rsadded+459 -0

Diff too large; view file

crates/forge-web/src/pages/login.rsadded+888 -0

Diff too large; view file

crates/forge-web/src/pages/media.rsadded+465 -0

Diff too large; view file

crates/forge-web/src/pages/milestones_page.rsadded+453 -0

Diff too large; view file

crates/forge-web/src/pages/mod.rsadded+95 -0

Diff too large; view file

crates/forge-web/src/pages/new_repo.rsadded+610 -0

Diff too large; view file

crates/forge-web/src/pages/notifications.rsadded+1377 -0

Diff too large; view file

crates/forge-web/src/pages/org_ctx.rsadded+255 -0

Diff too large; view file

crates/forge-web/src/pages/org_members.rsadded+355 -0

Diff too large; view file

crates/forge-web/src/pages/org_new.rsadded+151 -0

Diff too large; view file

crates/forge-web/src/pages/org_settings.rsadded+224 -0

Diff too large; view file

crates/forge-web/src/pages/org_teams.rsadded+779 -0

Diff too large; view file

crates/forge-web/src/pages/profile.rsadded+382 -0

Diff too large; view file

crates/forge-web/src/pages/pull_branch.rsadded+767 -0

Diff too large; view file

crates/forge-web/src/pages/pull_files.rsadded+589 -0

Diff too large; view file

crates/forge-web/src/pages/pull_reviews.rsadded+1180 -0

Diff too large; view file

crates/forge-web/src/pages/pull_view.rsadded+1607 -0

Diff too large; view file

crates/forge-web/src/pages/pulls_list.rsadded+532 -0

Diff too large; view file

crates/forge-web/src/pages/pulls_merge.rsadded+1231 -0

Diff too large; view file

crates/forge-web/src/pages/reactions.rsadded+90 -0

Diff too large; view file

crates/forge-web/src/pages/reactions_tests.rsadded+344 -0

Diff too large; view file

crates/forge-web/src/pages/refs.rsadded+209 -0

Diff too large; view file

crates/forge-web/src/pages/register.rsadded+683 -0

Diff too large; view file

crates/forge-web/src/pages/releases.rsadded+1308 -0

Diff too large; view file

crates/forge-web/src/pages/releases_tests.rsadded+590 -0

Diff too large; view file

crates/forge-web/src/pages/repo.rsadded+987 -0

Diff too large; view file