OXYGENHUB

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

290 of 320 lines run, 83 of 124 functions · run #545 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
    pub push: Push,
30
}
31
32
/// Where a hub built with the `postgres` feature keeps its tables (#211); a
33
/// SQLite build keeps them in `<data>/hub.db` and ignores this.
34
#[derive(Clone, Debug, Default, Deserialize)]
35
#[serde(default, deny_unknown_fields)]
36
pub struct Database {
37
    /// `postgres://user@host/db`; the password from the URL or `PGPASSWORD`.
38
    pub url: String,
39
    /// The schema to use, made if missing; empty for `public`, `*` for one
40
    /// per data directory (named after it), as the tests use.
41
    pub schema: String,
42
}
43
44
/// Outbound requests to addresses people give (oxygenhub#219): private
45
/// and local addresses are refused unless listed here, as `scheme://host`
46
/// (any port) or `scheme://host:port`, e.g. a local Ollama:
47
/// `allow = ["http://127.0.0.1:11434"]`.
48
#[derive(Clone, Debug, Default, Deserialize)]
49
#[serde(default, deny_unknown_fields)]
50
pub struct Outbound {
51
    pub allow: Vec<String>,
52
}
53
54
/// Phone alerts (oxygenhub-android#16, oxygenhub-gcp decision 0008): a
55
/// notification is also sent, sealed to each phone's key, through Firebase
56
/// Cloud Messaging. Off while `fcm_project` is empty. The hub sends as the
57
/// service account its VM runs as, whose token the metadata server gives:
58
/// no key.
59
#[derive(Clone, Debug, Deserialize)]
60
#[serde(default, deny_unknown_fields)]
61
pub struct Push {
62
    /// The Firebase (GCP) project, `OXYHUB_PUSH_FCM_PROJECT`.
63
    pub fcm_project: String,
64
    /// Cloud Messaging and the token source; set only to point them
65
    /// elsewhere (tests).
66
    pub fcm_url: String,
67
    pub token_url: String,
68
    /// The Android apps this hub's links open (App Links, #370), each
69
    /// `PACKAGE:SHA256` with the release key's certificate fingerprint as
70
    /// colon-separated hex; served at `/.well-known/assetlinks.json`. The
71
    /// official app by default; empty for none.
72
    pub android_apps: Vec<String>,
73
}
74
75
/// The OxygenHub Android app's package and its release key's SHA-256
76
/// certificate fingerprint (oxygenhub-android CHANGELOG, 0.1.0).
77
pub const OFFICIAL_ANDROID_APP: &str =
78
    "io.oxygenhub.android:B0:99:57:36:44:42:AF:B2:27:28:B7:EF:93:F8:23:2D:F1:41:17:DA:50:28:6D:45:93:04:0F:9C:9F:A2:2A:48";
