@jpgilldev / steid

5.4 KBRaw
1use std::{fmt, path::PathBuf};
2
3use serde::Deserialize;
4
5/// Application configuration, read from `STEID_`-prefixed environment variables.
6///
7/// The bind address is deliberately absent: Topcoat owns it via `HOST` and `PORT`.
8#[derive(Debug, Clone, Deserialize)]
9pub struct AppConfig {
10 /// SQLite connection string. Needs `mode=rwc` to create the file on first boot.
11 #[serde(default = "default_database_url")]
12 pub database_url: String,
13
14 /// Root directory for bare git repositories, laid out as
15 /// `{data_dir}/{handle}/{name}.git`.
16 ///
17 /// Keyed by handle rather than `OrgId` so the directory is legible to anyone
18 /// debugging it; the cost is that renaming a handle becomes a directory move.
19 #[serde(default = "default_data_dir")]
20 pub data_dir: PathBuf,
21
22 /// Drops `Secure` and the `__Host-` prefix from the session cookie so it survives
23 /// plain-HTTP `localhost`.
24 ///
25 /// Development only. With this on, the session cookie travels unencrypted and
26 /// anyone on the path can lift it.
27 #[serde(default)]
28 pub insecure_cookies: bool,
29
30 /// A setup token supplied by the operator instead of one generated at boot.
31 ///
32 /// Only ever consulted while the instance is unclaimed — a claimed instance holds
33 /// no token at all, so this is inert the moment an owner exists. It exists so a
34 /// scripted install can claim an instance without racing `journalctl` for the
35 /// generated token, and it is validated by `SetupToken::from_operator` before it
36 /// is used: a weak value fails startup rather than being accepted.
37 ///
38 /// This does not reopen `plans/decisions/0002`. What that decision refused to put
39 /// in configuration is the owner's *password* — a long-lived credential that goes
40 /// stale the moment it is changed in the app. This is the one-time claim secret,
41 /// which is dead as soon as it has been used once and which the operator was
42 /// already reading out of the log by hand.
43 ///
44 /// [`Secret`] keeps it out of any `Debug` rendering of the config, and `main`
45 /// takes it before the config reaches the app context.
46 #[serde(default)]
47 pub setup_token: Option<Secret>,
48}
49
50/// A configured value that must not reach a log line.
51///
52/// `AppConfig` is `Debug`, lives in the app context, and is one `dbg!` away from
53/// standard error; the redacted `Debug` is what makes that safe.
54#[derive(Clone, Deserialize)]
55pub struct Secret(String);
56
57impl Secret {
58 /// The value itself, at the one place that needs it.
59 #[must_use]
60 pub fn expose(&self) -> &str {
61 &self.0
62 }
63}
64
65impl fmt::Debug for Secret {
66 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67 f.write_str("Secret(redacted)")
68 }
69}
70
71fn default_database_url() -> String {
72 "sqlite:steid.db?mode=rwc".to_owned()
73}
74
75fn default_data_dir() -> PathBuf {
76 PathBuf::from("./data")
77}
78
79impl AppConfig {
80 /// Loads configuration from the environment, applying defaults for anything unset.
81 pub fn from_env() -> Result<Self, envy::Error> {
82 envy::prefixed("STEID_").from_env()
83 }
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89
90 /// Builds a config from an explicit iterator rather than the process environment,
91 /// so tests don't race on shared global state.
92 fn from_pairs(pairs: &[(&str, &str)]) -> Result<AppConfig, envy::Error> {
93 envy::prefixed("STEID_").from_iter(
94 pairs
95 .iter()
96 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())),
97 )
98 }
99
100 #[test]
101 fn applies_defaults_when_environment_is_empty() {
102 let config = from_pairs(&[]).expect("defaults should satisfy every field");
103
104 assert_eq!(config.database_url, "sqlite:steid.db?mode=rwc");
105 assert_eq!(config.data_dir, PathBuf::from("./data"));
106 }
107
108 #[test]
109 fn reads_prefixed_variables() {
110 let config = from_pairs(&[
111 ("STEID_DATABASE_URL", "sqlite:custom.db?mode=rwc"),
112 ("STEID_DATA_DIR", "/srv/steid/repos"),
113 ])
114 .expect("explicit values should parse");
115
116 assert_eq!(config.database_url, "sqlite:custom.db?mode=rwc");
117 assert_eq!(config.data_dir, PathBuf::from("/srv/steid/repos"));
118 }
119
120 #[test]
121 fn a_setup_token_is_absent_unless_configured() {
122 let config = from_pairs(&[]).expect("defaults should satisfy every field");
123
124 assert!(config.setup_token.is_none());
125 }
126
127 #[test]
128 fn reads_the_setup_token_from_the_environment() {
129 let config = from_pairs(&[("STEID_SETUP_TOKEN", "b2a9c17e4d5f80316a7c9e2b4d8f0135")])
130 .expect("an explicit token should parse");
131
132 assert_eq!(
133 config.setup_token.as_ref().map(Secret::expose),
134 Some("b2a9c17e4d5f80316a7c9e2b4d8f0135")
135 );
136 }
137
138 #[test]
139 fn a_configured_setup_token_is_redacted_in_debug_output() {
140 let secret = "b2a9c17e4d5f80316a7c9e2b4d8f0135";
141 let config = from_pairs(&[("STEID_SETUP_TOKEN", secret)]).expect("an explicit token");
142
143 let rendered = format!("{config:?}");
144
145 assert!(!rendered.contains(secret), "{rendered}");
146 assert!(rendered.contains("Secret(redacted)"), "{rendered}");
147 }
148
149 #[test]
150 fn ignores_unprefixed_variables() {
151 let config = from_pairs(&[("DATA_DIR", "/should/be/ignored")])
152 .expect("unprefixed keys should not interfere");
153
154 assert_eq!(config.data_dir, PathBuf::from("./data"));
155 }
156}