OXYGENHUB

Coverage / crates/hub/src/config.rs 90.6%

279 of 308 lines run, 81 of 122 functions · run #464 on main · run missed

1
//! Configuration (hub spec §17.1): `hub.toml` in the data directory, every
2
//! key overridable by `OXYHUB_<SECTION>_<KEY>`.
3
4
use serde::Deserialize;
5
use std::net::SocketAddr;
6
use std::path::{Path, PathBuf};
7
8
#[derive(Clone, Debug, Deserialize)]
9
#[serde(default, deny_unknown_fields)]
10
pub struct Config {
11
    /// Set from the command line, never from the file.
12
    #[serde(skip)]
13
    pub data: PathBuf,
14
    pub site: Site,
15
    pub server: Server,
16
    pub tls: Tls,
17
    pub auth: Auth,
18
    pub registration: Registration,
19
    pub email: Email,
20
    pub limits: Limits,
21
    pub retention: Retention,
22
    pub flow: Flow,
23
    pub apt: Apt,
24
    pub diffusion: Diffusion,
25
    pub backups: Backups,
26
    pub outbound: Outbound,
27
    pub database: Database,
28
    pub tlog: Tlog,
29
}
30
31
/// Where a hub built with the `postgres` feature keeps its tables (#211); a
32
/// SQLite build keeps them in `<data>/hub.db` and ignores this.
33
#[derive(Clone, Debug, Default, Deserialize)]
34
#[serde(default, deny_unknown_fields)]
35
pub struct Database {
36
    /// `postgres://user@host/db`; the password from the URL or `PGPASSWORD`.
37
    pub url: String,
38
    /// The schema to use, made if missing; empty for `public`, `*` for one
39
    /// per data directory (named after it), as the tests use.
40
    pub schema: String,
41
}
42
43
/// Outbound requests to addresses people give (oxygenhub#219): private
44
/// and local addresses are refused unless listed here, as `scheme://host`
45
/// (any port) or `scheme://host:port`, e.g. a local Ollama:
46
/// `allow = ["http://127.0.0.1:11434"]`.
47
#[derive(Clone, Debug, Default, Deserialize)]
48
#[serde(default, deny_unknown_fields)]
49
pub struct Outbound {
50
    pub allow: Vec<String>,
51
}
52
53
/// The transparency log's witnesses (docs/transparency.md, #238): who
54
/// cosigns this hub's checkpoints, and whose logs this hub cosigns.
55
#[derive(Clone, Debug, Default, Deserialize)]
56
#[serde(default, deny_unknown_fields)]
57
pub struct Tlog {
58
    pub witnesses: Vec<TlogWitness>,
59
    pub witness_for: Vec<TlogWitnessed>,
60
}
61
62
/// A witness this hub's log asks: its name in cosignatures, its
63
/// add-checkpoint address (`https://HUB/log/witness/add-checkpoint` for
64
/// another hub), and its identity object in base64 (another hub's
65
/// `/.well-known/oxyhub-key`).
66
#[derive(Clone, Debug, Deserialize)]
67
#[serde(deny_unknown_fields)]
68
pub struct TlogWitness {
69
    pub name: String,
70
    pub url: String,
71
    pub key: String,
72
}
73
74
/// A log this hub witnesses: its origin (`HOST/log`) and its key, an
75
/// identity object in base64.
76
#[derive(Clone, Debug, Deserialize)]
77
#[serde(deny_unknown_fields)]
78
pub struct TlogWitnessed {
79
    pub origin: String,
80
    pub key: String,
81
}
82
83
/// Backups (docs/backups.md, oxygenhub#169): the directories on this host
84
/// an owner may back up to. Empty, only GCS destinations can be chosen.
85
#[derive(Clone, Debug, Deserialize)]
86
#[serde(default, deny_unknown_fields)]
87
pub struct Backups {
88
    pub directories: Vec<PathBuf>,
89
    /// Google's token exchange and Cloud Storage, for GCS destinations;
90
    /// set only to point them elsewhere (tests, a private endpoint).
91
    pub sts_url: String,
92
    pub storage_url: String,
93
    /// AWS STS and S3, for S3 destinations; `{bucket}` and `{region}` in
94
    /// `s3_url` are filled in. Set only to point them elsewhere.
95
    pub aws_sts_url: String,
96
    pub s3_url: String,
97
}
98
99
impl Default for Backups {
100194×
    fn default() -> Self {
101194×
        Backups {
102194×
            directories: Vec::new(),
103194×
            sts_url: "https://sts.googleapis.com".into(),
104194×
            storage_url: "https://storage.googleapis.com".into(),
105194×
            aws_sts_url: "https://sts.amazonaws.com".into(),
106194×
            s3_url: "https://{bucket}.s3.{region}.amazonaws.com".into(),
107194×
        }
108194×
    }
109
}
110
111
/// Diffusion (#148, docs/diffusion.md §4.2): the site's default for
112
/// repositories that say nothing.
113
#[derive(Clone, Debug, Deserialize)]
114
#[serde(default, deny_unknown_fields)]
115
pub struct Diffusion {
116
    /// `off`, `green` or `any`.
117
    pub follow: String,
118
}
119
120
impl Default for Diffusion {
121194×
    fn default() -> Self {
122194×
        Diffusion { follow: "off".into() }
123194×
    }
124
}
125
126
/// The apt repository at `/apt` (oxygenhub#85): the `.deb` assets of these
127
/// public repositories' releases, signed with the key in `gnupg_home`.
128
#[derive(Clone, Debug, Deserialize)]
129
#[serde(default, deny_unknown_fields)]
130
pub struct Apt {
131
    /// `owner/name` of each repository whose release `.deb`s are served;
132
    /// none turns `/apt` off.
133
    pub repos: Vec<String>,
134
    /// The suite apt's `sources` names.
135
    pub suite: String,
136
    pub component: String,
137
    /// GnuPG home holding the signing key (relative to the data directory).
138
    pub gnupg_home: String,
139
    /// Architectures the index lists, each with its Packages file.
140
    pub architectures: Vec<String>,
141
}
142
143
impl Default for Apt {
144194×
    fn default() -> Self {
145194×
        Apt {
146194×
            repos: vec![],
147194×
            suite: "stable".into(),
148194×
            component: "main".into(),
149194×
            gnupg_home: "apt-gnupg".into(),
150194×
            architectures: vec!["amd64".into(), "arm64".into(), "riscv64".into()],
151194×
        }
152194×
    }
153
}
154
155
/// OxygenFlow runs (hub spec §13.2).
156
#[derive(Clone, Debug, Deserialize)]
157
#[serde(default, deny_unknown_fields)]
158
pub struct Flow {
159
    /// Bearer tokens runners present to claim jobs.
160
    pub runner_tokens: Vec<String>,
161
    /// `owner/*` or `owner/name`: repositories whose pushes start runs.
162
    pub repos: Vec<String>,
163
    pub job_timeout_minutes: u64,
164
    /// A runner whose last this many jobs all ended in an infrastructure
165
    /// error (status `error`, not `failed`) is drained and the
166
    /// administrators told (oxygenflow#30); 0 never drains one.
167
    pub drain_after_errors: u32,
168
}
169
170
impl Default for Flow {
171194×
    fn default() -> Self {
172194×
        Flow { runner_tokens: vec![], repos: vec![], job_timeout_minutes: 60, drain_after_errors: 5 }
173194×
    }
174
}
175
176
impl Flow {
177
    /// Whether pushes to `owner/name` start runs.
178899×
    pub fn allows(&self, owner: &str, name: &str) -> bool {
179899×
        self.repos.iter().any(|p| match p.split_once('/') {
180403×
            Some((o, "*")) => o == owner,
1810×
            Some((o, n)) => o == owner && n == name,
1820×
            None => false,
183403×
        })
184899×
    }
185
}
186
187
#[derive(Clone, Debug, Default, Deserialize)]
188
#[serde(default, deny_unknown_fields)]
189
pub struct Email {
190
    /// `smtp://user:pass@host:port` or `smtps://…`; empty disables email.
191
    pub smtp: String,
192
    /// `OxygenHub <hub@example>`.
193
    pub from: String,
194
}
195
196
#[derive(Clone, Debug, Deserialize)]
197
#[serde(default, deny_unknown_fields)]
198
pub struct Registration {
199
    /// `open`, `invite` or `closed`; the admin page can override it.
200
    pub mode: String,
201
    /// The setup code the first account must bring (oxygenhub#245), for
202
    /// automation; empty, the hub makes one and keeps it in
203
    /// `<data>/setup-code`.
204
    pub setup_code: String,
205
}
206
207
impl Default for Registration {
208194×
    fn default() -> Self {
209194×
        Registration { mode: "invite".into(), setup_code: String::new() }
210194×
    }
211
}
212
213
#[derive(Clone, Debug, Deserialize)]
214
#[serde(default, deny_unknown_fields)]
215
pub struct Site {
216
    pub name: String,
217
    pub description: String,
218
}
219
220
#[derive(Clone, Debug, Deserialize)]
221
#[serde(default, deny_unknown_fields)]
222
pub struct Server {
223
    /// Plain HTTP; empty disables it.
224
    pub bind: String,
225
    /// HTTPS; empty disables it. Needs `tls`.
226
    pub tls_bind: String,
227
    /// What links and clone URLs start with.
228
    pub base_url: String,
229
    /// Trust `X-Forwarded-*` from a terminating proxy.
230
    pub behind_proxy: bool,
231
    /// Strict-Transport-Security's max-age in seconds, sent when
232
    /// `base_url` is https; 0 sends none (oxygenhub#231). Raise it in
233
    /// stages: browsers keep it, so it is hard to take back.
234
    pub hsts_max_age: u64,
235
    /// The bearer token `/metrics` asks for (oxygenhub#212); empty, the
236
    /// metrics are off.
237
    pub metrics_token: String,
238
    /// A Grafana for this hub's metrics, linked from Insights (oxygenhub#257)
239
    /// for charts of one's own; empty, no link.
240
    pub grafana_url: String,
241
    /// What this process does (docs/roles.md, #272): `web` (pages, the
242
    /// API, the wire protocol, the runner API) and `worker` (the background
243
    /// tasks). Empty, both. `serve --role` overrides it.
244
    pub roles: Vec<String>,
245
}
246
247
/// One part of the hub's work a process can take (docs/roles.md, #272).
248
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
249
pub enum Role {
250
    /// Pages, the JSON API, the wire protocol, live pages and the runner
251
    /// API, and the transparency log they append to.
252
    Web,
253
    /// The background tasks: webhooks, Flow's scheduler, reaper and
254
    /// placement pass, nightly upkeep, backups and Diffusion's index.
255
    Worker,
256
}
257
258
impl Role {
259
    pub const ALL: [Role; 2] = [Role::Web, Role::Worker];
260
26114×
    pub fn name(self) -> &'static str {
26214×
        match self {
2637×
            Role::Web => "web",
2647×
            Role::Worker => "worker",
265
        }
26614×
    }
