fabrica

hanna/fabrica

fix(config): tolerate a not-yet-generated auth.secret_file

dad15c0 · hanna committed on 2026-07-26

The example config sets `auth.secret_file` and documents it as "generated on
first run", but config load hard-read the file and errored when it was missing —
so a fresh container (empty volume) crash-looped with "failed to read secret
file" before `ensure_secret` could create it. Treat a NotFound secret_file as
absent (the server generates and persists one); a present-but-unreadable file
stays a hard error, and a missing mail password_file still errors.

Also create /var/lib/fabrica owned by the fabrica user in the image so first-run
generation (signing secret, SQLite database, repos) can actually write to it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
2 files changed · +30 −7UnifiedSplit
Dockerfile +5 −1
@@ -39,12 +39,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
3939 && rm -rf /var/lib/apt/lists/* \
4040 && git --version
4141
42-RUN useradd --uid 10001 --system --home-dir /var/lib/fabrica --shell /usr/sbin/nologin fabrica
42+RUN useradd --uid 10001 --system --home-dir /var/lib/fabrica --shell /usr/sbin/nologin fabrica \
43+ && install -d -o fabrica -g fabrica -m 0750 /var/lib/fabrica
4344
4445 COPY --from=builder /build/target/release/fabrica /usr/local/bin/fabrica
4546 RUN fabrica --version && git --version
4647
4748 USER fabrica
49+# The data directory is created and owned by the fabrica user above so first-run
50+# generation (signing secret, SQLite database, repos) can write to it. A bind
51+# mount overrides this ownership — chown it to uid 10001 on the host.
4852 VOLUME /var/lib/fabrica
4953 EXPOSE 8080 2222
5054
crates/config/src/lib.rs +25 −6
@@ -139,7 +139,16 @@ fn xdg_config_path() -> Option<PathBuf> {
139139 /// Read any `*_file` secrets and fold them onto their inline siblings.
140140 fn resolve_secret_files(config: &mut Config) -> Result<(), ConfigError> {
141141 if let Some(path) = &config.auth.secret_file {
142- config.auth.secret = Some(read_secret(path)?);
142+ // The HS256 signing key is generated on first run (see the CLI's
143+ // `ensure_secret`), so a not-yet-created file is expected and fine — the
144+ // inline secret stays `None` and the server writes one. Only a file that
145+ // exists but cannot be read (e.g. permissions) is a hard error.
146+ match read_secret(path) {
147+ Ok(secret) => config.auth.secret = Some(secret),
148+ Err(ConfigError::SecretFile { source, .. })
149+ if source.kind() == std::io::ErrorKind::NotFound => {}
150+ Err(e) => return Err(e),
151+ }
143152 }
144153 if let Some(path) = &config.mail.password_file {
145154 config.mail.password = Some(read_secret(path)?);
@@ -453,12 +462,22 @@ mod tests {
453462 }
454463
455464 #[test]
456- fn missing_secret_file_errors() {
457- let err = from_toml("[auth]\nsecret_file = \"/no/such/fabrica/secret\"\n").unwrap_err();
465+ fn missing_auth_secret_file_is_tolerated() {
466+ // The signing key is generated on first run, so a not-yet-created
467+ // secret_file must not fail config load — the inline secret stays unset
468+ // and the server writes one.
469+ let loaded = from_toml("[auth]\nsecret_file = \"/no/such/fabrica/secret\"\n").unwrap();
470+ assert!(loaded.config.auth.secret.is_none());
471+ }
472+
473+ #[test]
474+ fn missing_mail_password_file_errors() {
475+ // A mail password file is operator-provided, not generated, so a missing
476+ // one is a real misconfiguration.
477+ let err =
478+ from_toml("[mail]\npassword_file = \"/no/such/fabrica/mail-pass\"\n").unwrap_err();
458479 match err {
459- ConfigError::SecretFile { path, .. } => {
460- assert!(path.ends_with("secret"));
461- }
480+ ConfigError::SecretFile { path, .. } => assert!(path.ends_with("mail-pass")),
462481 other => panic!("expected SecretFile, got {other:?}"),
463482 }
464483 }