79
80
impl Default for Push {
81213×
    fn default() -> Self {
82213×
        Push {
83213×
            fcm_project: String::new(),
84213×
            fcm_url: "https://fcm.googleapis.com".into(),
85213×
            token_url: "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token".into(),
86213×
            android_apps: vec![OFFICIAL_ANDROID_APP.into()],
87213×
        }
88213×
    }
89
}
90
91
/// The transparency log's witnesses (docs/transparency.md, #238): who
92
/// cosigns this hub's checkpoints, and whose logs this hub cosigns.
93
#[derive(Clone, Debug, Default, Deserialize)]
94
#[serde(default, deny_unknown_fields)]
95
pub struct Tlog {
96
    pub witnesses: Vec<TlogWitness>,
97
    pub witness_for: Vec<TlogWitnessed>,
98
}
99
100
/// A witness this hub's log asks: its name in cosignatures, its
101
/// add-checkpoint address (`https://HUB/log/witness/add-checkpoint` for
102
/// another hub), and its identity object in base64 (another hub's
103
/// `/.well-known/oxyhub-key`).
104
#[derive(Clone, Debug, Deserialize)]
105
#[serde(deny_unknown_fields)]
106
pub struct TlogWitness {
107
    pub name: String,
108
    pub url: String,
109
    pub key: String,
110
}
111
112
/// A log this hub witnesses: its origin (`HOST/log`) and its key, an
113
/// identity object in base64.
114
#[derive(Clone, Debug, Deserialize)]
115
#[serde(deny_unknown_fields)]
116
pub struct TlogWitnessed {
117
    pub origin: String,
118
    pub key: String,
119
}
120
121
/// Backups (docs/backups.md, oxygenhub#169): the directories on this host
122
/// an owner may back up to. Empty, only GCS destinations can be chosen.
123
#[derive(Clone, Debug, Deserialize)]
124
#[serde(default, deny_unknown_fields)]
125
pub struct Backups {
126
    pub directories: Vec<PathBuf>,
127
    /// Google's token exchange and Cloud Storage, for GCS destinations;
128
    /// set only to point them elsewhere (tests, a private endpoint).
129
    pub sts_url: String,
130
    pub storage_url: String,
131
    /// AWS STS and S3, for S3 destinations; `{bucket}` and `{region}` in
132
    /// `s3_url` are filled in. Set only to point them elsewhere.
133
    pub aws_sts_url: String,
134
    pub s3_url: String,
135
}
136
137
impl Default for Backups {
138213×
    fn default() -> Self {
139213×
        Backups {
140213×
            directories: Vec::new(),
141213×
            sts_url: "https://sts.googleapis.com".into(),
142213×
            storage_url: "https://storage.googleapis.com".into(),
143213×
            aws_sts_url: "https://sts.amazonaws.com".into(),
144213×
            s3_url: "https://{bucket}.s3.{region}.amazonaws.com".into(),
145213×
        }
146213×
    }
147
}
148
149
/// Diffusion (#148, docs/diffusion.md §4.2): the site's default for
150
/// repositories that say nothing.
151
#[derive(Clone, Debug, Deserialize)]
152
#[serde(default, deny_unknown_fields)]
153
pub struct Diffusion {
154
    /// `off`, `green` or `any`.
155
    pub follow: String,
156
}
157
158
impl Default for Diffusion {
159213×
    fn default() -> Self {
160213×
        Diffusion { follow: "off".into() }
161213×
    }
162
}
163
164
/// The apt repository at `/apt` (oxygenhub#85): the `.deb` assets of these
165
/// public repositories' releases, signed with the key in `gnupg_home`.
166
#[derive(Clone, Debug, Deserialize)]
167
#[serde(default, deny_unknown_fields)]
168
pub struct Apt {
169
    /// `owner/name` of each repository whose release `.deb`s are served;
170
    /// none turns `/apt` off.
171
    pub repos: Vec<String>,
172
    /// The suite apt's `sources` names.
173
    pub suite: String,
174
    pub component: String,
175
    /// GnuPG home holding the signing key (relative to the data directory).
176
    pub gnupg_home: String,
177
    /// Architectures the index lists, each with its Packages file.
178
    pub architectures: Vec<String>,
179
}
180
181
impl Default for Apt {
182213×
    fn default() -> Self {
183213×
        Apt {
184213×
            repos: vec![],
185213×
            suite: "stable".into(),
186213×
            component: "main".into(),
187213×
            gnupg_home: "apt-gnupg".into(),
188213×
            architectures: vec!["amd64".into(), "arm64".into(), "riscv64".into()],
189213×
        }
190213×
    }
191
}
192
193
/// OxygenFlow runs (hub spec §13.2).
194
#[derive(Clone, Debug, Deserialize)]
195
#[serde(default, deny_unknown_fields)]
196
pub struct Flow {
197
    /// Bearer tokens runners present to claim jobs.
198
    pub runner_tokens: Vec<String>,
199
    /// `owner/*` or `owner/name`: repositories whose pushes start runs.
200
    pub repos: Vec<String>,
201
    pub job_timeout_minutes: u64,
202
    /// A runner whose last this many jobs all ended in an infrastructure
203
    /// error (status `error`, not `failed`) is drained and the
204
    /// administrators told (oxygenflow#30); 0 never drains one.
205
    pub drain_after_errors: u32,
206
}
207
208
impl Default for Flow {
209213×
    fn default() -> Self {
210213×
        Flow { runner_tokens: vec![], repos: vec![], job_timeout_minutes: 60, drain_after_errors: 5 }
211213×
    }
212
}
213
214
impl Flow {
215
    /// Whether pushes to `owner/name` start runs.
216946×
    pub fn allows(&self, owner: &str, name: &str) -> bool {
217946×
        self.repos.iter().any(|p| match p.split_once('/') {
218420×
            Some((o, "*")) => o == owner,
2190×
            Some((o, n)) => o == owner && n == name,
2200×
            None => false,
221420×
        })
222946×
    }
223
}
224
225
#[derive(Clone, Debug, Default, Deserialize)]
226
#[serde(default, deny_unknown_fields)]
227
pub struct Email {
228
    /// `smtp://user:pass@host:port` or `smtps://…`; empty disables email.
229
    pub smtp: String,
230
    /// `OxygenHub <hub@example>`.
231
    pub from: String,
232
}
233
234
#[derive(Clone, Debug, Deserialize)]
235
#[serde(default, deny_unknown_fields)]
236
pub struct Registration {
237
    /// `open`, `invite` or `closed`; the admin page can override it.
238
    pub mode: String,
239
    /// The setup code the first account must bring (oxygenhub#245), for
240
    /// automation; empty, the hub makes one and keeps it in
241
    /// `<data>/setup-code`.
242
    pub setup_code: String,
243
}
244
245
impl Default for Registration {
246213×
    fn default() -> Self {
247213×
        Registration { mode: "invite".into(), setup_code: String::new() }
248213×
    }
249
}
250
251
#[derive(Clone, Debug, Deserialize)]
252
#[serde(default, deny_unknown_fields)]
253
pub struct Site {
254
    pub name: String,
255
    pub description: String,
256
}
257
258
#[derive(Clone, Debug, Deserialize)]
259
#[serde(default, deny_unknown_fields)]
260
pub struct Server {
261
    /// Plain HTTP; empty disables it.
262
    pub bind: String,
263
    /// HTTPS; empty disables it. Needs `tls`.
264
    pub tls_bind: String,
265
    /// What links and clone URLs start with.
266
    pub base_url: String,
267
    /// Trust `X-Forwarded-*` from a terminating proxy.
268
    pub behind_proxy: bool,
269
    /// Strict-Transport-Security's max-age in seconds, sent when
270
    /// `base_url` is https; 0 sends none (oxygenhub#231). Raise it in
271
    /// stages: browsers keep it, so it is hard to take back.
272
    pub hsts_max_age: u64,
273
    /// The bearer token `/metrics` asks for (oxygenhub#212); empty, the
274
    /// metrics are off.
275
    pub metrics_token: String,
276
    /// A Grafana for this hub's metrics, linked from Insights (oxygenhub#257)
277
    /// for charts of one's own; empty, no link.
278
    pub grafana_url: String,
279
    /// What this process does (docs/roles.md, #272): `web` (pages, the
280
    /// API, the wire protocol, the runner API) and `worker` (the background
281
    /// tasks). Empty, both. `serve --role` overrides it.
282
    pub roles: Vec<String>,
283
}
284
285
/// One part of the hub's work a process can take (docs/roles.md, #272).
286
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
287
pub enum Role {
288
    /// Pages, the JSON API, the wire protocol, live pages and the runner
289
    /// API, and the transparency log they append to.
290
    Web,
291
    /// The background tasks: webhooks, Flow's scheduler, reaper and
292
    /// placement pass, nightly upkeep, backups and Diffusion's index.
293
    Worker,
294
}
295
296
impl Role {
297
    pub const ALL: [Role; 2] = [Role::Web, Role::Worker];
298
29914×
    pub fn name(self) -> &'static str {
30014×
        match self {
3017×
            Role::Web => "web",
3027×
            Role::Worker => "worker",
303
        }
30414×
    }