267
}
268
269
impl std::str::FromStr for Role {
270
    type Err = String;
271
27217×
    fn from_str(s: &str) -> Result<Role, String> {
27317×
        match s.trim().to_ascii_lowercase().as_str() {
27417×
            "web" => Ok(Role::Web),
2759×
            "worker" => Ok(Role::Worker),
2764×
            _ => Err(format!("unknown role {:?}: the roles are web and worker", s.trim())),
277
        }
27817×
    }
279
}
280
281
/// The roles one process takes: every role unless some are named.
282
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
283
pub struct Roles {
284
    pub web: bool,
285
    pub worker: bool,
286
}
287
288
impl Default for Roles {
289381×
    fn default() -> Self {
290381×
        Roles { web: true, worker: true }
291381×
    }
292
}
293
294
impl Roles {
295
    /// The roles named, each `web` or `worker`; none named is every role.
296391×
    pub fn from_names<S: AsRef<str>>(names: &[S]) -> Result<Roles, String> {
297391×
        let names: Vec<&str> = names.iter().map(|s| s.as_ref().trim()).filter(|s| !s.is_empty()).collect();
298391×
        if names.is_empty() {
299379×
            return Ok(Roles::default());
30012×
        }
30112×
        let mut roles = Roles { web: false, worker: false };
30217×
        for n in names {
30317×
            match n.parse::<Role>()? {
3048×
                Role::Web => roles.web = true,
3055×
                Role::Worker => roles.worker = true,
306
            }
307
        }
3088×
        Ok(roles)
309391×
    }
310
31118×
    pub fn has(self, role: Role) -> bool {
31218×
        match role {
3139×
            Role::Web => self.web,
3149×
            Role::Worker => self.worker,
315
        }
31618×
    }
317
318
    /// The roles' names, in a fixed order: what `/health` reports.
3199×
    pub fn names(self) -> Vec<&'static str> {
32018×
        Role::ALL.into_iter().filter(|r| self.has(*r)).map(Role::name).collect()
3219×
    }
