Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +875 −0
| @@ -3,20 +3,189 @@ | |||
| 3 | 3 | version = 4 | |
| 4 | 4 | ||
| 5 | 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 | 62 | name = "api" | |
| 7 | 63 | version = "0.1.0" | |
| 8 | 64 | ||
| 9 | 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 | 75 | name = "auth" | |
| 11 | 76 | version = "0.1.0" | |
| 12 | 77 | ||
| 13 | 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 | 137 | name = "cli" | |
| 15 | 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 | 152 | [[package]] | |
| 18 | 153 | name = "config" | |
| 19 | 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 | 190 | [[package]] | |
| 22 | 191 | name = "fabrica" | |
| @@ -26,29 +195,735 @@ dependencies = [ | |||
| 26 | 195 | ] | |
| 27 | 196 | ||
| 28 | 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 | 240 | name = "git" | |
| 30 | 241 | version = "0.1.0" | |
| 31 | 242 | ||
| 32 | 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 | 256 | name = "highlight" | |
| 34 | 257 | version = "0.1.0" | |
| 35 | 258 | ||
| 36 | 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 | 417 | name = "mail" | |
| 38 | 418 | version = "0.1.0" | |
| 39 | 419 | ||
| 40 | 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 | 427 | name = "model" | |
| 42 | 428 | version = "0.1.0" | |
| 43 | 429 | ||
| 44 | 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 | 627 | name = "ssh" | |
| 46 | 628 | version = "0.1.0" | |
| 47 | 629 | ||
| 48 | 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 | 637 | name = "store" | |
| 50 | 638 | version = "0.1.0" | |
| 51 | 639 | ||
| 52 | 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 | 809 | name = "web" | |
| 54 | 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 | 48 | api = { path = "crates/api" } | |
| 49 | 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 | 62 | [workspace.lints.rust] | |
| 52 | 63 | unsafe_code = "forbid" | |
| 53 | 64 | missing_docs = "warn" | |
crates/cli/Cargo.toml +4 −0
| @@ -10,6 +10,10 @@ repository.workspace = true | |||
| 10 | 10 | publish.workspace = true | |
| 11 | 11 | ||
| 12 | 12 | [dependencies] | |
| 13 | + | config = { workspace = true } | |
| 14 | + | anyhow = { workspace = true } | |
| 15 | + | clap = { workspace = true } | |
| 16 | + | serde_json = { workspace = true } | |
| 13 | 17 | ||
| 14 | 18 | [lints] | |
| 15 | 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 | 7 | //! Every subcommand operates directly on the store and filesystem; there is no | |
| 8 | 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 | 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 | 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 | |
| 16 | - | /// implementation phases; today this is a placeholder that exits successfully. | |
| 53 | + | /// Returns the process exit code. Argument-parse failures are handled by clap | |
| 54 | + | /// (which prints usage and exits); any other error is printed to stderr and | |
| 55 | + | /// mapped to [`ExitCode::FAILURE`]. | |
| 17 | 56 | #[must_use] | |
| 18 | 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 | 10 | publish.workspace = true | |
| 11 | 11 | ||
| 12 | 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 | 22 | [lints] | |
| 15 | 23 | workspace = true | |
crates/config/src/lib.rs +420 −0
| @@ -3,3 +3,423 @@ | |||
| 3 | 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | 4 | ||
| 5 | 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 | 85 | **Rationale:** flake-parts scales better as the flake grows (NixOS module, | |
| 86 | 86 | container image, multiple checks) and gives a cleaner multi-output structure. The | |
| 87 | 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 | |