305
}
306
307
impl std::str::FromStr for Role {
308
    type Err = String;
309
31017×
    fn from_str(s: &str) -> Result<Role, String> {
31117×
        match s.trim().to_ascii_lowercase().as_str() {
31217×
            "web" => Ok(Role::Web),
3139×
            "worker" => Ok(Role::Worker),
3144×
            _ => Err(format!("unknown role {:?}: the roles are web and worker", s.trim())),
315
        }
31617×
    }
317
}
318
319
/// The roles one process takes: every role unless some are named.
320
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
321
pub struct Roles {
322
    pub web: bool,
323
    pub worker: bool,
324
}
325
326
impl Default for Roles {
327419×
    fn default() -> Self {
328419×
        Roles { web: true, worker: true }
329419×
    }
330
}
331
332
impl Roles {
333
    /// The roles named, each `web` or `worker`; none named is every role.
334429×
    pub fn from_names<S: AsRef<str>>(names: &[S]) -> Result<Roles, String> {
335429×
        let names: Vec<&str> = names.iter().map(|s| s.as_ref().trim()).filter(|s| !s.is_empty()).collect();
336429×
        if names.is_empty() {
337417×
            return Ok(Roles::default());
33812×
        }
33912×
        let mut roles = Roles { web: false, worker: false };
34017×
        for n in names {
34117×
            match n.parse::<Role>()? {
3428×
                Role::Web => roles.web = true,
3435×
                Role::Worker => roles.worker = true,
344
            }
345
        }
3468×
        Ok(roles)
347429×
    }
348
34918×
    pub fn has(self, role: Role) -> bool {
35018×
        match role {
3519×
            Role::Web => self.web,
3529×
            Role::Worker => self.worker,
353
        }
35418×
    }
355
356
    /// The roles' names, in a fixed order: what `/health` reports.
3579×
    pub fn names(self) -> Vec<&'static str> {
35818×
        Role::ALL.into_iter().filter(|r| self.has(*r)).map(Role::name).collect()
3599×
    }