322
}
323
324
#[derive(Clone, Debug, Deserialize)]
325
#[serde(default, deny_unknown_fields)]
326
pub struct Tls {
327
    /// Generate a certificate at `init` (developer edition).
328
    pub self_signed: bool,
329
    /// PEM files; relative paths are under the data directory.
330
    pub cert: String,
331
    pub key: String,
332
}
333
334
#[derive(Clone, Debug, Default, Deserialize)]
335
#[serde(default, deny_unknown_fields)]
336
pub struct Auth {
337
    /// Bearer tokens allowed to push until accounts exist (hub 0.2). Empty
338
    /// means every push is accepted, as `oxy serve` does.
339
    pub push_tokens: Vec<String>,
340
    /// For tests only: a base URL that stands in for every sign-in
341
    /// provider (`{base}/{provider}/authorize|token|userinfo`).
342
    pub signin_mock: Option<String>,
343
}
344
345
#[derive(Clone, Debug, Deserialize)]
346
#[serde(default, deny_unknown_fields)]
347
pub struct Limits {
348
    /// Largest file rendered in the browser, e.g. `2MiB`.
349
    pub render_limit: ByteSize,
350
    pub asset_limit: ByteSize,
351
    pub push_body_limit: ByteSize,
352
    /// Push protection's budget (oxygenhub#268): what one push may have
353
    /// scanned, the largest file or inserted text scanned, and how long
354
    /// the scan may take. Past them the rest goes unscanned.
355
    pub scan_limit: ByteSize,
356
    pub scan_file_limit: ByteSize,
357
    pub scan_time_ms: u64,
358
    /// The history scan's budget (oxygenhub#277): what one pass over one
359
    /// repository may scan, and for how long, before it moves on to the
360
    /// next and resumes there later. A limit of 0 turns the scan off.
361
    pub history_scan_limit: ByteSize,
362
    pub history_scan_time_ms: u64,
363
}
364
365
#[derive(Clone, Debug, Deserialize)]
366
#[serde(default, deny_unknown_fields)]
367
pub struct Retention {
368
    pub trash_days: u32,
369
    pub webhook_log_days: u32,
370
    /// The UTC hour at which every repository is garbage collected
371
    /// (spec §17.2); 24 or more turns the nightly gc off.
372
    pub gc_hour: u32,
373
}
374
375
/// A byte count written as `512`, `2MiB`, `4GiB`, `100kB`.
376
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
377
pub struct ByteSize(pub u64);
378
379
impl ByteSize {
3806×
    pub fn parse(s: &str) -> Option<ByteSize> {
3816×
        let s = s.trim();
38214×
        let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
3836×
        let unit = s[digits.len()..].trim();
3846×
        let n: u64 = digits.parse().ok()?;
3855×
        let mult = match unit {
3865×
            "" | "B" => 1,
3874×
            "k" | "kB" | "KB" => 1000,
3883×
            "KiB" | "K" => 1 << 10,
3893×
            "M" | "MB" => 1_000_000,
3903×
            "MiB" => 1 << 20,
3911×
            "G" | "GB" => 1_000_000_000,
3921×
            "GiB" => 1 << 30,
3930×
            _ => return None,
394
        };
3955×
        Some(ByteSize(n.checked_mul(mult)?))
3966×
    }
397
}
398
399
impl<'de> Deserialize<'de> for ByteSize {
4001×
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<ByteSize, D::Error> {
401
        #[derive(Deserialize)]
402
        #[serde(untagged)]
403
        enum Raw {
404
            Number(u64),
405
            Text(String),
406
        }
4071×
        match Raw::deserialize(d)? {
4080×
            Raw::Number(n) => Ok(ByteSize(n)),
4091×
            Raw::Text(t) => ByteSize::parse(&t).ok_or_else(|| serde::de::Error::custom(format!("not a byte size: {t:?}"))),
410
        }
4111×
    }
412
}
413
414
impl Default for Site {
415194×
    fn default() -> Self {
416194×
        Site { name: "OxygenHub".into(), description: "Repositories, proposals and issues for Oxygen".into() }
417194×
    }
418
}
419
420
impl Default for Server {
421197×
    fn default() -> Self {
422197×
        Server {
423197×
            bind: "127.0.0.1:7300".into(),
424197×
            tls_bind: String::new(),
425197×
            base_url: "http://127.0.0.1:7300".into(),
426197×
            behind_proxy: false,
427197×
            hsts_max_age: 0,
428197×
            metrics_token: String::new(),
429197×
            grafana_url: String::new(),
430197×
            roles: Vec::new(),
431197×
        }
432197×
    }
433
}
434
435
impl Default for Tls {
436194×
    fn default() -> Self {
437194×
        Tls { self_signed: false, cert: "keys/tls.crt".into(), key: "keys/tls.key".into() }
438194×
    }
439
}
440
441
impl Default for Limits {
442195×
    fn default() -> Self {
443195×
        let scan = crate::push_protection::Budget::default();
444195×
        let history = crate::history_scan::Budget::default();
445195×
        Limits {
446195×
            render_limit: ByteSize(2 << 20),
447195×
            asset_limit: ByteSize(512 << 20),
448195×
            push_body_limit: ByteSize(4 << 30),
449195×
            scan_limit: ByteSize(scan.bytes),
450195×
            scan_file_limit: ByteSize(scan.file),
451195×
            scan_time_ms: scan.time.as_millis() as u64,
452195×
            history_scan_limit: ByteSize(history.bytes),
453195×
            history_scan_time_ms: history.time.as_millis() as u64,
454195×
        }
455195×
    }
456
}
457
458
impl Default for Retention {
459194×
    fn default() -> Self {
460194×
        Retention { trash_days: 30, webhook_log_days: 30, gc_hour: 3 }
461194×
    }
462
}
463
464
impl Default for Config {
465194×
    fn default() -> Self {
466194×
        Config {
467194×
            data: PathBuf::from("data"),
468194×
            site: Site::default(),
469194×
            server: Server::default(),
470194×
            tls: Tls::default(),
471194×
            auth: Auth::default(),
472194×
            registration: Registration::default(),
473194×
            email: Email::default(),
474194×
            limits: Limits::default(),
475194×
            retention: Retention::default(),
476194×
            flow: Flow::default(),
477194×
            apt: Apt::default(),
478194×
            diffusion: Diffusion::default(),
479194×
            backups: Backups::default(),
480194×
            outbound: Outbound::default(),
481194×
            database: Database::default(),
482194×
            tlog: Tlog::default(),
483194×
        }
484194×
    }
485
}
486
487
impl Config {
488
    /// Read `<data>/hub.toml` if present, then apply environment overrides.
489191×
    pub fn load(data: &Path) -> anyhow::Result<Config> {
490191×
        let path = data.join("hub.toml");
491191×
        let mut cfg = match std::fs::read_to_string(&path) {
4921×
            Ok(text) => Config::parse(&text)?,
493190×
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Config::default(),
4940×
            Err(e) => return Err(anyhow::anyhow!("reading {}: {e}", path.display())),
495
        };
496191×
        cfg.data = data.to_path_buf();
4973247×
        cfg.apply_env(|k| std::env::var(k).ok());
498191×
        cfg.roles()?;
499190×
        Ok(cfg)
500191×
    }
501
5024×
    pub fn parse(text: &str) -> anyhow::Result<Config> {
5034×
        Ok(toml::from_str(text)?)
5044×
    }
505
506
    /// `OXYHUB_SERVER_BIND`, `OXYHUB_SITE_NAME`, … override the file.
507194×
    pub fn apply_env(&mut self, get: impl Fn(&str) -> Option<String>) {
508194×
        if let Some(v) = get("OXYHUB_DATABASE_URL") {
5090×
            self.database.url = v;
510194×
        }
511194×
        if let Some(v) = get("OXYHUB_DATABASE_SCHEMA") {
5120×
            self.database.schema = v;
513194×
        }
514194×
        if let Some(v) = get("OXYHUB_SITE_NAME") {
5151×
            self.site.name = v;
516193×
        }
517194×
        if let Some(v) = get("OXYHUB_SITE_DESCRIPTION") {
5180×
            self.site.description = v;
519194×
        }
520194×
        if let Some(v) = get("OXYHUB_SERVER_BIND") {
5210×
            self.server.bind = v;
522194×
        }
523194×
        if let Some(v) = get("OXYHUB_SERVER_TLS_BIND") {
5240×
            self.server.tls_bind = v;
525194×
        }
526194×
        if let Some(v) = get("OXYHUB_SERVER_BASE_URL") {
5270×
            self.server.base_url = v;
528194×
        }
529194×
        if let Some(v) = get("OXYHUB_SERVER_BEHIND_PROXY") {
5300×
            self.server.behind_proxy = matches!(v.as_str(), "1" | "true" | "yes");
531194×
        }
532194×
        if let Some(v) = get("OXYHUB_SERVER_HSTS_MAX_AGE") {
5330×
            self.server.hsts_max_age = v.parse().unwrap_or(0);
534194×
        }
535
        // The VM's setup gives these (oxygenhub-gcp#12): the token its
536
        // collector scrapes /metrics with, and its Grafana for Insights' link.
537194×
        if let Some(v) = get("OXYHUB_SERVER_METRICS_TOKEN") {
5381×
            self.server.metrics_token = v;
539193×
        }
540194×
        if let Some(v) = get("OXYHUB_SERVER_GRAFANA_URL") {
5411×
            self.server.grafana_url = v;
542193×
        }
543194×
        if let Some(v) = get("OXYHUB_SERVER_ROLES") {
5443×
            self.server.roles = v.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
545192×
        }
546194×
        if let Some(v) = get("OXYHUB_TLS_SELF_SIGNED") {
5470×
            self.tls.self_signed = matches!(v.as_str(), "1" | "true" | "yes");
548194×
        }
549194×
        if let Some(v) = get("OXYHUB_REGISTRATION_MODE") {
5500×
            self.registration.mode = v;
551194×
        }
552194×
        if let Some(v) = get("OXYHUB_REGISTRATION_SETUP_CODE") {
5530×
            self.registration.setup_code = v;
554194×
        }
555194×
        if let Some(v) = get("OXYHUB_AUTH_PUSH_TOKENS") {
5562×
            self.auth.push_tokens = v.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
557193×
        }
558194×
        if let Some(v) = get("OXYHUB_LIMITS_RENDER_LIMIT").and_then(|v| ByteSize::parse(&v)) {
5590×
            self.limits.render_limit = v;
560194×
        }
561194×
    }
562
563
    /// This process's roles (`[server] roles`); an unknown name is an error.
564386×
    pub fn roles(&self) -> anyhow::Result<Roles> {
565386×
        Roles::from_names(&self.server.roles).map_err(|e| anyhow::anyhow!("[server] roles: {e}"))
566386×
    }
567
5681×
    pub fn bind_addr(&self) -> anyhow::Result<Option<SocketAddr>> {
5691×
        parse_addr(&self.server.bind)
5701×
    }
571
5721×
    pub fn tls_bind_addr(&self) -> anyhow::Result<Option<SocketAddr>> {
5731×
        parse_addr(&self.server.tls_bind)
5741×
    }
575
5760×
    pub fn tls_cert(&self) -> PathBuf {
5770×
        self.data.join(&self.tls.cert)
5780×
    }
579
5800×
    pub fn tls_key(&self) -> PathBuf {
5810×
        self.data.join(&self.tls.key)
5820×
    }
583
584378×
    pub fn repos_dir(&self) -> PathBuf {
585378×
        self.data.join("repos")
586378×
    }
587
5881258×
    pub fn keys_dir(&self) -> PathBuf {
5891258×
        self.data.join("keys")
5901258×
    }
591
592189×
    pub fn db_path(&self) -> PathBuf {
593189×
        self.data.join("hub.db")
594189×
    }
595
596
    /// The base URL without a trailing slash.
5979414×
    pub fn base_url(&self) -> &str {
5989414×
        self.server.base_url.trim_end_matches('/')
5999414×
    }
600
}
601
6022×
fn parse_addr(s: &str) -> anyhow::Result<Option<SocketAddr>> {
6032×
    if s.trim().is_empty() {
6041×
        return Ok(None);
6051×
    }
6061×
    Ok(Some(s.parse().map_err(|e| anyhow::anyhow!("bind address {s:?}: {e}"))?))
6072×
}
608
609
#[cfg(test)]
610
mod tests {
611
    use super::*;
612
613
    #[test]
6141×
    fn byte_sizes() {
6151×
        assert_eq!(ByteSize::parse("2MiB"), Some(ByteSize(2 << 20)));
6161×
        assert_eq!(ByteSize::parse("512"), Some(ByteSize(512)));
6171×
        assert_eq!(ByteSize::parse("4 GiB"), Some(ByteSize(4 << 30)));
6181×
        assert_eq!(ByteSize::parse("100kB"), Some(ByteSize(100_000)));
6191×
        assert_eq!(ByteSize::parse("lots"), None);
6201×
    }
621
622
    #[test]
6231×
    fn file_then_environment() {
6241×
        let mut cfg = Config::parse("[server]\nbind = \"0.0.0.0:7300\"\n[limits]\nrender_limit = \"1MiB\"\n").unwrap();
6251×
        assert_eq!(cfg.server.bind, "0.0.0.0:7300");
6261×
        assert_eq!(cfg.limits.render_limit, ByteSize(1 << 20));
6271×
        assert_eq!(cfg.site.name, "OxygenHub");
62817×
        cfg.apply_env(|k| match k {
62917×
            "OXYHUB_SITE_NAME" => Some("Earth".into()),
63016×
            "OXYHUB_AUTH_PUSH_TOKENS" => Some("a, b".into()),
63115×
            "OXYHUB_SERVER_METRICS_TOKEN" => Some("m".into()),
63214×
            "OXYHUB_SERVER_GRAFANA_URL" => Some("https://grafana.example".into()),
63313×
            _ => None,
63417×
        });
6351×
        assert_eq!(cfg.site.name, "Earth");
6361×
        assert_eq!(cfg.auth.push_tokens, ["a", "b"]);
6371×
        assert_eq!((cfg.server.metrics_token.as_str(), cfg.server.grafana_url.as_str()), ("m", "https://grafana.example"));
6381×
        assert!(Config::parse("[nope]\nx = 1\n").is_err(), "unknown sections are errors, not silence");
6391×
    }
640
641
    #[test]
6421×
    fn roles_default_to_all_and_unknown_names_are_errors() {
6431×
        let cfg = Config::default();
6441×
        assert_eq!(cfg.roles().unwrap(), Roles { web: true, worker: true });
6451×
        assert_eq!(cfg.roles().unwrap().names(), ["web", "worker"]);
6461×
        assert_eq!(Roles::from_names(&["worker"]).unwrap(), Roles { web: false, worker: true });
6471×
        assert_eq!(Roles::from_names(&[" Web ", "web"]).unwrap().names(), ["web"]);
6481×
        assert_eq!(Roles::from_names(&["worker", "web"]).unwrap().names(), ["web", "worker"]);
6491×
        assert_eq!(Roles::from_names::<&str>(&[]).unwrap(), Roles::default());
6501×
        let e = Roles::from_names(&["web", "edge"]).unwrap_err();
6511×
        assert_eq!(e, "unknown role \"edge\": the roles are web and worker");
652
6531×
        let mut cfg = Config::parse("[server]\nroles = [\"worker\"]\n").unwrap();
6541×
        assert_eq!(cfg.roles().unwrap().names(), ["worker"]);
65517×
        cfg.apply_env(|k| (k == "OXYHUB_SERVER_ROLES").then(|| "web, worker".into()));
6561×
        assert_eq!(cfg.roles().unwrap(), Roles::default());
65717×
        cfg.apply_env(|k| (k == "OXYHUB_SERVER_ROLES").then(|| "store".into()));
6581×
        assert!(cfg.roles().unwrap_err().to_string().contains("unknown role \"store\""));
6591×
    }
660
661
    #[test]
6621×
    fn an_unknown_role_in_hub_toml_stops_loading() {
6631×
        let dir = tempfile::tempdir().unwrap();
6641×
        std::fs::write(dir.path().join("hub.toml"), "[server]\nroles = [\"wrker\"]\n").unwrap();
6651×
        let e = Config::load(dir.path()).unwrap_err().to_string();
6661×
        assert!(e.contains("unknown role \"wrker\""), "{e}");
6671×
    }
668
}