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 @@
3version = 43version = 4
44
5[[package]]5[[package]]
6name = "anstream"
7version = "1.0.0"
8source = "registry+https://github.com/rust-lang/crates.io-index"
9checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
10dependencies = [
11 "anstyle",
12 "anstyle-parse",
13 "anstyle-query",
14 "anstyle-wincon",
15 "colorchoice",
16 "is_terminal_polyfill",
17 "utf8parse",
18]
19
20[[package]]
21name = "anstyle"
22version = "1.0.14"
23source = "registry+https://github.com/rust-lang/crates.io-index"
24checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
25
26[[package]]
27name = "anstyle-parse"
28version = "1.0.0"
29source = "registry+https://github.com/rust-lang/crates.io-index"
30checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
31dependencies = [
32 "utf8parse",
33]
34
35[[package]]
36name = "anstyle-query"
37version = "1.1.5"
38source = "registry+https://github.com/rust-lang/crates.io-index"
39checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
40dependencies = [
41 "windows-sys",
42]
43
44[[package]]
45name = "anstyle-wincon"
46version = "3.0.11"
47source = "registry+https://github.com/rust-lang/crates.io-index"
48checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
49dependencies = [
50 "anstyle",
51 "once_cell_polyfill",
52 "windows-sys",
53]
54
55[[package]]
56name = "anyhow"
57version = "1.0.104"
58source = "registry+https://github.com/rust-lang/crates.io-index"
59checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
60
61[[package]]
6name = "api"62name = "api"
7version = "0.1.0"63version = "0.1.0"
864
9[[package]]65[[package]]
66name = "atomic"
67version = "0.6.1"
68source = "registry+https://github.com/rust-lang/crates.io-index"
69checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340"
70dependencies = [
71 "bytemuck",
72]
73
74[[package]]
10name = "auth"75name = "auth"
11version = "0.1.0"76version = "0.1.0"
1277
13[[package]]78[[package]]
79name = "bitflags"
80version = "2.13.1"
81source = "registry+https://github.com/rust-lang/crates.io-index"
82checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
83
84[[package]]
85name = "bytemuck"
86version = "1.25.2"
87source = "registry+https://github.com/rust-lang/crates.io-index"
88checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
89
90[[package]]
91name = "cfg-if"
92version = "1.0.4"
93source = "registry+https://github.com/rust-lang/crates.io-index"
94checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
95
96[[package]]
97name = "clap"
98version = "4.6.4"
99source = "registry+https://github.com/rust-lang/crates.io-index"
100checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7"
101dependencies = [
102 "clap_builder",
103 "clap_derive",
104]
105
106[[package]]
107name = "clap_builder"
108version = "4.6.2"
109source = "registry+https://github.com/rust-lang/crates.io-index"
110checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
111dependencies = [
112 "anstream",
113 "anstyle",
114 "clap_lex",
115 "strsim",
116]
117
118[[package]]
119name = "clap_derive"
120version = "4.6.4"
121source = "registry+https://github.com/rust-lang/crates.io-index"
122checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
123dependencies = [
124 "heck",
125 "proc-macro2",
126 "quote",
127 "syn 3.0.3",
128]
129
130[[package]]
131name = "clap_lex"
132version = "1.1.0"
133source = "registry+https://github.com/rust-lang/crates.io-index"
134checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
135
136[[package]]
14name = "cli"137name = "cli"
15version = "0.1.0"138version = "0.1.0"
139dependencies = [
140 "anyhow",
141 "clap",
142 "config",
143 "serde_json",
144]
145
146[[package]]
147name = "colorchoice"
148version = "1.0.5"
149source = "registry+https://github.com/rust-lang/crates.io-index"
150checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
16151
17[[package]]152[[package]]
18name = "config"153name = "config"
19version = "0.1.0"154version = "0.1.0"
155dependencies = [
156 "figment",
157 "serde",
158 "thiserror",
159 "toml",
160 "url",
161]
162
163[[package]]
164name = "displaydoc"
165version = "0.2.6"
166source = "registry+https://github.com/rust-lang/crates.io-index"
167checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
168dependencies = [
169 "proc-macro2",
170 "quote",
171 "syn 2.0.119",
172]
173
174[[package]]
175name = "equivalent"
176version = "1.0.2"
177source = "registry+https://github.com/rust-lang/crates.io-index"
178checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
179
180[[package]]
181name = "errno"
182version = "0.3.14"
183source = "registry+https://github.com/rust-lang/crates.io-index"
184checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
185dependencies = [
186 "libc",
187 "windows-sys",
188]
20189
21[[package]]190[[package]]
22name = "fabrica"191name = "fabrica"
@@ -26,29 +195,735 @@ dependencies = [
26]195]
27196
28[[package]]197[[package]]
198name = "fastrand"
199version = "2.5.0"
200source = "registry+https://github.com/rust-lang/crates.io-index"
201checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
202
203[[package]]
204name = "figment"
205version = "0.10.19"
206source = "registry+https://github.com/rust-lang/crates.io-index"
207checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3"
208dependencies = [
209 "atomic",
210 "parking_lot",
211 "pear",
212 "serde",
213 "tempfile",
214 "toml",
215 "uncased",
216 "version_check",
217]
218
219[[package]]
220name = "form_urlencoded"
221version = "1.2.2"
222source = "registry+https://github.com/rust-lang/crates.io-index"
223checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
224dependencies = [
225 "percent-encoding",
226]
227
228[[package]]
229name = "getrandom"
230version = "0.4.3"
231source = "registry+https://github.com/rust-lang/crates.io-index"
232checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
233dependencies = [
234 "cfg-if",
235 "libc",
236 "r-efi",
237]
238
239[[package]]
29name = "git"240name = "git"
30version = "0.1.0"241version = "0.1.0"
31242
32[[package]]243[[package]]
244name = "hashbrown"
245version = "0.17.1"
246source = "registry+https://github.com/rust-lang/crates.io-index"
247checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
248
249[[package]]
250name = "heck"
251version = "0.5.0"
252source = "registry+https://github.com/rust-lang/crates.io-index"
253checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
254
255[[package]]
33name = "highlight"256name = "highlight"
34version = "0.1.0"257version = "0.1.0"
35258
36[[package]]259[[package]]
260name = "icu_collections"
261version = "2.1.1"
262source = "registry+https://github.com/rust-lang/crates.io-index"
263checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
264dependencies = [
265 "displaydoc",
266 "potential_utf",
267 "yoke",
268 "zerofrom",
269 "zerovec",
270]
271
272[[package]]
273name = "icu_locale_core"
274version = "2.1.1"
275source = "registry+https://github.com/rust-lang/crates.io-index"
276checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
277dependencies = [
278 "displaydoc",
279 "litemap",
280 "tinystr",
281 "writeable",
282 "zerovec",
283]
284
285[[package]]
286name = "icu_normalizer"
287version = "2.1.1"
288source = "registry+https://github.com/rust-lang/crates.io-index"
289checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
290dependencies = [
291 "icu_collections",
292 "icu_normalizer_data",
293 "icu_properties",
294 "icu_provider",
295 "smallvec",
296 "zerovec",
297]
298
299[[package]]
300name = "icu_normalizer_data"
301version = "2.1.1"
302source = "registry+https://github.com/rust-lang/crates.io-index"
303checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
304
305[[package]]
306name = "icu_properties"
307version = "2.1.2"
308source = "registry+https://github.com/rust-lang/crates.io-index"
309checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
310dependencies = [
311 "icu_collections",
312 "icu_locale_core",
313 "icu_properties_data",
314 "icu_provider",
315 "zerotrie",
316 "zerovec",
317]
318
319[[package]]
320name = "icu_properties_data"
321version = "2.1.2"
322source = "registry+https://github.com/rust-lang/crates.io-index"
323checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
324
325[[package]]
326name = "icu_provider"
327version = "2.1.1"
328source = "registry+https://github.com/rust-lang/crates.io-index"
329checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
330dependencies = [
331 "displaydoc",
332 "icu_locale_core",
333 "writeable",
334 "yoke",
335 "zerofrom",
336 "zerotrie",
337 "zerovec",
338]
339
340[[package]]
341name = "idna"
342version = "1.1.0"
343source = "registry+https://github.com/rust-lang/crates.io-index"
344checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
345dependencies = [
346 "idna_adapter",
347 "smallvec",
348 "utf8_iter",
349]
350
351[[package]]
352name = "idna_adapter"
353version = "1.2.1"
354source = "registry+https://github.com/rust-lang/crates.io-index"
355checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
356dependencies = [
357 "icu_normalizer",
358 "icu_properties",
359]
360
361[[package]]
362name = "indexmap"
363version = "2.14.0"
364source = "registry+https://github.com/rust-lang/crates.io-index"
365checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
366dependencies = [
367 "equivalent",
368 "hashbrown",
369]
370
371[[package]]
372name = "inlinable_string"
373version = "0.1.15"
374source = "registry+https://github.com/rust-lang/crates.io-index"
375checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
376
377[[package]]
378name = "is_terminal_polyfill"
379version = "1.70.2"
380source = "registry+https://github.com/rust-lang/crates.io-index"
381checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
382
383[[package]]
384name = "itoa"
385version = "1.0.18"
386source = "registry+https://github.com/rust-lang/crates.io-index"
387checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
388
389[[package]]
390name = "libc"
391version = "0.2.189"
392source = "registry+https://github.com/rust-lang/crates.io-index"
393checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
394
395[[package]]
396name = "linux-raw-sys"
397version = "0.12.1"
398source = "registry+https://github.com/rust-lang/crates.io-index"
399checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
400
401[[package]]
402name = "litemap"
403version = "0.8.2"
404source = "registry+https://github.com/rust-lang/crates.io-index"
405checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
406
407[[package]]
408name = "lock_api"
409version = "0.4.14"
410source = "registry+https://github.com/rust-lang/crates.io-index"
411checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
412dependencies = [
413 "scopeguard",
414]
415
416[[package]]
37name = "mail"417name = "mail"
38version = "0.1.0"418version = "0.1.0"
39419
40[[package]]420[[package]]
421name = "memchr"
422version = "2.8.3"
423source = "registry+https://github.com/rust-lang/crates.io-index"
424checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
425
426[[package]]
41name = "model"427name = "model"
42version = "0.1.0"428version = "0.1.0"
43429
44[[package]]430[[package]]
431name = "once_cell"
432version = "1.21.4"
433source = "registry+https://github.com/rust-lang/crates.io-index"
434checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
435
436[[package]]
437name = "once_cell_polyfill"
438version = "1.70.2"
439source = "registry+https://github.com/rust-lang/crates.io-index"
440checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
441
442[[package]]
443name = "parking_lot"
444version = "0.12.5"
445source = "registry+https://github.com/rust-lang/crates.io-index"
446checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
447dependencies = [
448 "lock_api",
449 "parking_lot_core",
450]
451
452[[package]]
453name = "parking_lot_core"
454version = "0.9.12"
455source = "registry+https://github.com/rust-lang/crates.io-index"
456checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
457dependencies = [
458 "cfg-if",
459 "libc",
460 "redox_syscall",
461 "smallvec",
462 "windows-link",
463]
464
465[[package]]
466name = "pear"
467version = "0.2.9"
468source = "registry+https://github.com/rust-lang/crates.io-index"
469checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467"
470dependencies = [
471 "inlinable_string",
472 "pear_codegen",
473 "yansi",
474]
475
476[[package]]
477name = "pear_codegen"
478version = "0.2.9"
479source = "registry+https://github.com/rust-lang/crates.io-index"
480checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147"
481dependencies = [
482 "proc-macro2",
483 "proc-macro2-diagnostics",
484 "quote",
485 "syn 2.0.119",
486]
487
488[[package]]
489name = "percent-encoding"
490version = "2.3.2"
491source = "registry+https://github.com/rust-lang/crates.io-index"
492checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
493
494[[package]]
495name = "potential_utf"
496version = "0.1.5"
497source = "registry+https://github.com/rust-lang/crates.io-index"
498checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
499dependencies = [
500 "zerovec",
501]
502
503[[package]]
504name = "proc-macro2"
505version = "1.0.107"
506source = "registry+https://github.com/rust-lang/crates.io-index"
507checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
508dependencies = [
509 "unicode-ident",
510]
511
512[[package]]
513name = "proc-macro2-diagnostics"
514version = "0.10.1"
515source = "registry+https://github.com/rust-lang/crates.io-index"
516checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
517dependencies = [
518 "proc-macro2",
519 "quote",
520 "syn 2.0.119",
521 "version_check",
522 "yansi",
523]
524
525[[package]]
526name = "quote"
527version = "1.0.47"
528source = "registry+https://github.com/rust-lang/crates.io-index"
529checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
530dependencies = [
531 "proc-macro2",
532]
533
534[[package]]
535name = "r-efi"
536version = "6.0.0"
537source = "registry+https://github.com/rust-lang/crates.io-index"
538checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
539
540[[package]]
541name = "redox_syscall"
542version = "0.5.18"
543source = "registry+https://github.com/rust-lang/crates.io-index"
544checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
545dependencies = [
546 "bitflags",
547]
548
549[[package]]
550name = "rustix"
551version = "1.1.4"
552source = "registry+https://github.com/rust-lang/crates.io-index"
553checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
554dependencies = [
555 "bitflags",
556 "errno",
557 "libc",
558 "linux-raw-sys",
559 "windows-sys",
560]
561
562[[package]]
563name = "scopeguard"
564version = "1.2.0"
565source = "registry+https://github.com/rust-lang/crates.io-index"
566checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
567
568[[package]]
569name = "serde"
570version = "1.0.229"
571source = "registry+https://github.com/rust-lang/crates.io-index"
572checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
573dependencies = [
574 "serde_core",
575 "serde_derive",
576]
577
578[[package]]
579name = "serde_core"
580version = "1.0.229"
581source = "registry+https://github.com/rust-lang/crates.io-index"
582checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
583dependencies = [
584 "serde_derive",
585]
586
587[[package]]
588name = "serde_derive"
589version = "1.0.229"
590source = "registry+https://github.com/rust-lang/crates.io-index"
591checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
592dependencies = [
593 "proc-macro2",
594 "quote",
595 "syn 3.0.3",
596]
597
598[[package]]
599name = "serde_json"
600version = "1.0.151"
601source = "registry+https://github.com/rust-lang/crates.io-index"
602checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
603dependencies = [
604 "itoa",
605 "memchr",
606 "serde",
607 "serde_core",
608 "zmij",
609]
610
611[[package]]
612name = "serde_spanned"
613version = "0.6.9"
614source = "registry+https://github.com/rust-lang/crates.io-index"
615checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
616dependencies = [
617 "serde",
618]
619
620[[package]]
621name = "smallvec"
622version = "1.15.2"
623source = "registry+https://github.com/rust-lang/crates.io-index"
624checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
625
626[[package]]
45name = "ssh"627name = "ssh"
46version = "0.1.0"628version = "0.1.0"
47629
48[[package]]630[[package]]
631name = "stable_deref_trait"
632version = "1.2.1"
633source = "registry+https://github.com/rust-lang/crates.io-index"
634checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
635
636[[package]]
49name = "store"637name = "store"
50version = "0.1.0"638version = "0.1.0"
51639
52[[package]]640[[package]]
641name = "strsim"
642version = "0.11.1"
643source = "registry+https://github.com/rust-lang/crates.io-index"
644checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
645
646[[package]]
647name = "syn"
648version = "2.0.119"
649source = "registry+https://github.com/rust-lang/crates.io-index"
650checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
651dependencies = [
652 "proc-macro2",
653 "quote",
654 "unicode-ident",
655]
656
657[[package]]
658name = "syn"
659version = "3.0.3"
660source = "registry+https://github.com/rust-lang/crates.io-index"
661checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
662dependencies = [
663 "proc-macro2",
664 "quote",
665 "unicode-ident",
666]
667
668[[package]]
669name = "synstructure"
670version = "0.13.2"
671source = "registry+https://github.com/rust-lang/crates.io-index"
672checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
673dependencies = [
674 "proc-macro2",
675 "quote",
676 "syn 2.0.119",
677]
678
679[[package]]
680name = "tempfile"
681version = "3.27.0"
682source = "registry+https://github.com/rust-lang/crates.io-index"
683checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
684dependencies = [
685 "fastrand",
686 "getrandom",
687 "once_cell",
688 "rustix",
689 "windows-sys",
690]
691
692[[package]]
693name = "thiserror"
694version = "2.0.19"
695source = "registry+https://github.com/rust-lang/crates.io-index"
696checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
697dependencies = [
698 "thiserror-impl",
699]
700
701[[package]]
702name = "thiserror-impl"
703version = "2.0.19"
704source = "registry+https://github.com/rust-lang/crates.io-index"
705checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
706dependencies = [
707 "proc-macro2",
708 "quote",
709 "syn 3.0.3",
710]
711
712[[package]]
713name = "tinystr"
714version = "0.8.3"
715source = "registry+https://github.com/rust-lang/crates.io-index"
716checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
717dependencies = [
718 "displaydoc",
719 "zerovec",
720]
721
722[[package]]
723name = "toml"
724version = "0.8.23"
725source = "registry+https://github.com/rust-lang/crates.io-index"
726checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
727dependencies = [
728 "serde",
729 "serde_spanned",
730 "toml_datetime",
731 "toml_edit",
732]
733
734[[package]]
735name = "toml_datetime"
736version = "0.6.11"
737source = "registry+https://github.com/rust-lang/crates.io-index"
738checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
739dependencies = [
740 "serde",
741]
742
743[[package]]
744name = "toml_edit"
745version = "0.22.27"
746source = "registry+https://github.com/rust-lang/crates.io-index"
747checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
748dependencies = [
749 "indexmap",
750 "serde",
751 "serde_spanned",
752 "toml_datetime",
753 "toml_write",
754 "winnow",
755]
756
757[[package]]
758name = "toml_write"
759version = "0.1.2"
760source = "registry+https://github.com/rust-lang/crates.io-index"
761checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
762
763[[package]]
764name = "uncased"
765version = "0.9.10"
766source = "registry+https://github.com/rust-lang/crates.io-index"
767checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697"
768dependencies = [
769 "version_check",
770]
771
772[[package]]
773name = "unicode-ident"
774version = "1.0.24"
775source = "registry+https://github.com/rust-lang/crates.io-index"
776checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
777
778[[package]]
779name = "url"
780version = "2.5.8"
781source = "registry+https://github.com/rust-lang/crates.io-index"
782checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
783dependencies = [
784 "form_urlencoded",
785 "idna",
786 "percent-encoding",
787 "serde",
788]
789
790[[package]]
791name = "utf8_iter"
792version = "1.0.4"
793source = "registry+https://github.com/rust-lang/crates.io-index"
794checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
795
796[[package]]
797name = "utf8parse"
798version = "0.2.2"
799source = "registry+https://github.com/rust-lang/crates.io-index"
800checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
801
802[[package]]
803name = "version_check"
804version = "0.9.5"
805source = "registry+https://github.com/rust-lang/crates.io-index"
806checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
807
808[[package]]
53name = "web"809name = "web"
54version = "0.1.0"810version = "0.1.0"
811
812[[package]]
813name = "windows-link"
814version = "0.2.1"
815source = "registry+https://github.com/rust-lang/crates.io-index"
816checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
817
818[[package]]
819name = "windows-sys"
820version = "0.61.2"
821source = "registry+https://github.com/rust-lang/crates.io-index"
822checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
823dependencies = [
824 "windows-link",
825]
826
827[[package]]
828name = "winnow"
829version = "0.7.15"
830source = "registry+https://github.com/rust-lang/crates.io-index"
831checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
832dependencies = [
833 "memchr",
834]
835
836[[package]]
837name = "writeable"
838version = "0.6.3"
839source = "registry+https://github.com/rust-lang/crates.io-index"
840checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
841
842[[package]]
843name = "yansi"
844version = "1.0.1"
845source = "registry+https://github.com/rust-lang/crates.io-index"
846checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
847
848[[package]]
849name = "yoke"
850version = "0.8.3"
851source = "registry+https://github.com/rust-lang/crates.io-index"
852checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
853dependencies = [
854 "stable_deref_trait",
855 "yoke-derive",
856 "zerofrom",
857]
858
859[[package]]
860name = "yoke-derive"
861version = "0.8.2"
862source = "registry+https://github.com/rust-lang/crates.io-index"
863checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
864dependencies = [
865 "proc-macro2",
866 "quote",
867 "syn 2.0.119",
868 "synstructure",
869]
870
871[[package]]
872name = "zerofrom"
873version = "0.1.8"
874source = "registry+https://github.com/rust-lang/crates.io-index"
875checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
876dependencies = [
877 "zerofrom-derive",
878]
879
880[[package]]
881name = "zerofrom-derive"
882version = "0.1.7"
883source = "registry+https://github.com/rust-lang/crates.io-index"
884checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
885dependencies = [
886 "proc-macro2",
887 "quote",
888 "syn 2.0.119",
889 "synstructure",
890]
891
892[[package]]
893name = "zerotrie"
894version = "0.2.4"
895source = "registry+https://github.com/rust-lang/crates.io-index"
896checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
897dependencies = [
898 "displaydoc",
899 "yoke",
900 "zerofrom",
901]
902
903[[package]]
904name = "zerovec"
905version = "0.11.6"
906source = "registry+https://github.com/rust-lang/crates.io-index"
907checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
908dependencies = [
909 "yoke",
910 "zerofrom",
911 "zerovec-derive",
912]
913
914[[package]]
915name = "zerovec-derive"
916version = "0.11.3"
917source = "registry+https://github.com/rust-lang/crates.io-index"
918checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
919dependencies = [
920 "proc-macro2",
921 "quote",
922 "syn 2.0.119",
923]
924
925[[package]]
926name = "zmij"
927version = "1.0.23"
928source = "registry+https://github.com/rust-lang/crates.io-index"
929checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Cargo.toml +11 −0
@@ -48,6 +48,17 @@ web = { path = "crates/web" }
48api = { path = "crates/api" }48api = { path = "crates/api" }
49cli = { path = "crates/cli" }49cli = { 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.
53figment = { version = "0.10", features = ["toml", "env"] }
54serde = { version = "1", features = ["derive"] }
55serde_json = "1"
56toml = "0.8"
57thiserror = "2"
58url = "2"
59clap = { version = "4", features = ["derive"] }
60anyhow = "1"
61
51[workspace.lints.rust]62[workspace.lints.rust]
52unsafe_code = "forbid"63unsafe_code = "forbid"
53missing_docs = "warn"64missing_docs = "warn"
crates/cli/Cargo.toml +4 −0
@@ -10,6 +10,10 @@ repository.workspace = true
10publish.workspace = true10publish.workspace = true
1111
12[dependencies]12[dependencies]
13config = { workspace = true }
14anyhow = { workspace = true }
15clap = { workspace = true }
16serde_json = { workspace = true }
1317
14[lints]18[lints]
15workspace = true19workspace = 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
7use std::path::Path;
8use std::process::ExitCode;
9
10use clap::Subcommand;
11
12/// Actions under `fabrica config`.
13#[derive(Debug, Subcommand)]
14pub(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.
27pub(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.
58fn 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 @@
6//!6//!
7//! Every subcommand operates directly on the store and filesystem; there is no7//! Every subcommand operates directly on the store and filesystem; there is no
8//! IPC with a running `serve`. [`run`] is the single entry point invoked by the8//! 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
12mod config_cmd;
13
14use std::path::PathBuf;
11use std::process::ExitCode;15use std::process::ExitCode;
1216
17use 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.")]
22struct 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)]
42enum 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
13/// Parse arguments and dispatch to the requested subcommand.51/// Parse arguments and dispatch to the requested subcommand.
14///52///
15/// Returns the process exit code. The command tree is built out over the53/// Returns the process exit code. Argument-parse failures are handled by clap
16/// implementation phases; today this is a placeholder that exits successfully.54/// (which prints usage and exits); any other error is printed to stderr and
55/// mapped to [`ExitCode::FAILURE`].
17#[must_use]56#[must_use]
18pub fn run() -> ExitCode {57pub fn run() -> ExitCode {
19 ExitCode::SUCCESS58 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.
69fn 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 }
20}73}
crates/config/Cargo.toml +8 −0
@@ -10,6 +10,14 @@ repository.workspace = true
10publish.workspace = true10publish.workspace = true
1111
12[dependencies]12[dependencies]
13figment = { workspace = true }
14serde = { workspace = true }
15thiserror = { workspace = true }
16toml = { workspace = true }
17url = { workspace = true }
18
19[dev-dependencies]
20figment = { workspace = true, features = ["test"] }
1321
14[lints]22[lints]
15workspace = true23workspace = true
crates/config/src/lib.rs +420 −0
@@ -3,3 +3,423 @@
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
44
5//! Configuration loading, validation, and path resolution.5//! 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
25mod secret;
26mod types;
27
28use std::path::{Path, PathBuf};
29
30use figment::Figment;
31use figment::providers::{Env, Format, Serialized, Toml};
32
33pub use crate::secret::{REDACTED, Secret};
34pub 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.
40const ETC_PATH: &str = "/etc/fabrica/fabrica.toml";
41
42/// Prefix and nesting separator for environment overrides.
43const ENV_PREFIX: &str = "FABRICA__";
44
45/// Errors produced while loading or validating configuration.
46#[derive(Debug, thiserror::Error)]
47pub 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)]
75pub 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.
90pub 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`].
103pub 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.
113fn 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`.
127fn 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.
139fn 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.
150fn 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.
162fn 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`.
222fn 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
256impl 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)]
268mod 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
7use std::fmt;
8
9use serde::{Deserialize, Deserializer, Serialize, Serializer};
10
11/// The placeholder rendered in every non-authorised view of a secret.
12pub 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)]
21pub struct Secret(String);
22
23impl 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
41impl fmt::Debug for Secret {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 f.write_str(REDACTED)
44 }
45}
46
47impl Serialize for Secret {
48 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
49 serializer.serialize_str(REDACTED)
50 }
51}
52
53impl<'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
59impl From<String> for Secret {
60 fn from(value: String) -> Self {
61 Self(value)
62 }
63}
64
65#[cfg(test)]
66mod 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
11use std::path::PathBuf;
12
13use serde::{Deserialize, Serialize};
14
15use crate::secret::Secret;
16
17/// The complete, merged configuration.
18#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub 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)]
46pub 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
59impl 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)]
74pub 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
87impl 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)]
102pub 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
117impl 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)]
133pub 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
140impl 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)]
152pub 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
159impl 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)]
171pub 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
196impl 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")]
215pub 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")]
227pub 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)]
239pub 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
263impl 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)]
282pub 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
303impl 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)]
322pub 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
331impl 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")]
344pub 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")]
354pub 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)]
370pub struct Log {
371 /// Minimum level emitted.
372 pub level: LogLevel,
373 /// Output format.
374 pub format: LogFormat,
375}
376
377impl 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
3fabrica reads a single TOML file, layered over built-in defaults and overridable
4by the environment. A fully-commented starting point ships as
5[`fabrica.example.toml`](../fabrica.example.toml); copy it, keep only the keys
6you change, and validate with `fabrica config check`.
7
8## Resolution order
9
10Sources are merged in the following order; a later source overrides an earlier
11one key-by-key:
12
131. Built-in defaults (every key has one — a bare config is valid).
142. `/etc/fabrica/fabrica.toml`
153. `$XDG_CONFIG_HOME/fabrica/fabrica.toml` (falls back to
16 `$HOME/.config/fabrica/fabrica.toml`)
174. `--config <path>` passed on the command line
185. `FABRICA__*` environment variables
19
20Missing files are skipped silently, so the same binary serves a first-run
21machine and a fully-configured one.
22
23### Environment overrides
24
25Environment variables are prefixed `FABRICA__` and nest with double
26underscores; single underscores inside a key are preserved:
27
28```
29FABRICA__SERVER__PORT=8080 # -> server.port
30FABRICA__INSTANCE__DEFAULT_BRANCH=trunk # -> instance.default_branch
31FABRICA__LOG__LEVEL=debug # -> log.level
32```
33
34### Secret files (`*_file`)
35
36Any key ending in `_file` reads its value from that file and takes precedence
37over its inline sibling. This keeps secrets out of the config file for
38agenix / 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
45A single trailing newline is trimmed from the file. Secrets are never logged,
46never appear in error messages, and render as `<redacted>` in
47`fabrica config show`.
48
49## Validation
50
51Validation 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
61Some 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
68Each 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
92Both 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.
85**Rationale:** flake-parts scales better as the flake grows (NixOS module,85**Rationale:** flake-parts scales better as the flake grows (NixOS module,
86container image, multiple checks) and gives a cleaner multi-output structure. The86container image, multiple checks) and gives a cleaner multi-output structure. The
87spec permits either; picked one and stay consistent.87spec 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
92wrapped in a `config::Secret` newtype whose `Debug` and `Serialize` impls always
93emit `<redacted>`. The real value is reachable only through `Secret::expose`.
94
95**Alternatives:** Store secrets as plain `String` and redact them at each output
96site (`config show`, error messages, logs).
97
98**Rationale:** Redaction is the default and cannot be forgotten — a `Secret`
99cannot leak through `tracing`, a `Debug`-formatted `Config`, `fabrica config
100show`, or the JSON API, satisfying the spec's "secrets MUST NOT be logged,
101echoed, or included in error messages". Site-by-site redaction is one missed
102call away from a leak. Trade-off: serialization is lossy, so `Secret` is never
103round-tripped through our own `Serialize` (figment reads the raw value into the
104figment `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
109unknown key (typo, stale key, stray `FABRICA__*`) aborts the load. Missing files
110at the fixed locations (`/etc`, XDG) and a missing `--config` path are tolerated
111silently. Directory validation checks creatability without side effects (an
112existing directory ancestor), never creating anything during `config check`.
113
114**Alternatives:** Ignore unknown keys (serde default); require every file to
115exist; create directories during validation.
116
117**Rationale:** Failing fast on typos is worth more than forward-compatibility for
118a single-operator forge. Tolerating missing files lets one binary serve both a
119bare first-run machine and a fully-configured one. Side-effect-free validation
120keeps `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]
18name = "fabrica" # shown in the header and page titles
19url = "https://git.example.com" # canonical external URL, no trailing slash
20description = "A small, private forge." # one line, shown on the landing page
21default_branch = "main" # default branch for new repositories
22allow_anonymous = true # anonymous browse + clone of public repos
23
24[server]
25address = "0.0.0.0"
26port = 8080
27behind_proxy = false # trust X-Forwarded-For / X-Forwarded-Proto
28graceful_shutdown_secs = 20 # drain in-flight requests for this long on shutdown
29max_body_bytes = 1048576 # non-pack routes only
30
31[ssh]
32enabled = true
33address = "0.0.0.0"
34port = 2222
35host_key_path = "/var/lib/fabrica/ssh/host_ed25519" # generated on first run if absent
36clone_host = "git.example.com" # what the UI shows in clone URLs, may differ from bind
37clone_port = 22
38
39[storage]
40data_dir = "/var/lib/fabrica" # secrets, host key, SQLite db, scratch
41repo_dir = "/var/lib/fabrica/repos" # bare repos, sharded by id prefix
42
43[database]
44url = "sqlite:///var/lib/fabrica/fabrica.db"
45max_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
51secret_file = "/var/lib/fabrica/secret" # generated on first run, mode 0600
52session_ttl_days = 30
53token_ttl_days = 90
54cookie_name = "fabrica_session"
55cookie_secure = true # set false only if instance.url is plain http (dev)
56argon2_m_cost = 19456 # KiB
57argon2_t_cost = 2 # iterations
58argon2_p_cost = 1 # lanes
59
60[mail]
61backend = "smtp" # smtp | stdout | none
62from = "fabrica@example.com"
63host = "smtp.example.com" # required when backend = "smtp"
64port = 587
65username = "fabrica"
66# password = "…" # prefer password_file below
67password_file = "/run/secrets/fabrica-smtp"
68encryption = "starttls" # starttls | tls | none
69timeout_secs = 15
70
71[ui]
72theme = "dark" # file stem in themes_dir, or an embedded theme name
73themes_dir = "/var/lib/fabrica/themes"
74assets_dir = "/var/lib/fabrica/assets"
75allow_theme_choice = true # show the theme picker; choice persists in a cookie
76show_runs = false # CI nav slot, inert until CI ships
77diff_context_lines = 3
78max_highlight_bytes = 1048576 # skip highlighting blobs larger than this
79max_diff_bytes = 5242880 # refuse to render diffs larger than this
80tree_history_limit = 1000 # commits walked when annotating a tree listing
81
82[git]
83binary = "git" # must be >= 2.41 and on PATH
84transport_timeout_secs = 3600
85max_pack_size_bytes = 536870912 # reject pushes with a larger received pack
86
87[log]
88level = "info" # trace | debug | info | warn | error (RUST_LOG also honoured)
89format = "pretty" # pretty | json