fabrica

hanna/fabrica

feat(config): load, validate, and show TOML+env configuration

d1a7fc7 · hanna committed on 2026-07-25

Implement phase 2. The config crate loads a TOML file layered over
built-in defaults and FABRICA__* env overrides via figment (defaults ->
/etc -> XDG -> --config -> env, later wins), then validates strictly and
fails fast: unknown keys are an error, instance.url must parse without a
trailing slash, storage dirs must be creatable, smtp needs host/from;
cookie-secure-over-http is a warning.

Secrets (auth.secret, mail.password) are a redact-by-default Secret
newtype — Debug and Serialize always emit <redacted>, the real value is
reachable only via expose() — and their *_file siblings are read from
disk and take precedence. This keeps secrets out of logs, errors, and
`config show`.

Wire `fabrica config check` and `fabrica config show [--json]` into a
minimal clap tree with global --config/--log-level/--json. Ship a
fully-commented fabrica.example.toml and docs/configuration.md, and log
the Secret and strict-loading decisions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
12 files changed · +2136 −4UnifiedSplit
Cargo.lock +875 −0
@@ -3,20 +3,189 @@
33 version = 4
44
55 [[package]]
6+name = "anstream"
7+version = "1.0.0"
8+source = "registry+https://github.com/rust-lang/crates.io-index"
9+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
10+dependencies = [
11+ "anstyle",
12+ "anstyle-parse",
13+ "anstyle-query",
14+ "anstyle-wincon",
15+ "colorchoice",
16+ "is_terminal_polyfill",
17+ "utf8parse",
18+]
19+
20+[[package]]
21+name = "anstyle"
22+version = "1.0.14"
23+source = "registry+https://github.com/rust-lang/crates.io-index"
24+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
25+
26+[[package]]
27+name = "anstyle-parse"
28+version = "1.0.0"
29+source = "registry+https://github.com/rust-lang/crates.io-index"
30+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
31+dependencies = [
32+ "utf8parse",
33+]
34+
35+[[package]]
36+name = "anstyle-query"
37+version = "1.1.5"
38+source = "registry+https://github.com/rust-lang/crates.io-index"
39+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
40+dependencies = [
41+ "windows-sys",
42+]
43+
44+[[package]]
45+name = "anstyle-wincon"
46+version = "3.0.11"
47+source = "registry+https://github.com/rust-lang/crates.io-index"
48+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
49+dependencies = [
50+ "anstyle",
51+ "once_cell_polyfill",
52+ "windows-sys",
53+]
54+
55+[[package]]
56+name = "anyhow"
57+version = "1.0.104"
58+source = "registry+https://github.com/rust-lang/crates.io-index"
59+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
60+
61+[[package]]
662 name = "api"
763 version = "0.1.0"
864
965 [[package]]
66+name = "atomic"
67+version = "0.6.1"
68+source = "registry+https://github.com/rust-lang/crates.io-index"
69+checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340"
70+dependencies = [
71+ "bytemuck",
72+]
73+
74+[[package]]
1075 name = "auth"
1176 version = "0.1.0"
1277
1378 [[package]]
79+name = "bitflags"
80+version = "2.13.1"
81+source = "registry+https://github.com/rust-lang/crates.io-index"
82+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
83+
84+[[package]]
85+name = "bytemuck"
86+version = "1.25.2"
87+source = "registry+https://github.com/rust-lang/crates.io-index"
88+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
89+
90+[[package]]
91+name = "cfg-if"
92+version = "1.0.4"
93+source = "registry+https://github.com/rust-lang/crates.io-index"
94+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
95+
96+[[package]]
97+name = "clap"
98+version = "4.6.4"
99+source = "registry+https://github.com/rust-lang/crates.io-index"
100+checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7"
101+dependencies = [
102+ "clap_builder",
103+ "clap_derive",
104+]
105+
106+[[package]]
107+name = "clap_builder"
108+version = "4.6.2"
109+source = "registry+https://github.com/rust-lang/crates.io-index"
110+checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
111+dependencies = [
112+ "anstream",
113+ "anstyle",
114+ "clap_lex",
115+ "strsim",
116+]
117+
118+[[package]]
119+name = "clap_derive"
120+version = "4.6.4"
121+source = "registry+https://github.com/rust-lang/crates.io-index"
122+checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
123+dependencies = [
124+ "heck",
125+ "proc-macro2",
126+ "quote",
127+ "syn 3.0.3",
128+]
129+
130+[[package]]
131+name = "clap_lex"
132+version = "1.1.0"
133+source = "registry+https://github.com/rust-lang/crates.io-index"
134+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
135+
136+[[package]]
14137 name = "cli"
15138 version = "0.1.0"
139+dependencies = [
140+ "anyhow",
141+ "clap",
142+ "config",
143+ "serde_json",
144+]
145+
146+[[package]]
147+name = "colorchoice"
148+version = "1.0.5"
149+source = "registry+https://github.com/rust-lang/crates.io-index"
150+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
16151
17152 [[package]]
18153 name = "config"
19154 version = "0.1.0"
155+dependencies = [
156+ "figment",
157+ "serde",
158+ "thiserror",
159+ "toml",
160+ "url",
161+]
162+
163+[[package]]
164+name = "displaydoc"
165+version = "0.2.6"
166+source = "registry+https://github.com/rust-lang/crates.io-index"
167+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
168+dependencies = [
169+ "proc-macro2",
170+ "quote",
171+ "syn 2.0.119",
172+]
173+
174+[[package]]
175+name = "equivalent"
176+version = "1.0.2"
177+source = "registry+https://github.com/rust-lang/crates.io-index"
178+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
179+
180+[[package]]
181+name = "errno"
182+version = "0.3.14"
183+source = "registry+https://github.com/rust-lang/crates.io-index"
184+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
185+dependencies = [
186+ "libc",
187+ "windows-sys",
188+]
20189
21190 [[package]]
22191 name = "fabrica"
@@ -26,29 +195,735 @@ dependencies = [
26195 ]
27196
28197 [[package]]
198+name = "fastrand"
199+version = "2.5.0"
200+source = "registry+https://github.com/rust-lang/crates.io-index"
201+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
202+
203+[[package]]
204+name = "figment"
205+version = "0.10.19"
206+source = "registry+https://github.com/rust-lang/crates.io-index"
207+checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3"
208+dependencies = [
209+ "atomic",
210+ "parking_lot",
211+ "pear",
212+ "serde",
213+ "tempfile",
214+ "toml",
215+ "uncased",
216+ "version_check",
217+]
218+
219+[[package]]
220+name = "form_urlencoded"
221+version = "1.2.2"
222+source = "registry+https://github.com/rust-lang/crates.io-index"
223+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
224+dependencies = [
225+ "percent-encoding",
226+]
227+
228+[[package]]
229+name = "getrandom"
230+version = "0.4.3"
231+source = "registry+https://github.com/rust-lang/crates.io-index"
232+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
233+dependencies = [
234+ "cfg-if",
235+ "libc",
236+ "r-efi",
237+]
238+
239+[[package]]
29240 name = "git"
30241 version = "0.1.0"
31242
32243 [[package]]
244+name = "hashbrown"
245+version = "0.17.1"
246+source = "registry+https://github.com/rust-lang/crates.io-index"
247+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
248+
249+[[package]]
250+name = "heck"
251+version = "0.5.0"
252+source = "registry+https://github.com/rust-lang/crates.io-index"
253+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
254+
255+[[package]]
33256 name = "highlight"
34257 version = "0.1.0"
35258
36259 [[package]]
260+name = "icu_collections"
261+version = "2.1.1"
262+source = "registry+https://github.com/rust-lang/crates.io-index"
263+checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
264+dependencies = [
265+ "displaydoc",
266+ "potential_utf",
267+ "yoke",
268+ "zerofrom",
269+ "zerovec",
270+]
271+
272+[[package]]
273+name = "icu_locale_core"
274+version = "2.1.1"
275+source = "registry+https://github.com/rust-lang/crates.io-index"
276+checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
277+dependencies = [
278+ "displaydoc",
279+ "litemap",
280+ "tinystr",
281+ "writeable",
282+ "zerovec",
283+]
284+
285+[[package]]
286+name = "icu_normalizer"
287+version = "2.1.1"
288+source = "registry+https://github.com/rust-lang/crates.io-index"
289+checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
290+dependencies = [
291+ "icu_collections",
292+ "icu_normalizer_data",
293+ "icu_properties",
294+ "icu_provider",
295+ "smallvec",
296+ "zerovec",
297+]
298+
299+[[package]]
300+name = "icu_normalizer_data"
301+version = "2.1.1"
302+source = "registry+https://github.com/rust-lang/crates.io-index"
303+checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
304+
305+[[package]]
306+name = "icu_properties"
307+version = "2.1.2"
308+source = "registry+https://github.com/rust-lang/crates.io-index"
309+checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
310+dependencies = [
311+ "icu_collections",
312+ "icu_locale_core",
313+ "icu_properties_data",
314+ "icu_provider",
315+ "zerotrie",
316+ "zerovec",
317+]
318+
319+[[package]]
320+name = "icu_properties_data"
321+version = "2.1.2"
322+source = "registry+https://github.com/rust-lang/crates.io-index"
323+checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
324+
325+[[package]]
326+name = "icu_provider"
327+version = "2.1.1"
328+source = "registry+https://github.com/rust-lang/crates.io-index"
329+checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
330+dependencies = [
331+ "displaydoc",
332+ "icu_locale_core",
333+ "writeable",
334+ "yoke",
335+ "zerofrom",
336+ "zerotrie",
337+ "zerovec",
338+]
339+
340+[[package]]
341+name = "idna"
342+version = "1.1.0"
343+source = "registry+https://github.com/rust-lang/crates.io-index"
344+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
345+dependencies = [
346+ "idna_adapter",
347+ "smallvec",
348+ "utf8_iter",
349+]
350+
351+[[package]]
352+name = "idna_adapter"
353+version = "1.2.1"
354+source = "registry+https://github.com/rust-lang/crates.io-index"
355+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
356+dependencies = [
357+ "icu_normalizer",
358+ "icu_properties",
359+]
360+
361+[[package]]
362+name = "indexmap"
363+version = "2.14.0"
364+source = "registry+https://github.com/rust-lang/crates.io-index"
365+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
366+dependencies = [
367+ "equivalent",
368+ "hashbrown",
369+]
370+
371+[[package]]
372+name = "inlinable_string"
373+version = "0.1.15"
374+source = "registry+https://github.com/rust-lang/crates.io-index"
375+checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
376+
377+[[package]]
378+name = "is_terminal_polyfill"
379+version = "1.70.2"
380+source = "registry+https://github.com/rust-lang/crates.io-index"
381+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
382+
383+[[package]]
384+name = "itoa"
385+version = "1.0.18"
386+source = "registry+https://github.com/rust-lang/crates.io-index"
387+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
388+
389+[[package]]
390+name = "libc"
391+version = "0.2.189"
392+source = "registry+https://github.com/rust-lang/crates.io-index"
393+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
394+
395+[[package]]
396+name = "linux-raw-sys"
397+version = "0.12.1"
398+source = "registry+https://github.com/rust-lang/crates.io-index"
399+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
400+
401+[[package]]
402+name = "litemap"
403+version = "0.8.2"
404+source = "registry+https://github.com/rust-lang/crates.io-index"
405+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
406+
407+[[package]]
408+name = "lock_api"
409+version = "0.4.14"
410+source = "registry+https://github.com/rust-lang/crates.io-index"
411+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
412+dependencies = [
413+ "scopeguard",
414+]
415+
416+[[package]]
37417 name = "mail"
38418 version = "0.1.0"
39419
40420 [[package]]
421+name = "memchr"
422+version = "2.8.3"
423+source = "registry+https://github.com/rust-lang/crates.io-index"
424+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
425+
426+[[package]]
41427 name = "model"
42428 version = "0.1.0"
43429
44430 [[package]]
431+name = "once_cell"
432+version = "1.21.4"
433+source = "registry+https://github.com/rust-lang/crates.io-index"
434+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
435+
436+[[package]]
437+name = "once_cell_polyfill"
438+version = "1.70.2"
439+source = "registry+https://github.com/rust-lang/crates.io-index"
440+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
441+
442+[[package]]
443+name = "parking_lot"
444+version = "0.12.5"
445+source = "registry+https://github.com/rust-lang/crates.io-index"
446+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
447+dependencies = [
448+ "lock_api",
449+ "parking_lot_core",
450+]
451+
452+[[package]]
453+name = "parking_lot_core"
454+version = "0.9.12"
455+source = "registry+https://github.com/rust-lang/crates.io-index"
456+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
457+dependencies = [
458+ "cfg-if",
459+ "libc",
460+ "redox_syscall",
461+ "smallvec",
462+ "windows-link",
463+]
464+
465+[[package]]
466+name = "pear"
467+version = "0.2.9"
468+source = "registry+https://github.com/rust-lang/crates.io-index"
469+checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467"
470+dependencies = [
471+ "inlinable_string",
472+ "pear_codegen",
473+ "yansi",
474+]
475+
476+[[package]]
477+name = "pear_codegen"
478+version = "0.2.9"
479+source = "registry+https://github.com/rust-lang/crates.io-index"
480+checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147"
481+dependencies = [
482+ "proc-macro2",
483+ "proc-macro2-diagnostics",
484+ "quote",
485+ "syn 2.0.119",
486+]
487+
488+[[package]]
489+name = "percent-encoding"
490+version = "2.3.2"
491+source = "registry+https://github.com/rust-lang/crates.io-index"
492+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
493+
494+[[package]]
495+name = "potential_utf"
496+version = "0.1.5"
497+source = "registry+https://github.com/rust-lang/crates.io-index"
498+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
499+dependencies = [
500+ "zerovec",
501+]
502+
503+[[package]]
504+name = "proc-macro2"
505+version = "1.0.107"
506+source = "registry+https://github.com/rust-lang/crates.io-index"
507+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
508+dependencies = [
509+ "unicode-ident",
510+]
511+
512+[[package]]
513+name = "proc-macro2-diagnostics"
514+version = "0.10.1"
515+source = "registry+https://github.com/rust-lang/crates.io-index"
516+checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
517+dependencies = [
518+ "proc-macro2",
519+ "quote",
520+ "syn 2.0.119",
521+ "version_check",
522+ "yansi",
523+]
524+
525+[[package]]
526+name = "quote"
527+version = "1.0.47"
528+source = "registry+https://github.com/rust-lang/crates.io-index"
529+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
530+dependencies = [
531+ "proc-macro2",
532+]
533+
534+[[package]]
535+name = "r-efi"
536+version = "6.0.0"
537+source = "registry+https://github.com/rust-lang/crates.io-index"
538+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
539+
540+[[package]]
541+name = "redox_syscall"
542+version = "0.5.18"
543+source = "registry+https://github.com/rust-lang/crates.io-index"
544+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
545+dependencies = [
546+ "bitflags",
547+]
548+
549+[[package]]
550+name = "rustix"
551+version = "1.1.4"
552+source = "registry+https://github.com/rust-lang/crates.io-index"
553+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
554+dependencies = [
555+ "bitflags",
556+ "errno",
557+ "libc",
558+ "linux-raw-sys",
559+ "windows-sys",
560+]
561+
562+[[package]]
563+name = "scopeguard"
564+version = "1.2.0"
565+source = "registry+https://github.com/rust-lang/crates.io-index"
566+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
567+
568+[[package]]
569+name = "serde"
570+version = "1.0.229"
571+source = "registry+https://github.com/rust-lang/crates.io-index"
572+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
573+dependencies = [
574+ "serde_core",
575+ "serde_derive",
576+]
577+
578+[[package]]
579+name = "serde_core"
580+version = "1.0.229"
581+source = "registry+https://github.com/rust-lang/crates.io-index"
582+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
583+dependencies = [
584+ "serde_derive",
585+]
586+
587+[[package]]
588+name = "serde_derive"
589+version = "1.0.229"
590+source = "registry+https://github.com/rust-lang/crates.io-index"
591+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
592+dependencies = [
593+ "proc-macro2",
594+ "quote",
595+ "syn 3.0.3",
596+]
597+
598+[[package]]
599+name = "serde_json"
600+version = "1.0.151"
601+source = "registry+https://github.com/rust-lang/crates.io-index"
602+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
603+dependencies = [
604+ "itoa",
605+ "memchr",
606+ "serde",
607+ "serde_core",
608+ "zmij",
609+]
610+
611+[[package]]
612+name = "serde_spanned"
613+version = "0.6.9"
614+source = "registry+https://github.com/rust-lang/crates.io-index"
615+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
616+dependencies = [
617+ "serde",
618+]
619+
620+[[package]]
621+name = "smallvec"
622+version = "1.15.2"
623+source = "registry+https://github.com/rust-lang/crates.io-index"
624+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
625+
626+[[package]]
45627 name = "ssh"
46628 version = "0.1.0"
47629
48630 [[package]]
631+name = "stable_deref_trait"
632+version = "1.2.1"
633+source = "registry+https://github.com/rust-lang/crates.io-index"
634+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
635+
636+[[package]]
49637 name = "store"
50638 version = "0.1.0"
51639
52640 [[package]]
641+name = "strsim"
642+version = "0.11.1"
643+source = "registry+https://github.com/rust-lang/crates.io-index"
644+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
645+
646+[[package]]
647+name = "syn"
648+version = "2.0.119"
649+source = "registry+https://github.com/rust-lang/crates.io-index"
650+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
651+dependencies = [
652+ "proc-macro2",
653+ "quote",
654+ "unicode-ident",
655+]
656+
657+[[package]]
658+name = "syn"
659+version = "3.0.3"
660+source = "registry+https://github.com/rust-lang/crates.io-index"
661+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
662+dependencies = [
663+ "proc-macro2",
664+ "quote",
665+ "unicode-ident",
666+]
667+
668+[[package]]
669+name = "synstructure"
670+version = "0.13.2"
671+source = "registry+https://github.com/rust-lang/crates.io-index"
672+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
673+dependencies = [
674+ "proc-macro2",
675+ "quote",
676+ "syn 2.0.119",
677+]
678+
679+[[package]]
680+name = "tempfile"
681+version = "3.27.0"
682+source = "registry+https://github.com/rust-lang/crates.io-index"
683+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
684+dependencies = [
685+ "fastrand",
686+ "getrandom",
687+ "once_cell",
688+ "rustix",
689+ "windows-sys",
690+]
691+
692+[[package]]
693+name = "thiserror"
694+version = "2.0.19"
695+source = "registry+https://github.com/rust-lang/crates.io-index"
696+checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
697+dependencies = [
698+ "thiserror-impl",
699+]
700+
701+[[package]]
702+name = "thiserror-impl"
703+version = "2.0.19"
704+source = "registry+https://github.com/rust-lang/crates.io-index"
705+checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
706+dependencies = [
707+ "proc-macro2",
708+ "quote",
709+ "syn 3.0.3",
710+]
711+
712+[[package]]
713+name = "tinystr"
714+version = "0.8.3"
715+source = "registry+https://github.com/rust-lang/crates.io-index"
716+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
717+dependencies = [
718+ "displaydoc",
719+ "zerovec",
720+]
721+
722+[[package]]
723+name = "toml"
724+version = "0.8.23"
725+source = "registry+https://github.com/rust-lang/crates.io-index"
726+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
727+dependencies = [
728+ "serde",
729+ "serde_spanned",
730+ "toml_datetime",
731+ "toml_edit",
732+]
733+
734+[[package]]
735+name = "toml_datetime"
736+version = "0.6.11"
737+source = "registry+https://github.com/rust-lang/crates.io-index"
738+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
739+dependencies = [
740+ "serde",
741+]
742+
743+[[package]]
744+name = "toml_edit"
745+version = "0.22.27"
746+source = "registry+https://github.com/rust-lang/crates.io-index"
747+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
748+dependencies = [
749+ "indexmap",
750+ "serde",
751+ "serde_spanned",
752+ "toml_datetime",
753+ "toml_write",
754+ "winnow",
755+]
756+
757+[[package]]
758+name = "toml_write"
759+version = "0.1.2"
760+source = "registry+https://github.com/rust-lang/crates.io-index"
761+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
762+
763+[[package]]
764+name = "uncased"
765+version = "0.9.10"
766+source = "registry+https://github.com/rust-lang/crates.io-index"
767+checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697"
768+dependencies = [
769+ "version_check",
770+]
771+
772+[[package]]
773+name = "unicode-ident"
774+version = "1.0.24"
775+source = "registry+https://github.com/rust-lang/crates.io-index"
776+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
777+
778+[[package]]
779+name = "url"
780+version = "2.5.8"
781+source = "registry+https://github.com/rust-lang/crates.io-index"
782+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
783+dependencies = [
784+ "form_urlencoded",
785+ "idna",
786+ "percent-encoding",
787+ "serde",
788+]
789+
790+[[package]]
791+name = "utf8_iter"
792+version = "1.0.4"
793+source = "registry+https://github.com/rust-lang/crates.io-index"
794+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
795+
796+[[package]]
797+name = "utf8parse"
798+version = "0.2.2"
799+source = "registry+https://github.com/rust-lang/crates.io-index"
800+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
801+
802+[[package]]
803+name = "version_check"
804+version = "0.9.5"
805+source = "registry+https://github.com/rust-lang/crates.io-index"
806+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
807+
808+[[package]]
53809 name = "web"
54810 version = "0.1.0"
811+
812+[[package]]
813+name = "windows-link"
814+version = "0.2.1"
815+source = "registry+https://github.com/rust-lang/crates.io-index"
816+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
817+
818+[[package]]
819+name = "windows-sys"
820+version = "0.61.2"
821+source = "registry+https://github.com/rust-lang/crates.io-index"
822+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
823+dependencies = [
824+ "windows-link",
825+]
826+
827+[[package]]
828+name = "winnow"
829+version = "0.7.15"
830+source = "registry+https://github.com/rust-lang/crates.io-index"
831+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
832+dependencies = [
833+ "memchr",
834+]
835+
836+[[package]]
837+name = "writeable"
838+version = "0.6.3"
839+source = "registry+https://github.com/rust-lang/crates.io-index"
840+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
841+
842+[[package]]
843+name = "yansi"
844+version = "1.0.1"
845+source = "registry+https://github.com/rust-lang/crates.io-index"
846+checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
847+
848+[[package]]
849+name = "yoke"
850+version = "0.8.3"
851+source = "registry+https://github.com/rust-lang/crates.io-index"
852+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
853+dependencies = [
854+ "stable_deref_trait",
855+ "yoke-derive",
856+ "zerofrom",
857+]
858+
859+[[package]]
860+name = "yoke-derive"
861+version = "0.8.2"
862+source = "registry+https://github.com/rust-lang/crates.io-index"
863+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
864+dependencies = [
865+ "proc-macro2",
866+ "quote",
867+ "syn 2.0.119",
868+ "synstructure",
869+]
870+
871+[[package]]
872+name = "zerofrom"
873+version = "0.1.8"
874+source = "registry+https://github.com/rust-lang/crates.io-index"
875+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
876+dependencies = [
877+ "zerofrom-derive",
878+]
879+
880+[[package]]
881+name = "zerofrom-derive"
882+version = "0.1.7"
883+source = "registry+https://github.com/rust-lang/crates.io-index"
884+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
885+dependencies = [
886+ "proc-macro2",
887+ "quote",
888+ "syn 2.0.119",
889+ "synstructure",
890+]
891+
892+[[package]]
893+name = "zerotrie"
894+version = "0.2.4"
895+source = "registry+https://github.com/rust-lang/crates.io-index"
896+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
897+dependencies = [
898+ "displaydoc",
899+ "yoke",
900+ "zerofrom",
901+]
902+
903+[[package]]
904+name = "zerovec"
905+version = "0.11.6"
906+source = "registry+https://github.com/rust-lang/crates.io-index"
907+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
908+dependencies = [
909+ "yoke",
910+ "zerofrom",
911+ "zerovec-derive",
912+]
913+
914+[[package]]
915+name = "zerovec-derive"
916+version = "0.11.3"
917+source = "registry+https://github.com/rust-lang/crates.io-index"
918+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
919+dependencies = [
920+ "proc-macro2",
921+ "quote",
922+ "syn 2.0.119",
923+]
924+
925+[[package]]
926+name = "zmij"
927+version = "1.0.23"
928+source = "registry+https://github.com/rust-lang/crates.io-index"
929+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Cargo.toml +11 −0
@@ -48,6 +48,17 @@ web = { path = "crates/web" }
4848 api = { path = "crates/api" }
4949 cli = { path = "crates/cli" }
5050
51+# External dependencies. Versions are centralised here; crates opt in with
52+# `<dep> = { workspace = true }` so the whole workspace stays on one version.
53+figment = { version = "0.10", features = ["toml", "env"] }
54+serde = { version = "1", features = ["derive"] }
55+serde_json = "1"
56+toml = "0.8"
57+thiserror = "2"
58+url = "2"
59+clap = { version = "4", features = ["derive"] }
60+anyhow = "1"
61+
5162 [workspace.lints.rust]
5263 unsafe_code = "forbid"
5364 missing_docs = "warn"
crates/cli/Cargo.toml +4 −0
@@ -10,6 +10,10 @@ repository.workspace = true
1010 publish.workspace = true
1111
1212 [dependencies]
13+config = { workspace = true }
14+anyhow = { workspace = true }
15+clap = { workspace = true }
16+serde_json = { workspace = true }
1317
1418 [lints]
1519 workspace = true
crates/cli/src/config_cmd.rs +62 −0
@@ -0,0 +1,62 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! `fabrica config check` and `fabrica config show`.
6+
7+use std::path::Path;
8+use std::process::ExitCode;
9+
10+use clap::Subcommand;
11+
12+/// Actions under `fabrica config`.
13+#[derive(Debug, Subcommand)]
14+pub(crate) enum ConfigCommand {
15+ /// Validate the effective configuration and exit non-zero on any error.
16+ Check,
17+ /// Print the effective, merged configuration with secrets redacted.
18+ Show,
19+}
20+
21+/// Dispatch a `config` subcommand.
22+///
23+/// Warnings are always written to stderr; on `check` they do not affect the exit
24+/// status. A validation failure is reported to stderr and yields
25+/// [`ExitCode::FAILURE`] without returning an `Err`, so no backtrace-style
26+/// wrapper is printed for the expected "your config is wrong" case.
27+pub(crate) fn run(
28+ command: &ConfigCommand,
29+ config_path: Option<&Path>,
30+ json: bool,
31+) -> anyhow::Result<ExitCode> {
32+ match command {
33+ ConfigCommand::Check => match config::load(config_path) {
34+ Ok(loaded) => {
35+ warn(&loaded.warnings);
36+ println!("configuration ok");
37+ Ok(ExitCode::SUCCESS)
38+ }
39+ Err(err) => {
40+ eprintln!("error: {err}");
41+ Ok(ExitCode::FAILURE)
42+ }
43+ },
44+ ConfigCommand::Show => {
45+ let loaded = config::load(config_path)?;
46+ warn(&loaded.warnings);
47+ if json {
48+ println!("{}", serde_json::to_string_pretty(&loaded.config)?);
49+ } else {
50+ print!("{}", loaded.config.to_toml_string()?);
51+ }
52+ Ok(ExitCode::SUCCESS)
53+ }
54+ }
55+}
56+
57+/// Write each warning to stderr, prefixed, leaving stdout for real output.
58+fn warn(warnings: &[String]) {
59+ for warning in warnings {
60+ eprintln!("warning: {warning}");
61+ }
62+}
crates/cli/src/lib.rs +57 −4
@@ -6,15 +6,68 @@
66 //!
77 //! Every subcommand operates directly on the store and filesystem; there is no
88 //! IPC with a running `serve`. [`run`] is the single entry point invoked by the
9-//! `fabrica` binary.
9+//! `fabrica` binary. The command tree is built out over the implementation
10+//! phases; today it covers only `config check` and `config show`.
1011
12+mod config_cmd;
13+
14+use std::path::PathBuf;
1115 use std::process::ExitCode;
1216
17+use clap::{Parser, Subcommand};
18+
19+/// Top-level argument parser for the `fabrica` binary.
20+#[derive(Debug, Parser)]
21+#[command(name = "fabrica", version, about = "A self-hosted git server.")]
22+struct Cli {
23+ /// Path to a configuration file, merged last after the standard locations.
24+ #[arg(long, global = true, value_name = "PATH")]
25+ config: Option<PathBuf>,
26+
27+ /// Override the configured log level (`trace|debug|info|warn|error`).
28+ #[arg(long, global = true, value_name = "LEVEL")]
29+ log_level: Option<String>,
30+
31+ /// Emit machine-readable JSON instead of human-formatted output.
32+ #[arg(long, global = true)]
33+ json: bool,
34+
35+ /// The subcommand to run.
36+ #[command(subcommand)]
37+ command: Command,
38+}
39+
40+/// The fabrica subcommand tree.
41+#[derive(Debug, Subcommand)]
42+enum Command {
43+ /// Inspect and validate configuration.
44+ Config {
45+ /// The configuration action to perform.
46+ #[command(subcommand)]
47+ command: config_cmd::ConfigCommand,
48+ },
49+}
50+
1351 /// Parse arguments and dispatch to the requested subcommand.
1452 ///
15-/// Returns the process exit code. The command tree is built out over the
16-/// implementation phases; today this is a placeholder that exits successfully.
53+/// Returns the process exit code. Argument-parse failures are handled by clap
54+/// (which prints usage and exits); any other error is printed to stderr and
55+/// mapped to [`ExitCode::FAILURE`].
1756 #[must_use]
1857 pub fn run() -> ExitCode {
19- ExitCode::SUCCESS
58+ let cli = Cli::parse();
59+ match dispatch(&cli) {
60+ Ok(code) => code,
61+ Err(err) => {
62+ eprintln!("error: {err:#}");
63+ ExitCode::FAILURE
64+ }
65+ }
66+}
67+
68+/// Route a parsed [`Cli`] to its handler.
69+fn dispatch(cli: &Cli) -> anyhow::Result<ExitCode> {
70+ match &cli.command {
71+ Command::Config { command } => config_cmd::run(command, cli.config.as_deref(), cli.json),
72+ }
2073 }
crates/config/Cargo.toml +8 −0
@@ -10,6 +10,14 @@ repository.workspace = true
1010 publish.workspace = true
1111
1212 [dependencies]
13+figment = { workspace = true }
14+serde = { workspace = true }
15+thiserror = { workspace = true }
16+toml = { workspace = true }
17+url = { workspace = true }
18+
19+[dev-dependencies]
20+figment = { workspace = true, features = ["test"] }
1321
1422 [lints]
1523 workspace = true
crates/config/src/lib.rs +420 −0
@@ -3,3 +3,423 @@
33 // file, You can obtain one at https://mozilla.org/MPL/2.0/.
44
55 //! Configuration loading, validation, and path resolution.
6+//!
7+//! Configuration is a TOML file plus environment overrides, merged with
8+//! [`figment`]. The resolution order (later wins) is:
9+//!
10+//! 1. built-in defaults ([`Config::default`]);
11+//! 2. `/etc/fabrica/fabrica.toml`;
12+//! 3. `$XDG_CONFIG_HOME/fabrica/fabrica.toml`;
13+//! 4. an explicit `--config <path>`;
14+//! 5. `FABRICA__*` environment variables (nested keys use `__`, e.g.
15+//! `FABRICA__SERVER__PORT=8080`).
16+//!
17+//! Missing files at the fixed locations are skipped; a missing `--config` path
18+//! is likewise tolerated so the same code path serves first-run.
19+//!
20+//! Any `*_file` key (`auth.secret_file`, `mail.password_file`) is read from disk
21+//! after merging and supersedes its inline sibling. Secrets are wrapped in
22+//! [`Secret`], which redacts itself in every `Debug`/`Serialize` context, so
23+//! neither logging nor `fabrica config show` can leak them.
24+
25+mod secret;
26+mod types;
27+
28+use std::path::{Path, PathBuf};
29+
30+use figment::Figment;
31+use figment::providers::{Env, Format, Serialized, Toml};
32+
33+pub use crate::secret::{REDACTED, Secret};
34+pub use crate::types::{
35+ Auth, Config, Database, Git, Instance, Log, LogFormat, LogLevel, Mail, MailBackend,
36+ MailEncryption, Server, Ssh, Storage, Ui,
37+};
38+
39+/// System-wide configuration path, the lowest-priority file source.
40+const ETC_PATH: &str = "/etc/fabrica/fabrica.toml";
41+
42+/// Prefix and nesting separator for environment overrides.
43+const ENV_PREFIX: &str = "FABRICA__";
44+
45+/// Errors produced while loading or validating configuration.
46+#[derive(Debug, thiserror::Error)]
47+pub enum ConfigError {
48+ /// A file could not be parsed, a value had the wrong type, or an unknown key
49+ /// was present. Boxed because `figment::Error` is large relative to the
50+ /// other variants.
51+ #[error("failed to load configuration: {0}")]
52+ Load(Box<figment::Error>),
53+
54+ /// A `*_file` secret could not be read. The path is included; the file's
55+ /// contents never are.
56+ #[error("failed to read secret file {path}: {source}")]
57+ SecretFile {
58+ /// The path that could not be read.
59+ path: PathBuf,
60+ /// The underlying I/O error.
61+ source: std::io::Error,
62+ },
63+
64+ /// Validation failed. Each line is a distinct, actionable problem.
65+ #[error("invalid configuration:\n{0}")]
66+ Invalid(String),
67+
68+ /// The effective configuration could not be serialized for display.
69+ #[error("failed to render configuration: {0}")]
70+ Render(#[from] toml::ser::Error),
71+}
72+
73+/// A successfully loaded configuration together with any non-fatal warnings.
74+#[derive(Debug, Clone)]
75+pub struct Loaded {
76+ /// The validated, effective configuration.
77+ pub config: Config,
78+ /// Non-fatal advisories (e.g. an insecure cookie setting). The caller is
79+ /// expected to surface these to the operator.
80+ pub warnings: Vec<String>,
81+}
82+
83+/// Load configuration from all standard sources, applying the `--config`
84+/// override when supplied.
85+///
86+/// # Errors
87+///
88+/// Returns [`ConfigError`] if a source fails to parse, an unknown key is
89+/// present, a `*_file` secret cannot be read, or validation fails.
90+pub fn load(explicit: Option<&Path>) -> Result<Loaded, ConfigError> {
91+ load_from(&base_figment(explicit))
92+}
93+
94+/// Load configuration from an already-assembled [`Figment`].
95+///
96+/// This is the seam used by [`load`] and by tests that need to bypass the fixed
97+/// filesystem locations. The provided figment is expected to include the
98+/// [`Config::default`] layer as its base.
99+///
100+/// # Errors
101+///
102+/// Returns [`ConfigError`] under the same conditions as [`load`].
103+pub fn load_from(figment: &Figment) -> Result<Loaded, ConfigError> {
104+ let mut config: Config = figment
105+ .extract()
106+ .map_err(|e| ConfigError::Load(Box::new(e)))?;
107+ resolve_secret_files(&mut config)?;
108+ let warnings = validate(&config)?;
109+ Ok(Loaded { config, warnings })
110+}
111+
112+/// Assemble the layered figment for the standard load path.
113+fn base_figment(explicit: Option<&Path>) -> Figment {
114+ let mut figment =
115+ Figment::from(Serialized::defaults(Config::default())).merge(Toml::file(ETC_PATH));
116+ if let Some(path) = xdg_config_path() {
117+ figment = figment.merge(Toml::file(path));
118+ }
119+ if let Some(path) = explicit {
120+ figment = figment.merge(Toml::file(path));
121+ }
122+ figment.merge(Env::prefixed(ENV_PREFIX).split("__"))
123+}
124+
125+/// Resolve `$XDG_CONFIG_HOME/fabrica/fabrica.toml`, falling back to
126+/// `$HOME/.config`.
127+fn xdg_config_path() -> Option<PathBuf> {
128+ let base = match std::env::var_os("XDG_CONFIG_HOME") {
129+ Some(dir) if !dir.is_empty() => PathBuf::from(dir),
130+ _ => {
131+ let home = std::env::var_os("HOME").filter(|h| !h.is_empty())?;
132+ PathBuf::from(home).join(".config")
133+ }
134+ };
135+ Some(base.join("fabrica").join("fabrica.toml"))
136+}
137+
138+/// Read any `*_file` secrets and fold them onto their inline siblings.
139+fn resolve_secret_files(config: &mut Config) -> Result<(), ConfigError> {
140+ if let Some(path) = &config.auth.secret_file {
141+ config.auth.secret = Some(read_secret(path)?);
142+ }
143+ if let Some(path) = &config.mail.password_file {
144+ config.mail.password = Some(read_secret(path)?);
145+ }
146+ Ok(())
147+}
148+
149+/// Read a secret from a file, trimming a single trailing newline.
150+fn read_secret(path: &Path) -> Result<Secret, ConfigError> {
151+ let raw = std::fs::read_to_string(path).map_err(|source| ConfigError::SecretFile {
152+ path: path.to_path_buf(),
153+ source,
154+ })?;
155+ Ok(Secret::new(raw.trim_end_matches(['\r', '\n']).to_string()))
156+}
157+
158+/// Validate the effective configuration, failing fast with actionable messages.
159+///
160+/// Returns the list of non-fatal warnings on success; returns
161+/// [`ConfigError::Invalid`] with every hard error joined by newlines otherwise.
162+fn validate(config: &Config) -> Result<Vec<String>, ConfigError> {
163+ let mut errors: Vec<String> = Vec::new();
164+ let mut warnings: Vec<String> = Vec::new();
165+
166+ // instance.url must parse and must not carry a trailing slash.
167+ if let Err(e) = url::Url::parse(&config.instance.url) {
168+ errors.push(format!(
169+ "instance.url ({}) is not a valid URL: {e}",
170+ config.instance.url
171+ ));
172+ } else if config.instance.url.ends_with('/') {
173+ errors.push(format!(
174+ "instance.url ({}) must not have a trailing slash",
175+ config.instance.url
176+ ));
177+ }
178+
179+ if config.instance.default_branch.trim().is_empty() {
180+ errors.push("instance.default_branch must not be empty".to_string());
181+ }
182+
183+ // Storage directories must exist or be creatable.
184+ check_dir_creatable("storage.data_dir", &config.storage.data_dir, &mut errors);
185+ check_dir_creatable("storage.repo_dir", &config.storage.repo_dir, &mut errors);
186+
187+ // SMTP delivery needs a host and a from address.
188+ if config.mail.backend == MailBackend::Smtp {
189+ if config.mail.host.trim().is_empty() {
190+ errors.push("mail.backend is \"smtp\" but mail.host is empty".to_string());
191+ }
192+ if config.mail.from.trim().is_empty() {
193+ errors.push("mail.backend is \"smtp\" but mail.from is empty".to_string());
194+ }
195+ }
196+
197+ // A secure cookie over plain HTTP will be dropped by browsers.
198+ if config.auth.cookie_secure && !config.instance.url.starts_with("https://") {
199+ warnings.push(
200+ "auth.cookie_secure is true but instance.url is not https; browsers will drop the \
201+ session cookie"
202+ .to_string(),
203+ );
204+ }
205+
206+ if errors.is_empty() {
207+ Ok(warnings)
208+ } else {
209+ Err(ConfigError::Invalid(
210+ errors
211+ .iter()
212+ .map(|e| format!(" - {e}"))
213+ .collect::<Vec<_>>()
214+ .join("\n"),
215+ ))
216+ }
217+}
218+
219+/// Push an error unless `path` is an existing directory or has an existing
220+/// directory ancestor (i.e. it could be created). Performs no filesystem
221+/// mutation, so it is safe to call from `fabrica config check`.
222+fn check_dir_creatable(label: &str, path: &Path, errors: &mut Vec<String>) {
223+ match path.metadata() {
224+ Ok(meta) if meta.is_dir() => {}
225+ Ok(_) => errors.push(format!(
226+ "{label} ({}) exists but is not a directory",
227+ path.display()
228+ )),
229+ Err(_) => {
230+ let mut ancestor = path.parent();
231+ while let Some(dir) = ancestor {
232+ if dir.as_os_str().is_empty() {
233+ break;
234+ }
235+ match dir.metadata() {
236+ Ok(meta) if meta.is_dir() => return,
237+ Ok(_) => {
238+ errors.push(format!(
239+ "{label} ({}) is not creatable: {} is not a directory",
240+ path.display(),
241+ dir.display()
242+ ));
243+ return;
244+ }
245+ Err(_) => ancestor = dir.parent(),
246+ }
247+ }
248+ errors.push(format!(
249+ "{label} ({}) does not exist and has no existing parent directory to create it in",
250+ path.display()
251+ ));
252+ }
253+ }
254+}
255+
256+impl Config {
257+ /// Render the effective configuration as TOML, with secrets redacted.
258+ ///
259+ /// # Errors
260+ ///
261+ /// Returns [`ConfigError::Render`] if serialization fails.
262+ pub fn to_toml_string(&self) -> Result<String, ConfigError> {
263+ Ok(toml::to_string_pretty(self)?)
264+ }
265+}
266+
267+#[cfg(test)]
268+mod tests {
269+ #![allow(
270+ clippy::unwrap_used,
271+ clippy::expect_used,
272+ clippy::panic,
273+ clippy::result_large_err
274+ )]
275+
276+ use figment::Jail;
277+ use figment::providers::{Format, Serialized, Toml};
278+
279+ use super::*;
280+
281+ /// Build a figment from defaults plus an inline TOML fragment — no
282+ /// filesystem or environment sources, so tests stay hermetic.
283+ fn from_toml(fragment: &str) -> Result<Loaded, ConfigError> {
284+ load_from(
285+ &Figment::from(Serialized::defaults(Config::default())).merge(Toml::string(fragment)),
286+ )
287+ }
288+
289+ #[test]
290+ fn defaults_load_and_match() {
291+ // A bare config validates; the only advisory is the cookie-over-http
292+ // warning, since the default url is http and cookie_secure is true.
293+ let loaded = from_toml("").unwrap();
294+ assert_eq!(loaded.config, Config::default());
295+ }
296+
297+ #[test]
298+ fn later_sources_win() {
299+ let loaded = from_toml("[server]\nport = 9000\n").unwrap();
300+ assert_eq!(loaded.config.server.port, 9000);
301+ // Untouched keys keep their defaults.
302+ assert_eq!(loaded.config.server.address, "0.0.0.0");
303+ }
304+
305+ #[test]
306+ fn unknown_key_is_rejected() {
307+ let err = from_toml("[server]\nprot = 9000\n").unwrap_err();
308+ assert!(matches!(err, ConfigError::Load(_)), "got {err:?}");
309+ assert!(format!("{err}").contains("prot"));
310+ }
311+
312+ #[test]
313+ fn unknown_section_is_rejected() {
314+ let err = from_toml("[nope]\nx = 1\n").unwrap_err();
315+ assert!(matches!(err, ConfigError::Load(_)), "got {err:?}");
316+ }
317+
318+ #[test]
319+ fn enum_values_are_validated() {
320+ let err = from_toml("[mail]\nbackend = \"carrier-pigeon\"\n").unwrap_err();
321+ assert!(matches!(err, ConfigError::Load(_)), "got {err:?}");
322+ }
323+
324+ #[test]
325+ fn trailing_slash_url_is_rejected() {
326+ let err = from_toml("[instance]\nurl = \"https://git.example.com/\"\n").unwrap_err();
327+ match err {
328+ ConfigError::Invalid(msg) => assert!(msg.contains("trailing slash"), "{msg}"),
329+ other => panic!("expected Invalid, got {other:?}"),
330+ }
331+ }
332+
333+ #[test]
334+ fn unparseable_url_is_rejected() {
335+ let err = from_toml("[instance]\nurl = \"not a url\"\n").unwrap_err();
336+ assert!(matches!(err, ConfigError::Invalid(_)), "got {err:?}");
337+ }
338+
339+ #[test]
340+ fn smtp_without_host_is_rejected() {
341+ let err = from_toml("[mail]\nbackend = \"smtp\"\n").unwrap_err();
342+ match err {
343+ ConfigError::Invalid(msg) => assert!(msg.contains("mail.host"), "{msg}"),
344+ other => panic!("expected Invalid, got {other:?}"),
345+ }
346+ }
347+
348+ #[test]
349+ fn insecure_cookie_over_http_warns_but_loads() {
350+ // Default url is http and cookie_secure defaults to true.
351+ let loaded = from_toml("").unwrap();
352+ assert!(
353+ loaded.warnings.iter().any(|w| w.contains("cookie_secure")),
354+ "expected a cookie warning, got {:?}",
355+ loaded.warnings
356+ );
357+ }
358+
359+ #[test]
360+ fn https_url_produces_no_cookie_warning() {
361+ let loaded = from_toml("[instance]\nurl = \"https://git.example.com\"\n").unwrap();
362+ assert!(!loaded.warnings.iter().any(|w| w.contains("cookie_secure")));
363+ }
364+
365+ #[test]
366+ fn secrets_are_redacted_in_rendered_toml() {
367+ let loaded = from_toml("[auth]\nsecret = \"super-secret-key\"\n").unwrap();
368+ assert_eq!(
369+ loaded.config.auth.secret.as_ref().unwrap().expose(),
370+ "super-secret-key"
371+ );
372+ let rendered = loaded.config.to_toml_string().unwrap();
373+ assert!(rendered.contains(REDACTED));
374+ assert!(!rendered.contains("super-secret-key"));
375+ }
376+
377+ #[test]
378+ fn secret_file_supersedes_inline_and_reads_from_disk() {
379+ Jail::expect_with(|jail| {
380+ let path = jail.directory().join("secret.key");
381+ std::fs::write(&path, "from-file\n").unwrap();
382+ let fragment = format!(
383+ "[auth]\nsecret = \"inline\"\nsecret_file = \"{}\"\n",
384+ path.display()
385+ );
386+ let loaded = from_toml(&fragment).map_err(|e| e.to_string())?;
387+ assert_eq!(loaded.config.auth.secret.unwrap().expose(), "from-file");
388+ Ok(())
389+ });
390+ }
391+
392+ #[test]
393+ fn missing_secret_file_errors() {
394+ let err = from_toml("[auth]\nsecret_file = \"/no/such/fabrica/secret\"\n").unwrap_err();
395+ match err {
396+ ConfigError::SecretFile { path, .. } => {
397+ assert!(path.ends_with("secret"));
398+ }
399+ other => panic!("expected SecretFile, got {other:?}"),
400+ }
401+ }
402+
403+ #[test]
404+ fn env_overrides_apply_with_double_underscore_nesting() {
405+ Jail::expect_with(|jail| {
406+ jail.set_env("FABRICA__SERVER__PORT", "7000");
407+ jail.set_env("FABRICA__INSTANCE__DEFAULT_BRANCH", "trunk");
408+ let loaded = load_from(&base_figment(None)).map_err(|e| e.to_string())?;
409+ assert_eq!(loaded.config.server.port, 7000);
410+ assert_eq!(loaded.config.instance.default_branch, "trunk");
411+ Ok(())
412+ });
413+ }
414+
415+ #[test]
416+ fn explicit_config_path_is_merged() {
417+ Jail::expect_with(|jail| {
418+ let path = jail.directory().join("fabrica.toml");
419+ std::fs::write(&path, "[instance]\nname = \"from-explicit\"\n").unwrap();
420+ let loaded = load_from(&base_figment(Some(&path))).map_err(|e| e.to_string())?;
421+ assert_eq!(loaded.config.instance.name, "from-explicit");
422+ Ok(())
423+ });
424+ }
425+}
crates/config/src/secret.rs +101 −0
@@ -0,0 +1,101 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! A string wrapper that never reveals itself when logged or serialized.
6+
7+use std::fmt;
8+
9+use serde::{Deserialize, Deserializer, Serialize, Serializer};
10+
11+/// The placeholder rendered in every non-authorised view of a secret.
12+pub const REDACTED: &str = "<redacted>";
13+
14+/// A secret string (signing key, SMTP password, …).
15+///
16+/// The inner value is only reachable through [`Secret::expose`]. Both the
17+/// [`fmt::Debug`] and [`Serialize`] implementations emit [`REDACTED`] instead of
18+/// the real value, so a `Secret` can never leak through `tracing` output, a
19+/// `Debug`-formatted [`crate::Config`], or `fabrica config show`.
20+#[derive(Clone, PartialEq, Eq)]
21+pub struct Secret(String);
22+
23+impl Secret {
24+ /// Wrap a value as a secret.
25+ #[must_use]
26+ pub fn new(value: String) -> Self {
27+ Self(value)
28+ }
29+
30+ /// Borrow the underlying secret value.
31+ ///
32+ /// This is the single deliberate escape hatch; call it only where the raw
33+ /// value is genuinely needed (e.g. signing a token or authenticating to an
34+ /// SMTP server), never for display or logging.
35+ #[must_use]
36+ pub fn expose(&self) -> &str {
37+ &self.0
38+ }
39+}
40+
41+impl fmt::Debug for Secret {
42+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43+ f.write_str(REDACTED)
44+ }
45+}
46+
47+impl Serialize for Secret {
48+ fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
49+ serializer.serialize_str(REDACTED)
50+ }
51+}
52+
53+impl<'de> Deserialize<'de> for Secret {
54+ fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
55+ String::deserialize(deserializer).map(Self)
56+ }
57+}
58+
59+impl From<String> for Secret {
60+ fn from(value: String) -> Self {
61+ Self(value)
62+ }
63+}
64+
65+#[cfg(test)]
66+mod tests {
67+ #![allow(clippy::unwrap_used, clippy::expect_used)]
68+
69+ use serde::{Deserialize, Serialize};
70+
71+ use super::*;
72+
73+ #[test]
74+ fn debug_is_redacted() {
75+ let s = Secret::new("hunter2".to_string());
76+ assert_eq!(format!("{s:?}"), REDACTED);
77+ assert!(!format!("{s:?}").contains("hunter2"));
78+ }
79+
80+ #[derive(Serialize, Deserialize)]
81+ struct Holder {
82+ s: Secret,
83+ }
84+
85+ #[test]
86+ fn serialize_is_redacted_but_expose_is_not() {
87+ let holder = Holder {
88+ s: Secret::new("hunter2".to_string()),
89+ };
90+ let rendered = toml::to_string(&holder).unwrap();
91+ assert!(rendered.contains(REDACTED));
92+ assert!(!rendered.contains("hunter2"));
93+ assert_eq!(holder.s.expose(), "hunter2");
94+ }
95+
96+ #[test]
97+ fn deserialize_reads_the_real_value() {
98+ let holder: Holder = toml::from_str("s = \"real\"").unwrap();
99+ assert_eq!(holder.s.expose(), "real");
100+ }
101+}
crates/config/src/types.rs +384 −0
@@ -0,0 +1,384 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! The strongly-typed configuration tree.
6+//!
7+//! Field names and section layout mirror `fabrica.example.toml` exactly. Every
8+//! struct is `deny_unknown_fields` so a typo in the TOML (or a stray
9+//! `FABRICA__*` variable) is a load error rather than a silently ignored key.
10+
11+use std::path::PathBuf;
12+
13+use serde::{Deserialize, Serialize};
14+
15+use crate::secret::Secret;
16+
17+/// The complete, merged configuration.
18+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
19+#[serde(deny_unknown_fields)]
20+pub struct Config {
21+ /// Instance identity and global policy.
22+ pub instance: Instance,
23+ /// HTTP server binding and limits.
24+ pub server: Server,
25+ /// SSH server binding and advertised clone endpoint.
26+ pub ssh: Ssh,
27+ /// On-disk storage locations.
28+ pub storage: Storage,
29+ /// Database connection.
30+ pub database: Database,
31+ /// Authentication parameters and secrets.
32+ pub auth: Auth,
33+ /// Outbound mail.
34+ pub mail: Mail,
35+ /// Web UI presentation.
36+ pub ui: Ui,
37+ /// Git binary and pack-transport limits.
38+ pub git: Git,
39+ /// Logging.
40+ pub log: Log,
41+}
42+
43+/// `[instance]` — identity and global policy.
44+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45+#[serde(deny_unknown_fields)]
46+pub struct Instance {
47+ /// Display name shown in the header and page titles.
48+ pub name: String,
49+ /// Canonical external URL, with no trailing slash.
50+ pub url: String,
51+ /// One-line description shown on the landing page.
52+ pub description: String,
53+ /// Default branch for newly created repositories.
54+ pub default_branch: String,
55+ /// Allow anonymous browse and clone of public repositories.
56+ pub allow_anonymous: bool,
57+}
58+
59+impl Default for Instance {
60+ fn default() -> Self {
61+ Self {
62+ name: "fabrica".to_string(),
63+ url: "http://localhost:8080".to_string(),
64+ description: String::new(),
65+ default_branch: "main".to_string(),
66+ allow_anonymous: true,
67+ }
68+ }
69+}
70+
71+/// `[server]` — HTTP binding and limits.
72+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73+#[serde(deny_unknown_fields)]
74+pub struct Server {
75+ /// Bind address for the HTTP listener.
76+ pub address: String,
77+ /// Bind port for the HTTP listener.
78+ pub port: u16,
79+ /// Trust `X-Forwarded-For` / `X-Forwarded-Proto` from a fronting proxy.
80+ pub behind_proxy: bool,
81+ /// Seconds to wait for in-flight requests to drain on shutdown.
82+ pub graceful_shutdown_secs: u64,
83+ /// Maximum request body size for non-pack routes, in bytes.
84+ pub max_body_bytes: u64,
85+}
86+
87+impl Default for Server {
88+ fn default() -> Self {
89+ Self {
90+ address: "0.0.0.0".to_string(),
91+ port: 8080,
92+ behind_proxy: false,
93+ graceful_shutdown_secs: 20,
94+ max_body_bytes: 1_048_576,
95+ }
96+ }
97+}
98+
99+/// `[ssh]` — SSH binding and advertised clone endpoint.
100+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101+#[serde(deny_unknown_fields)]
102+pub struct Ssh {
103+ /// Whether the SSH server is started at all.
104+ pub enabled: bool,
105+ /// Bind address for the SSH listener.
106+ pub address: String,
107+ /// Bind port for the SSH listener.
108+ pub port: u16,
109+ /// Path to the Ed25519 host key (generated on first run if absent).
110+ pub host_key_path: PathBuf,
111+ /// Hostname shown in clone URLs (may differ from the bind address).
112+ pub clone_host: String,
113+ /// Port shown in clone URLs (may differ from the bind port).
114+ pub clone_port: u16,
115+}
116+
117+impl Default for Ssh {
118+ fn default() -> Self {
119+ Self {
120+ enabled: true,
121+ address: "0.0.0.0".to_string(),
122+ port: 2222,
123+ host_key_path: PathBuf::from("/var/lib/fabrica/ssh/host_ed25519"),
124+ clone_host: "localhost".to_string(),
125+ clone_port: 22,
126+ }
127+ }
128+}
129+
130+/// `[storage]` — on-disk locations.
131+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132+#[serde(deny_unknown_fields)]
133+pub struct Storage {
134+ /// Base data directory (secrets, host key, SQLite database, tmp).
135+ pub data_dir: PathBuf,
136+ /// Directory holding the bare repositories, sharded by id prefix.
137+ pub repo_dir: PathBuf,
138+}
139+
140+impl Default for Storage {
141+ fn default() -> Self {
142+ Self {
143+ data_dir: PathBuf::from("/var/lib/fabrica"),
144+ repo_dir: PathBuf::from("/var/lib/fabrica/repos"),
145+ }
146+ }
147+}
148+
149+/// `[database]` — connection settings.
150+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151+#[serde(deny_unknown_fields)]
152+pub struct Database {
153+ /// Connection URL; `sqlite:` and `postgres:` schemes are detected at runtime.
154+ pub url: String,
155+ /// Maximum number of pooled connections.
156+ pub max_connections: u32,
157+}
158+
159+impl Default for Database {
160+ fn default() -> Self {
161+ Self {
162+ url: "sqlite:///var/lib/fabrica/fabrica.db".to_string(),
163+ max_connections: 16,
164+ }
165+ }
166+}
167+
168+/// `[auth]` — authentication parameters and secrets.
169+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170+#[serde(deny_unknown_fields)]
171+pub struct Auth {
172+ /// HS256 signing key. Generated on first run if neither this nor
173+ /// [`Auth::secret_file`] is set. Superseded by `secret_file` when present.
174+ #[serde(default, skip_serializing_if = "Option::is_none")]
175+ pub secret: Option<Secret>,
176+ /// Path to a file holding the HS256 signing key; takes precedence over
177+ /// [`Auth::secret`].
178+ #[serde(default, skip_serializing_if = "Option::is_none")]
179+ pub secret_file: Option<PathBuf>,
180+ /// Session lifetime, in days.
181+ pub session_ttl_days: u32,
182+ /// API token lifetime, in days.
183+ pub token_ttl_days: u32,
184+ /// Name of the session cookie.
185+ pub cookie_name: String,
186+ /// Set the `Secure` attribute on the session cookie.
187+ pub cookie_secure: bool,
188+ /// Argon2id memory cost, in KiB.
189+ pub argon2_m_cost: u32,
190+ /// Argon2id time cost (iterations).
191+ pub argon2_t_cost: u32,
192+ /// Argon2id parallelism (lanes).
193+ pub argon2_p_cost: u32,
194+}
195+
196+impl Default for Auth {
197+ fn default() -> Self {
198+ Self {
199+ secret: None,
200+ secret_file: None,
201+ session_ttl_days: 30,
202+ token_ttl_days: 90,
203+ cookie_name: "fabrica_session".to_string(),
204+ cookie_secure: true,
205+ argon2_m_cost: 19456,
206+ argon2_t_cost: 2,
207+ argon2_p_cost: 1,
208+ }
209+ }
210+}
211+
212+/// Outbound mail backend.
213+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
214+#[serde(rename_all = "lowercase")]
215+pub enum MailBackend {
216+ /// Deliver via SMTP (requires host and from).
217+ Smtp,
218+ /// Print messages to standard output (useful for development).
219+ Stdout,
220+ /// Discard messages; mail-dependent features are disabled.
221+ None,
222+}
223+
224+/// Transport encryption for the SMTP backend.
225+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
226+#[serde(rename_all = "lowercase")]
227+pub enum MailEncryption {
228+ /// Upgrade a plaintext connection with `STARTTLS`.
229+ Starttls,
230+ /// Connect with implicit TLS (SMTPS).
231+ Tls,
232+ /// No transport encryption.
233+ None,
234+}
235+
236+/// `[mail]` — outbound mail.
237+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238+#[serde(deny_unknown_fields)]
239+pub struct Mail {
240+ /// Which delivery backend to use.
241+ pub backend: MailBackend,
242+ /// Envelope and header `From` address.
243+ pub from: String,
244+ /// SMTP server hostname.
245+ pub host: String,
246+ /// SMTP server port.
247+ pub port: u16,
248+ /// SMTP username.
249+ pub username: String,
250+ /// SMTP password. Superseded by [`Mail::password_file`] when present.
251+ #[serde(default, skip_serializing_if = "Option::is_none")]
252+ pub password: Option<Secret>,
253+ /// Path to a file holding the SMTP password; takes precedence over
254+ /// [`Mail::password`].
255+ #[serde(default, skip_serializing_if = "Option::is_none")]
256+ pub password_file: Option<PathBuf>,
257+ /// Transport encryption.
258+ pub encryption: MailEncryption,
259+ /// Connection/handshake timeout, in seconds.
260+ pub timeout_secs: u64,
261+}
262+
263+impl Default for Mail {
264+ fn default() -> Self {
265+ Self {
266+ backend: MailBackend::Stdout,
267+ from: "fabrica@localhost".to_string(),
268+ host: String::new(),
269+ port: 587,
270+ username: String::new(),
271+ password: None,
272+ password_file: None,
273+ encryption: MailEncryption::Starttls,
274+ timeout_secs: 15,
275+ }
276+ }
277+}
278+
279+/// `[ui]` — web UI presentation.
280+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
281+#[serde(deny_unknown_fields)]
282+pub struct Ui {
283+ /// Active theme: a file stem in [`Ui::themes_dir`] or an embedded theme name.
284+ pub theme: String,
285+ /// Directory scanned for additional `*.css` themes.
286+ pub themes_dir: PathBuf,
287+ /// Directory for asset overrides; embedded assets are used as a fallback.
288+ pub assets_dir: PathBuf,
289+ /// Show the theme picker and honour the per-visitor theme cookie.
290+ pub allow_theme_choice: bool,
291+ /// Show the (CI) runs navigation slot. Inert until CI ships.
292+ pub show_runs: bool,
293+ /// Lines of context shown around each diff hunk.
294+ pub diff_context_lines: u32,
295+ /// Skip syntax highlighting for blobs larger than this many bytes.
296+ pub max_highlight_bytes: u64,
297+ /// Refuse to render diffs larger than this many bytes.
298+ pub max_diff_bytes: u64,
299+ /// Maximum number of commits walked when annotating a tree listing.
300+ pub tree_history_limit: u32,
301+}
302+
303+impl Default for Ui {
304+ fn default() -> Self {
305+ Self {
306+ theme: "dark".to_string(),
307+ themes_dir: PathBuf::from("/var/lib/fabrica/themes"),
308+ assets_dir: PathBuf::from("/var/lib/fabrica/assets"),
309+ allow_theme_choice: true,
310+ show_runs: false,
311+ diff_context_lines: 3,
312+ max_highlight_bytes: 1_048_576,
313+ max_diff_bytes: 5_242_880,
314+ tree_history_limit: 1000,
315+ }
316+ }
317+}
318+
319+/// `[git]` — git binary and pack-transport limits.
320+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321+#[serde(deny_unknown_fields)]
322+pub struct Git {
323+ /// The `git` binary to spawn for pack transport and maintenance.
324+ pub binary: String,
325+ /// Maximum wall-clock time for a single pack transfer, in seconds.
326+ pub transport_timeout_secs: u64,
327+ /// Reject pushes whose received pack exceeds this many bytes.
328+ pub max_pack_size_bytes: u64,
329+}
330+
331+impl Default for Git {
332+ fn default() -> Self {
333+ Self {
334+ binary: "git".to_string(),
335+ transport_timeout_secs: 3600,
336+ max_pack_size_bytes: 536_870_912,
337+ }
338+ }
339+}
340+
341+/// Log output format.
342+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
343+#[serde(rename_all = "lowercase")]
344+pub enum LogFormat {
345+ /// Human-readable, colourised when attached to a terminal.
346+ Pretty,
347+ /// One JSON object per line.
348+ Json,
349+}
350+
351+/// Minimum log level. `RUST_LOG` is also honoured at runtime.
352+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
353+#[serde(rename_all = "lowercase")]
354+pub enum LogLevel {
355+ /// Verbose tracing.
356+ Trace,
357+ /// Debugging detail.
358+ Debug,
359+ /// Normal operational messages.
360+ Info,
361+ /// Warnings only.
362+ Warn,
363+ /// Errors only.
364+ Error,
365+}
366+
367+/// `[log]` — logging.
368+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
369+#[serde(deny_unknown_fields)]
370+pub struct Log {
371+ /// Minimum level emitted.
372+ pub level: LogLevel,
373+ /// Output format.
374+ pub format: LogFormat,
375+}
376+
377+impl Default for Log {
378+ fn default() -> Self {
379+ Self {
380+ level: LogLevel::Info,
381+ format: LogFormat::Pretty,
382+ }
383+ }
384+}
docs/configuration.md +92 −0
@@ -0,0 +1,92 @@
1+# Configuration
2+
3+fabrica reads a single TOML file, layered over built-in defaults and overridable
4+by the environment. A fully-commented starting point ships as
5+[`fabrica.example.toml`](../fabrica.example.toml); copy it, keep only the keys
6+you change, and validate with `fabrica config check`.
7+
8+## Resolution order
9+
10+Sources are merged in the following order; a later source overrides an earlier
11+one key-by-key:
12+
13+1. Built-in defaults (every key has one — a bare config is valid).
14+2. `/etc/fabrica/fabrica.toml`
15+3. `$XDG_CONFIG_HOME/fabrica/fabrica.toml` (falls back to
16+ `$HOME/.config/fabrica/fabrica.toml`)
17+4. `--config <path>` passed on the command line
18+5. `FABRICA__*` environment variables
19+
20+Missing files are skipped silently, so the same binary serves a first-run
21+machine and a fully-configured one.
22+
23+### Environment overrides
24+
25+Environment variables are prefixed `FABRICA__` and nest with double
26+underscores; single underscores inside a key are preserved:
27+
28+```
29+FABRICA__SERVER__PORT=8080 # -> server.port
30+FABRICA__INSTANCE__DEFAULT_BRANCH=trunk # -> instance.default_branch
31+FABRICA__LOG__LEVEL=debug # -> log.level
32+```
33+
34+### Secret files (`*_file`)
35+
36+Any key ending in `_file` reads its value from that file and takes precedence
37+over its inline sibling. This keeps secrets out of the config file for
38+agenix / systemd-creds / Docker secrets:
39+
40+| Inline key | File key | Contents |
41+| --------------- | ------------------ | ---------------------------- |
42+| `auth.secret` | `auth.secret_file` | HS256 session-signing key |
43+| `mail.password` | `mail.password_file` | SMTP password |
44+
45+A single trailing newline is trimmed from the file. Secrets are never logged,
46+never appear in error messages, and render as `<redacted>` in
47+`fabrica config show`.
48+
49+## Validation
50+
51+Validation is strict and fails fast with actionable messages:
52+
53+- **Unknown keys are an error.** A typo (`prot` for `port`, a misplaced section)
54+ aborts the load rather than being ignored.
55+- `instance.url` must be a valid URL and must not have a trailing slash.
56+- `instance.default_branch` must not be empty.
57+- `storage.data_dir` and `storage.repo_dir` must exist or be creatable (they may
58+ be created on first run).
59+- `mail.backend = "smtp"` requires `mail.host` and `mail.from`.
60+
61+Some conditions are **warnings** — printed to stderr but non-fatal:
62+
63+- `auth.cookie_secure = true` with a non-`https` `instance.url`: browsers will
64+ drop the session cookie. Use this only for local development.
65+
66+## Sections
67+
68+Each section maps one-to-one to a `[table]` in the TOML file. See
69+`fabrica.example.toml` for the default value and a comment on every key.
70+
71+| Section | Purpose |
72+| ------------ | ------------------------------------------------------------- |
73+| `[instance]` | Identity, canonical URL, default branch, anonymous access. |
74+| `[server]` | HTTP bind address/port, proxy trust, body-size and drain limits. |
75+| `[ssh]` | SSH bind address/port, host key path, advertised clone endpoint. |
76+| `[storage]` | Data and repository directories on disk. |
77+| `[database]` | Connection URL (`sqlite:`/`postgres:`) and pool size. |
78+| `[auth]` | Session/token lifetimes, cookie settings, Argon2id cost, signing key. |
79+| `[mail]` | Backend (`smtp`/`stdout`/`none`), sender, SMTP endpoint and encryption. |
80+| `[ui]` | Theme, asset/theme directories, diff/highlight limits. |
81+| `[git]` | `git` binary path and pack-transport limits. |
82+| `[log]` | Level and format (`pretty`/`json`). |
83+
84+## Commands
85+
86+- `fabrica config check` — validate the effective configuration and exit. Prints
87+ `configuration ok` and exits `0` on success; prints the problems and exits `1`
88+ otherwise. Warnings are printed but do not change the exit status.
89+- `fabrica config show` — print the effective, merged configuration with secrets
90+ redacted. Add `--json` for machine-readable output.
91+
92+Both honour the global `--config <path>` flag.
docs/decisions.md +33 −0
@@ -85,3 +85,36 @@ the intended path if linear scan stops being fast enough.
8585 **Rationale:** flake-parts scales better as the flake grows (NixOS module,
8686 container image, multiple checks) and gives a cleaner multi-output structure. The
8787 spec permits either; picked one and stay consistent.
88+
89+## 2026-07-24 — Secrets are a redact-by-default `Secret` newtype
90+
91+**Decision:** Secret configuration values (`auth.secret`, `mail.password`) are
92+wrapped in a `config::Secret` newtype whose `Debug` and `Serialize` impls always
93+emit `<redacted>`. The real value is reachable only through `Secret::expose`.
94+
95+**Alternatives:** Store secrets as plain `String` and redact them at each output
96+site (`config show`, error messages, logs).
97+
98+**Rationale:** Redaction is the default and cannot be forgotten — a `Secret`
99+cannot leak through `tracing`, a `Debug`-formatted `Config`, `fabrica config
100+show`, or the JSON API, satisfying the spec's "secrets MUST NOT be logged,
101+echoed, or included in error messages". Site-by-site redaction is one missed
102+call away from a leak. Trade-off: serialization is lossy, so `Secret` is never
103+round-tripped through our own `Serialize` (figment reads the raw value into the
104+figment `Value` before deserialization, and `*_file` resolution reads from disk).
105+
106+## 2026-07-24 — Strict config: unknown keys are errors, missing files are not
107+
108+**Decision:** Every config struct is `#[serde(deny_unknown_fields)]`, so an
109+unknown key (typo, stale key, stray `FABRICA__*`) aborts the load. Missing files
110+at the fixed locations (`/etc`, XDG) and a missing `--config` path are tolerated
111+silently. Directory validation checks creatability without side effects (an
112+existing directory ancestor), never creating anything during `config check`.
113+
114+**Alternatives:** Ignore unknown keys (serde default); require every file to
115+exist; create directories during validation.
116+
117+**Rationale:** Failing fast on typos is worth more than forward-compatibility for
118+a single-operator forge. Tolerating missing files lets one binary serve both a
119+bare first-run machine and a fully-configured one. Side-effect-free validation
120+keeps `config check` safe to run anywhere.
fabrica.example.toml +89 −0
@@ -0,0 +1,89 @@
1+# fabrica configuration — copy to /etc/fabrica/fabrica.toml (or point at it with
2+# `fabrica --config <path>`) and edit. Every value shown below is the built-in
3+# default, so you only need to keep the keys you actually change.
4+#
5+# Resolution order, later wins: built-in defaults -> /etc/fabrica/fabrica.toml
6+# -> $XDG_CONFIG_HOME/fabrica/fabrica.toml -> --config <path> -> FABRICA__* env.
7+# Environment overrides nest with double underscores, e.g.
8+# FABRICA__SERVER__PORT=8080
9+#
10+# Any *_file key reads its value from that file and wins over its inline sibling
11+# (use it for agenix / systemd-creds / Docker secrets). Unknown keys are a hard
12+# error, so a typo fails fast rather than being silently ignored.
13+#
14+# Validate with `fabrica config check`; print the effective config (secrets
15+# redacted) with `fabrica config show`.
16+
17+[instance]
18+name = "fabrica" # shown in the header and page titles
19+url = "https://git.example.com" # canonical external URL, no trailing slash
20+description = "A small, private forge." # one line, shown on the landing page
21+default_branch = "main" # default branch for new repositories
22+allow_anonymous = true # anonymous browse + clone of public repos
23+
24+[server]
25+address = "0.0.0.0"
26+port = 8080
27+behind_proxy = false # trust X-Forwarded-For / X-Forwarded-Proto
28+graceful_shutdown_secs = 20 # drain in-flight requests for this long on shutdown
29+max_body_bytes = 1048576 # non-pack routes only
30+
31+[ssh]
32+enabled = true
33+address = "0.0.0.0"
34+port = 2222
35+host_key_path = "/var/lib/fabrica/ssh/host_ed25519" # generated on first run if absent
36+clone_host = "git.example.com" # what the UI shows in clone URLs, may differ from bind
37+clone_port = 22
38+
39+[storage]
40+data_dir = "/var/lib/fabrica" # secrets, host key, SQLite db, scratch
41+repo_dir = "/var/lib/fabrica/repos" # bare repos, sharded by id prefix
42+
43+[database]
44+url = "sqlite:///var/lib/fabrica/fabrica.db"
45+max_connections = 16
46+# SQLite is opened with journal_mode=WAL, synchronous=NORMAL, foreign_keys=ON,
47+# busy_timeout=5000. Postgres URLs (postgres://…) are detected automatically.
48+
49+[auth]
50+# secret = "…" # HS256 key; prefer secret_file below
51+secret_file = "/var/lib/fabrica/secret" # generated on first run, mode 0600
52+session_ttl_days = 30
53+token_ttl_days = 90
54+cookie_name = "fabrica_session"
55+cookie_secure = true # set false only if instance.url is plain http (dev)
56+argon2_m_cost = 19456 # KiB
57+argon2_t_cost = 2 # iterations
58+argon2_p_cost = 1 # lanes
59+
60+[mail]
61+backend = "smtp" # smtp | stdout | none
62+from = "fabrica@example.com"
63+host = "smtp.example.com" # required when backend = "smtp"
64+port = 587
65+username = "fabrica"
66+# password = "…" # prefer password_file below
67+password_file = "/run/secrets/fabrica-smtp"
68+encryption = "starttls" # starttls | tls | none
69+timeout_secs = 15
70+
71+[ui]
72+theme = "dark" # file stem in themes_dir, or an embedded theme name
73+themes_dir = "/var/lib/fabrica/themes"
74+assets_dir = "/var/lib/fabrica/assets"
75+allow_theme_choice = true # show the theme picker; choice persists in a cookie
76+show_runs = false # CI nav slot, inert until CI ships
77+diff_context_lines = 3
78+max_highlight_bytes = 1048576 # skip highlighting blobs larger than this
79+max_diff_bytes = 5242880 # refuse to render diffs larger than this
80+tree_history_limit = 1000 # commits walked when annotating a tree listing
81+
82+[git]
83+binary = "git" # must be >= 2.41 and on PATH
84+transport_timeout_secs = 3600
85+max_pack_size_bytes = 536870912 # reject pushes with a larger received pack
86+
87+[log]
88+level = "info" # trace | debug | info | warn | error (RUST_LOG also honoured)
89+format = "pretty" # pretty | json