360
}
361
362
#[derive(Clone, Debug, Deserialize)]
363
#[serde(default, deny_unknown_fields)]
364
pub struct Tls {
365
    /// Generate a certificate at `init` (developer edition).
366
    pub self_signed: bool,
367
    /// PEM files; relative paths are under the data directory.
368
    pub cert: String,
369
    pub key: String,
370
}
371
372
#[derive(Clone, Debug, Default, Deserialize)]
373
#[serde(default, deny_unknown_fields)]
374
pub struct Auth {
375
    /// Bearer tokens allowed to push until accounts exist (hub 0.2). Empty
376
    /// means every push is accepted, as `oxy serve` does.
377
    pub push_tokens: Vec<String>,
378
    /// For tests only: a base URL that stands in for every sign-in
379
    /// provider (`{base}/{provider}/authorize|token|userinfo`).
380
    pub signin_mock: Option<String>,
381
}
382
383
#[derive(Clone, Debug, Deserialize)]
384
#[serde(default, deny_unknown_fields)]
385
pub struct Limits {
386
    /// Largest file rendered in the browser, e.g. `2MiB`.
387
    pub render_limit: ByteSize,
388
    pub asset_limit: ByteSize,
389
    pub push_body_limit: ByteSize,
390
    /// Push protection's budget (oxygenhub#268): what one push may have
391
    /// scanned, the largest file or inserted text scanned, and how long
392
    /// the scan may take. Past them the rest goes unscanned.
393
    pub scan_limit: ByteSize,
394
    pub scan_file_limit: ByteSize,
395
    pub scan_time_ms: u64,
396
    /// The history scan's budget (oxygenhub#277): what one pass over one
397
    /// repository may scan, and for how long, before it moves on to the
398
    /// next and resumes there later. A limit of 0 turns the scan off.
399
    pub history_scan_limit: ByteSize,
400
    pub history_scan_time_ms: u64,
401
}
402
403
#[derive(Clone, Debug, Deserialize)]
404
#[serde(default, deny_unknown_fields)]
405
pub struct Retention {
406
    pub trash_days: u32,
407
    pub webhook_log_days: u32,
408
    /// The UTC hour at which every repository is garbage collected
409
    /// (spec §17.2); 24 or more turns the nightly gc off.
410
    pub gc_hour: u32,
411
}
412
413
/// A byte count written as `512`, `2MiB`, `4GiB`, `100kB`.
414
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
415
pub struct ByteSize(pub u64);
416
417
impl ByteSize {
4186×
    pub fn parse(s: &str) -> Option<ByteSize> {
4196×
        let s = s.trim();
42014×
        let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
4216×
        let unit = s[digits.len()..].trim();
4226×
        let n: u64 = digits.parse().ok()?;
4235×
        let mult = match unit {
4245×
            "" | "B" => 1,
4254×
            "k" | "kB" | "KB" => 1000,
4263×
            "KiB" | "K" => 1 << 10,
4273×
            "M" | "MB" => 1_000_000,
4283×
            "MiB" => 1 << 20,
4291×
            "G" | "GB" => 1_000_000_000,
4301×
            "GiB" => 1 << 30,
4310×
            _ => return None,
432
        };
4335×
        Some(ByteSize(n.checked_mul(mult)?))
4346×
    }
435
}
436
437
impl<'de> Deserialize<'de> for ByteSize {
4381×
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<ByteSize, D::Error> {
439
        #[derive(Deserialize)]
440
        #[serde(untagged)]
441
        enum Raw {
442
            Number(u64),
443
            Text(String),
444
        }
4451×
        match Raw::deserialize(d)? {
4460×
            Raw::Number(n) => Ok(ByteSize(n)),
4471×
            Raw::Text(t) => ByteSize::parse(&t).ok_or_else(|| serde::de::Error::custom(format!("not a byte size: {t:?}"))),
448
        }
4491×
    }
450
}
451
452
impl Default for Site {
453213×
    fn default() -> Self {
454213×
        Site { name: "OxygenHub".into(), description: "Repositories, proposals and issues for Oxygen".into() }
455213×
    }
456
}
457
458
impl Default for Server {
459216×
    fn default() -> Self {
460216×
        Server {
461216×
            bind: "127.0.0.1:7300".into(),
462216×
            tls_bind: String::new(),
463216×
            base_url: "http://127.0.0.1:7300".into(),
464216×
            behind_proxy: false,
465216×
            hsts_max_age: 0,
466216×
            metrics_token: String::new(),
467216×
            grafana_url: String::new(),
468216×
            roles: Vec::new(),
469216×
        }
470216×
    }
471
}
472
473
impl Default for Tls {
474213×
    fn default() -> Self {
475213×
        Tls { self_signed: false, cert: "keys/tls.crt".into(), key: "keys/tls.key".into() }
476213×
    }
477
}
478
479
impl Default for Limits {
480214×
    fn default() -> Self {
481214×
        let scan = crate::push_protection::Budget::default();
482214×
        let history = crate::history_scan::Budget::default();
483214×
        Limits {
484214×
            render_limit: ByteSize(2 << 20),
485214×
            asset_limit: ByteSize(512 << 20),
486214×
            push_body_limit: ByteSize(4 << 30),
487214×
            scan_limit: ByteSize(scan.bytes),
488214×
            scan_file_limit: ByteSize(scan.file),
489214×
            scan_time_ms: scan.time.as_millis() as u64,
490214×
            history_scan_limit: ByteSize(history.bytes),
491214×
            history_scan_time_ms: history.time.as_millis() as u64,
492214×
        }
493214×
    }
494
}
495
496
impl Default for Retention {
497213×
    fn default() -> Self {
498213×
        Retention { trash_days: 30, webhook_log_days: 30, gc_hour: 3 }
499213×
    }
500
}
501
502
impl Default for Config {
503213×
    fn default() -> Self {
504213×
        Config {
505213×
            data: PathBuf::from("data"),
506213×
            site: Site::default(),
507213×
            server: Server::default(),
508213×
            tls: Tls::default(),
509213×
            auth: Auth::default(),
510213×
            registration: Registration::default(),
511213×
            email: Email::default(),
512213×
            limits: Limits::default(),
513213×
            retention: Retention::default(),
514213×
            flow: Flow::default(),
515213×
            apt: Apt::default(),
516213×
            diffusion: Diffusion::default(),
517213×
            backups: Backups::default(),
518213×
            outbound: Outbound::default(),
519213×
            database: Database::default(),
520213×
            tlog: Tlog::default(),
521213×
            push: Push::default(),
522213×
        }
523213×
    }
524
}
525
526
impl Config {
527
    /// Read `<data>/hub.toml` if present, then apply environment overrides.
528210×
    pub fn load(data: &Path) -> anyhow::Result<Config> {
529210×
        let path = data.join("hub.toml");
530210×
        let mut cfg = match std::fs::read_to_string(&path) {
5311×
            Ok(text) => Config::parse(&text)?,
532209×
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Config::default(),
5330×
            Err(e) => return Err(anyhow::anyhow!("reading {}: {e}", path.display())),
534
        };
535210×
        cfg.data = data.to_path_buf();
5363780×
        cfg.apply_env(|k| std::env::var(k).ok());
537210×
        cfg.roles()?;
538209×
        Ok(cfg)
539210×
    }
540
5414×
    pub fn parse(text: &str) -> anyhow::Result<Config> {
5424×
        Ok(toml::from_str(text)?)
5434×
    }
544
545
    /// `OXYHUB_SERVER_BIND`, `OXYHUB_SITE_NAME`, … override the file.
546213×
    pub fn apply_env(&mut self, get: impl Fn(&str) -> Option<String>) {
547213×
        if let Some(v) = get("OXYHUB_DATABASE_URL") {
5480×
            self.database.url = v;
549213×
        }
550213×
        if let Some(v) = get("OXYHUB_DATABASE_SCHEMA") {
5510×
            self.database.schema = v;
552213×
        }
553213×
        if let Some(v) = get("OXYHUB_PUSH_FCM_PROJECT") {
5540×
            self.push.fcm_project = v;
555213×
        }
556213×
        if let Some(v) = get("OXYHUB_SITE_NAME") {
5571×
            self.site.name = v;
558212×
        }
559213×
        if let Some(v) = get("OXYHUB_SITE_DESCRIPTION") {
5600×
            self.site.description = v;
561213×
        }
562213×
        if let Some(v) = get("OXYHUB_SERVER_BIND") {
5630×
            self.server.bind = v;
564213×
        }
565213×
        if let Some(v) = get("OXYHUB_SERVER_TLS_BIND") {
5660×
            self.server.tls_bind = v;
567213×
        }
568213×
        if let Some(v) = get("OXYHUB_SERVER_BASE_URL") {
5690×
            self.server.base_url = v;
570213×
        }
571213×
        if let Some(v) = get("OXYHUB_SERVER_BEHIND_PROXY") {
5720×
            self.server.behind_proxy = matches!(v.as_str(), "1" | "true" | "yes");
573213×
        }
574213×
        if let Some(v) = get("OXYHUB_SERVER_HSTS_MAX_AGE") {
5750×
            self.server.hsts_max_age = v.parse().unwrap_or(0);
576213×
        }
577
        // The VM's setup gives these (oxygenhub-gcp#12): the token its
578
        // collector scrapes /metrics with, and its Grafana for Insights' link.
579213×
        if let Some(v) = get("OXYHUB_SERVER_METRICS_TOKEN") {
5801×
            self.server.metrics_token = v;
581212×
        }
582213×
        if let Some(v) = get("OXYHUB_SERVER_GRAFANA_URL") {
5831×
            self.server.grafana_url = v;
584212×
        }
585213×
        if let Some(v) = get("OXYHUB_SERVER_ROLES") {
5863×
            self.server.roles = v.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
587211×
        }
588213×
        if let Some(v) = get("OXYHUB_TLS_SELF_SIGNED") {
5890×
            self.tls.self_signed = matches!(v.as_str(), "1" | "true" | "yes");
590213×
        }
591213×
        if let Some(v) = get("OXYHUB_REGISTRATION_MODE") {
5920×
            self.registration.mode = v;
593213×
        }
594213×
        if let Some(v) = get("OXYHUB_REGISTRATION_SETUP_CODE") {
5950×
            self.registration.setup_code = v;
596213×
        }
597213×
        if let Some(v) = get("OXYHUB_AUTH_PUSH_TOKENS") {
5982×
            self.auth.push_tokens = v.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
599212×
        }
600213×
        if let Some(v) = get("OXYHUB_LIMITS_RENDER_LIMIT").and_then(|v| ByteSize::parse(&v)) {
6010×
            self.limits.render_limit = v;
602213×
        }
603213×
    }
604
605
    /// This process's roles (`[server] roles`); an unknown name is an error.
606424×
    pub fn roles(&self) -> anyhow::Result<Roles> {
607424×
        Roles::from_names(&self.server.roles).map_err(|e| anyhow::anyhow!("[server] roles: {e}"))
608424×
    }
609
6101×
    pub fn bind_addr(&self) -> anyhow::Result<Option<SocketAddr>> {
6111×
        parse_addr(&self.server.bind)
6121×
    }
613
6141×
    pub fn tls_bind_addr(&self) -> anyhow::Result<Option<SocketAddr>> {
6151×
        parse_addr(&self.server.tls_bind)
6161×
    }
617
6180×
    pub fn tls_cert(&self) -> PathBuf {
6190×
        self.data.join(&self.tls.cert)
6200×
    }
621
6220×
    pub fn tls_key(&self) -> PathBuf {
6230×
        self.data.join(&self.tls.key)
6240×
    }
625
626416×
    pub fn repos_dir(&self) -> PathBuf {
627416×
        self.data.join("repos")
628416×
    }
629
6301425×
    pub fn keys_dir(&self) -> PathBuf {
6311425×
        self.data.join("keys")
6321425×
    }
633
634208×
    pub fn db_path(&self) -> PathBuf {
635208×
        self.data.join("hub.db")
636208×
    }
637
638
    /// The base URL without a trailing slash.
63910961×
    pub fn base_url(&self) -> &str {
64010961×
        self.server.base_url.trim_end_matches('/')
64110961×
    }
642
}
643
6442×
fn parse_addr(s: &str) -> anyhow::Result<Option<SocketAddr>> {
6452×
    if s.trim().is_empty() {
6461×
        return Ok(None);
6471×
    }
6481×
    Ok(Some(s.parse().map_err(|e| anyhow::anyhow!("bind address {s:?}: {e}"))?))
6492×
}
650
651
#[cfg(test)]
652
mod tests {
653
    use super::*;
654
655
    #[test]
6561×
    fn byte_sizes() {
6571×
        assert_eq!(ByteSize::parse("2MiB"), Some(ByteSize(2 << 20)));
6581×
        assert_eq!(ByteSize::parse("512"), Some(ByteSize(512)));
6591×
        assert_eq!(ByteSize::parse("4 GiB"), Some(ByteSize(4 << 30)));
6601×
        assert_eq!(ByteSize::parse("100kB"), Some(ByteSize(100_000)));
6611×
        assert_eq!(ByteSize::parse("lots"), None);
6621×
    }
663
664
    #[test]
6651×
    fn file_then_environment() {
6661×
        let mut cfg = Config::parse("[server]\nbind = \"0.0.0.0:7300\"\n[limits]\nrender_limit = \"1MiB\"\n").unwrap();
6671×
        assert_eq!(cfg.server.bind, "0.0.0.0:7300");
6681×
        assert_eq!(cfg.limits.render_limit, ByteSize(1 << 20));
6691×
        assert_eq!(cfg.site.name, "OxygenHub");
67018×
        cfg.apply_env(|k| match k {
67118×
            "OXYHUB_SITE_NAME" => Some("Earth".into()),
67217×
            "OXYHUB_AUTH_PUSH_TOKENS" => Some("a, b".into()),
67316×
            "OXYHUB_SERVER_METRICS_TOKEN" => Some("m".into()),
67415×
            "OXYHUB_SERVER_GRAFANA_URL" => Some("https://grafana.example".into()),
67514×
            _ => None,
67618×
        });
6771×
        assert_eq!(cfg.site.name, "Earth");
6781×
        assert_eq!(cfg.auth.push_tokens, ["a", "b"]);
6791×
        assert_eq!((cfg.server.metrics_token.as_str(), cfg.server.grafana_url.as_str()), ("m", "https://grafana.example"));
6801×
        assert!(Config::parse("[nope]\nx = 1\n").is_err(), "unknown sections are errors, not silence");
6811×
    }
682
683
    #[test]
6841×
    fn roles_default_to_all_and_unknown_names_are_errors() {
6851×
        let cfg = Config::default();
6861×
        assert_eq!(cfg.roles().unwrap(), Roles { web: true, worker: true });
6871×
        assert_eq!(cfg.roles().unwrap().names(), ["web", "worker"]);
6881×
        assert_eq!(Roles::from_names(&["worker"]).unwrap(), Roles { web: false, worker: true });
6891×
        assert_eq!(Roles::from_names(&[" Web ", "web"]).unwrap().names(), ["web"]);
6901×
        assert_eq!(Roles::from_names(&["worker", "web"]).unwrap().names(), ["web", "worker"]);
6911×
        assert_eq!(Roles::from_names::<&str>(&[]).unwrap(), Roles::default());
6921×
        let e = Roles::from_names(&["web", "edge"]).unwrap_err();
6931×
        assert_eq!(e, "unknown role \"edge\": the roles are web and worker");
694
6951×
        let mut cfg = Config::parse("[server]\nroles = [\"worker\"]\n").unwrap();
6961×
        assert_eq!(cfg.roles().unwrap().names(), ["worker"]);
69718×
        cfg.apply_env(|k| (k == "OXYHUB_SERVER_ROLES").then(|| "web, worker".into()));
6981×
        assert_eq!(cfg.roles().unwrap(), Roles::default());
69918×
        cfg.apply_env(|k| (k == "OXYHUB_SERVER_ROLES").then(|| "store".into()));
7001×
        assert!(cfg.roles().unwrap_err().to_string().contains("unknown role \"store\""));
7011×
    }
702
703
    #[test]
7041×
    fn an_unknown_role_in_hub_toml_stops_loading() {
7051×
        let dir = tempfile::tempdir().unwrap();
7061×
        std::fs::write(dir.path().join("hub.toml"), "[server]\nroles = [\"wrker\"]\n").unwrap();
7071×
        let e = Config::load(dir.path()).unwrap_err().to_string();
7081×
        assert!(e.contains("unknown role \"wrker\""), "{e}");
7091×
    }
710
}