Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +875 −0
| @@ -3,20 +3,189 @@ | |||
| 3 | version = 4 | 3 | version = 4 |
| 4 | 4 | ||
| 5 | [[package]] | 5 | [[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]] | ||
| 6 | name = "api" | 62 | name = "api" |
| 7 | version = "0.1.0" | 63 | version = "0.1.0" |
| 8 | 64 | ||
| 9 | [[package]] | 65 | [[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]] | ||
| 10 | name = "auth" | 75 | name = "auth" |
| 11 | version = "0.1.0" | 76 | version = "0.1.0" |
| 12 | 77 | ||
| 13 | [[package]] | 78 | [[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]] | ||
| 14 | name = "cli" | 137 | name = "cli" |
| 15 | version = "0.1.0" | 138 | 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" | ||
| 16 | 151 | ||
| 17 | [[package]] | 152 | [[package]] |
| 18 | name = "config" | 153 | name = "config" |
| 19 | version = "0.1.0" | 154 | 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 | ] | ||
| 20 | 189 | ||
| 21 | [[package]] | 190 | [[package]] |
| 22 | name = "fabrica" | 191 | name = "fabrica" |
| @@ -26,29 +195,735 @@ dependencies = [ | |||
| 26 | ] | 195 | ] |
| 27 | 196 | ||
| 28 | [[package]] | 197 | [[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]] | ||
| 29 | name = "git" | 240 | name = "git" |
| 30 | version = "0.1.0" | 241 | version = "0.1.0" |
| 31 | 242 | ||
| 32 | [[package]] | 243 | [[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]] | ||
| 33 | name = "highlight" | 256 | name = "highlight" |
| 34 | version = "0.1.0" | 257 | version = "0.1.0" |
| 35 | 258 | ||
| 36 | [[package]] | 259 | [[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]] | ||
| 37 | name = "mail" | 417 | name = "mail" |
| 38 | version = "0.1.0" | 418 | version = "0.1.0" |
| 39 | 419 | ||
| 40 | [[package]] | 420 | [[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]] | ||
| 41 | name = "model" | 427 | name = "model" |
| 42 | version = "0.1.0" | 428 | version = "0.1.0" |
| 43 | 429 | ||
| 44 | [[package]] | 430 | [[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]] | ||
| 45 | name = "ssh" | 627 | name = "ssh" |
| 46 | version = "0.1.0" | 628 | version = "0.1.0" |
| 47 | 629 | ||
| 48 | [[package]] | 630 | [[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]] | ||
| 49 | name = "store" | 637 | name = "store" |
| 50 | version = "0.1.0" | 638 | version = "0.1.0" |
| 51 | 639 | ||
| 52 | [[package]] | 640 | [[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]] | ||
| 53 | name = "web" | 809 | name = "web" |
| 54 | version = "0.1.0" | 810 | 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" } | |||
| 48 | api = { path = "crates/api" } | 48 | api = { path = "crates/api" } |
| 49 | cli = { path = "crates/cli" } | 49 | cli = { path = "crates/cli" } |
| 50 | 50 | ||
| 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 | |||
| 51 | [workspace.lints.rust] | 62 | [workspace.lints.rust] |
| 52 | unsafe_code = "forbid" | 63 | unsafe_code = "forbid" |
| 53 | missing_docs = "warn" | 64 | missing_docs = "warn" |
crates/cli/Cargo.toml +4 −0
| @@ -10,6 +10,10 @@ repository.workspace = true | |||
| 10 | publish.workspace = true | 10 | publish.workspace = true |
| 11 | 11 | ||
| 12 | [dependencies] | 12 | [dependencies] |
| 13 | config = { workspace = true } | ||
| 14 | anyhow = { workspace = true } | ||
| 15 | clap = { workspace = true } | ||
| 16 | serde_json = { workspace = true } | ||
| 13 | 17 | ||
| 14 | [lints] | 18 | [lints] |
| 15 | workspace = true | 19 | 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 @@ | |||
| 6 | //! | 6 | //! |
| 7 | //! Every subcommand operates directly on the store and filesystem; there is no | 7 | //! 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 the | 8 | //! 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`. | ||
| 10 | 11 | ||
| 12 | mod config_cmd; | ||
| 13 | |||
| 14 | use std::path::PathBuf; | ||
| 11 | use std::process::ExitCode; | 15 | use std::process::ExitCode; |
| 12 | 16 | ||
| 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 | |||
| 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 the | 53 | /// 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] |
| 18 | pub fn run() -> ExitCode { | 57 | 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 | } | ||
| 20 | } | 73 | } |
crates/config/Cargo.toml +8 −0
| @@ -10,6 +10,14 @@ repository.workspace = true | |||
| 10 | publish.workspace = true | 10 | publish.workspace = true |
| 11 | 11 | ||
| 12 | [dependencies] | 12 | [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"] } | ||
| 13 | 21 | ||
| 14 | [lints] | 22 | [lints] |
| 15 | workspace = true | 23 | workspace = 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/. |
| 4 | 4 | ||
| 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 | |||
| 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. | |||
| 85 | **Rationale:** flake-parts scales better as the flake grows (NixOS module, | 85 | **Rationale:** flake-parts scales better as the flake grows (NixOS module, |
| 86 | container image, multiple checks) and gives a cleaner multi-output structure. The | 86 | container image, multiple checks) and gives a cleaner multi-output structure. The |
| 87 | spec permits either; picked one and stay consistent. | 87 | 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 | ||