OXYGENHUB

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

289 of 319 lines run, 83 of 124 functions · run #539 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
}
69
70
impl Default for Push {
71212×
    fn default() -> Self {
72212×
        Push {
73212×
            fcm_project: String::new(),
74212×
            fcm_url: "https://fcm.googleapis.com".into(),
75212×
            token_url: "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token".into(),
76212×
        }
77212×
    }
78
}
79
80
/// The transparency log's witnesses (docs/transparency.md, #238): who
81
/// cosigns this hub's checkpoints, and whose logs this hub cosigns.
82
#[derive(Clone, Debug, Default, Deserialize)]
83
#[serde(default, deny_unknown_fields)]
84
pub struct Tlog {
85
    pub witnesses: Vec<TlogWitness>,
86
    pub witness_for: Vec<TlogWitnessed>,
87
}
88
89
/// A witness this hub's log asks: its name in cosignatures, its
90
/// add-checkpoint address (`https://HUB/log/witness/add-checkpoint` for
91
/// another hub), and its identity object in base64 (another hub's
92
/// `/.well-known/oxyhub-key`).
93
#[derive(Clone, Debug, Deserialize)]
94
#[serde(deny_unknown_fields)]
95
pub struct TlogWitness {
96
    pub name: String,
97
    pub url: String,
98
    pub key: String,
99
}
100
101
/// A log this hub witnesses: its origin (`HOST/log`) and its key, an
102
/// identity object in base64.
103
#[derive(Clone, Debug, Deserialize)]
104
#[serde(deny_unknown_fields)]
105
pub struct TlogWitnessed {
106
    pub origin: String,
107
    pub key: String,
108
}
109
110
/// Backups (docs/backups.md, oxygenhub#169): the directories on this host
111
/// an owner may back up to. Empty, only GCS destinations can be chosen.
112
#[derive(Clone, Debug, Deserialize)]
113
#[serde(default, deny_unknown_fields)]
114
pub struct Backups {
115
    pub directories: Vec<PathBuf>,
116
    /// Google's token exchange and Cloud Storage, for GCS destinations;
117
    /// set only to point them elsewhere (tests, a private endpoint).
118
    pub sts_url: String,
119
    pub storage_url: String,
120
    /// AWS STS and S3, for S3 destinations; `{bucket}` and `{region}` in
121
    /// `s3_url` are filled in. Set only to point them elsewhere.
122
    pub aws_sts_url: String,
123
    pub s3_url: String,
124
}
125
126
impl Default for Backups {
127212×
    fn default() -> Self {
128212×
        Backups {
129212×
            directories: Vec::new(),
130212×
            sts_url: "https://sts.googleapis.com".into(),
131212×
            storage_url: "https://storage.googleapis.com".into(),
132212×
            aws_sts_url: "https://sts.amazonaws.com".into(),
133212×
            s3_url: "https://{bucket}.s3.{region}.amazonaws.com".into(),
134212×
        }
135212×
    }
136
}
137
138
/// Diffusion (#148, docs/diffusion.md §4.2): the site's default for
139
/// repositories that say nothing.
140
#[derive(Clone, Debug, Deserialize)]
141
#[serde(default, deny_unknown_fields)]
142
pub struct Diffusion {
143
    /// `off`, `green` or `any`.
144
    pub follow: String,
145
}
146
147
impl Default for Diffusion {
148212×
    fn default() -> Self {
149212×
        Diffusion { follow: "off".into() }
150212×
    }
151
}
152
153
/// The apt repository at `/apt` (oxygenhub#85): the `.deb` assets of these
154
/// public repositories' releases, signed with the key in `gnupg_home`.
155
#[derive(Clone, Debug, Deserialize)]
156
#[serde(default, deny_unknown_fields)]
157
pub struct Apt {
158
    /// `owner/name` of each repository whose release `.deb`s are served;
159
    /// none turns `/apt` off.
160
    pub repos: Vec<String>,
161
    /// The suite apt's `sources` names.
162
    pub suite: String,
163
    pub component: String,
164
    /// GnuPG home holding the signing key (relative to the data directory).
165
    pub gnupg_home: String,
166
    /// Architectures the index lists, each with its Packages file.
167
    pub architectures: Vec<String>,
168
}
169
170
impl Default for Apt {
171212×
    fn default() -> Self {
172212×
        Apt {
173212×
            repos: vec![],
174212×
            suite: "stable".into(),
175212×
            component: "main".into(),
176212×
            gnupg_home: "apt-gnupg".into(),
177212×
            architectures: vec!["amd64".into(), "arm64".into(), "riscv64".into()],
178212×
        }
179212×
    }
180
}
181
182
/// OxygenFlow runs (hub spec §13.2).
183
#[derive(Clone, Debug, Deserialize)]
184
#[serde(default, deny_unknown_fields)]
185
pub struct Flow {
186
    /// Bearer tokens runners present to claim jobs.
187
    pub runner_tokens: Vec<String>,
188
    /// `owner/*` or `owner/name`: repositories whose pushes start runs.
189
    pub repos: Vec<String>,
190
    pub job_timeout_minutes: u64,
191
    /// A runner whose last this many jobs all ended in an infrastructure
192
    /// error (status `error`, not `failed`) is drained and the
193
    /// administrators told (oxygenflow#30); 0 never drains one.
194
    pub drain_after_errors: u32,
195
}
196
197
impl Default for Flow {
198212×
    fn default() -> Self {
199212×
        Flow { runner_tokens: vec![], repos: vec![], job_timeout_minutes: 60, drain_after_errors: 5 }
200212×
    }
201
}
202
203
impl Flow {
204
    /// Whether pushes to `owner/name` start runs.
205945×
    pub fn allows(&self, owner: &str, name: &str) -> bool {
206945×
        self.repos.iter().any(|p| match p.split_once('/') {
207420×
            Some((o, "*")) => o == owner,
2080×
            Some((o, n)) => o == owner && n == name,
2090×
            None => false,
210420×
        })
211945×
    }
212
}
213
214
#[derive(Clone, Debug, Default, Deserialize)]
215
#[serde(default, deny_unknown_fields)]
216
pub struct Email {
217
    /// `smtp://user:pass@host:port` or `smtps://…`; empty disables email.
218
    pub smtp: String,
219
    /// `OxygenHub <hub@example>`.
220
    pub from: String,
221
}
222
223
#[derive(Clone, Debug, Deserialize)]
224
#[serde(default, deny_unknown_fields)]
225
pub struct Registration {
226
    /// `open`, `invite` or `closed`; the admin page can override it.
227
    pub mode: String,
228
    /// The setup code the first account must bring (oxygenhub#245), for
229
    /// automation; empty, the hub makes one and keeps it in
230
    /// `<data>/setup-code`.
231
    pub setup_code: String,
232
}
233
234
impl Default for Registration {
235212×
    fn default() -> Self {
236212×
        Registration { mode: "invite".into(), setup_code: String::new() }
237212×
    }
238
}
239
240
#[derive(Clone, Debug, Deserialize)]
241
#[serde(default, deny_unknown_fields)]
242
pub struct Site {
243
    pub name: String,
244
    pub description: String,
245
}
246
247
#[derive(Clone, Debug, Deserialize)]
248
#[serde(default, deny_unknown_fields)]
249
pub struct Server {
250
    /// Plain HTTP; empty disables it.
251
    pub bind: String,
252
    /// HTTPS; empty disables it. Needs `tls`.
253
    pub tls_bind: String,
254
    /// What links and clone URLs start with.
255
    pub base_url: String,
256
    /// Trust `X-Forwarded-*` from a terminating proxy.
257
    pub behind_proxy: bool,
258
    /// Strict-Transport-Security's max-age in seconds, sent when
259
    /// `base_url` is https; 0 sends none (oxygenhub#231). Raise it in
260
    /// stages: browsers keep it, so it is hard to take back.
261
    pub hsts_max_age: u64,
262
    /// The bearer token `/metrics` asks for (oxygenhub#212); empty, the
263
    /// metrics are off.
264
    pub metrics_token: String,
265
    /// A Grafana for this hub's metrics, linked from Insights (oxygenhub#257)
266
    /// for charts of one's own; empty, no link.
267
    pub grafana_url: String,
268
    /// What this process does (docs/roles.md, #272): `web` (pages, the
269
    /// API, the wire protocol, the runner API) and `worker` (the background
270
    /// tasks). Empty, both. `serve --role` overrides it.
271
    pub roles: Vec<String>,
272
}
273
274
/// One part of the hub's work a process can take (docs/roles.md, #272).
275
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
276
pub enum Role {
277
    /// Pages, the JSON API, the wire protocol, live pages and the runner
278
    /// API, and the transparency log they append to.
279
    Web,
280
    /// The background tasks: webhooks, Flow's scheduler, reaper and
281
    /// placement pass, nightly upkeep, backups and Diffusion's index.
282
    Worker,
283
}
284
285
impl Role {
286
    pub const ALL: [Role; 2] = [Role::Web, Role::Worker];
287
28814×
    pub fn name(self) -> &'static str {
28914×
        match self {
2907×
            Role::Web => "web",
2917×
            Role::Worker => "worker",
292
        }
29314×
    }
294
}
295
296
impl std::str::FromStr for Role {
297
    type Err = String;
298
29917×
    fn from_str(s: &str) -> Result<Role, String> {
30017×
        match s.trim().to_ascii_lowercase().as_str() {
30117×
            "web" => Ok(Role::Web),
3029×
            "worker" => Ok(Role::Worker),
3034×
            _ => Err(format!("unknown role {:?}: the roles are web and worker", s.trim())),
304
        }
30517×
    }
306
}
307
308
/// The roles one process takes: every role unless some are named.
309
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
310
pub struct Roles {
311
    pub web: bool,
312
    pub worker: bool,
313
}
314
315
impl Default for Roles {
316417×
    fn default() -> Self {
317417×
        Roles { web: true, worker: true }
318417×
    }
319
}
320
321
impl Roles {
322
    /// The roles named, each `web` or `worker`; none named is every role.
323427×
    pub fn from_names<S: AsRef<str>>(names: &[S]) -> Result<Roles, String> {
324427×
        let names: Vec<&str> = names.iter().map(|s| s.as_ref().trim()).filter(|s| !s.is_empty()).collect();
325427×
        if names.is_empty() {
326415×
            return Ok(Roles::default());
32712×
        }
32812×
        let mut roles = Roles { web: false, worker: false };
32917×
        for n in names {
33017×
            match n.parse::<Role>()? {
3318×
                Role::Web => roles.web = true,
3325×
                Role::Worker => roles.worker = true,
333
            }
334
        }
3358×
        Ok(roles)
336427×
    }
337
33818×
    pub fn has(self, role: Role) -> bool {
33918×
        match role {
3409×
            Role::Web => self.web,
3419×
            Role::Worker => self.worker,
342
        }
34318×
    }
344
345
    /// The roles' names, in a fixed order: what `/health` reports.
3469×
    pub fn names(self) -> Vec<&'static str> {
34718×
        Role::ALL.into_iter().filter(|r| self.has(*r)).map(Role::name).collect()
3489×
    }
349
}
350
351
#[derive(Clone, Debug, Deserialize)]
352
#[serde(default, deny_unknown_fields)]
353
pub struct Tls {
354
    /// Generate a certificate at `init` (developer edition).
355
    pub self_signed: bool,
356
    /// PEM files; relative paths are under the data directory.
357
    pub cert: String,
358
    pub key: String,
359
}
360
361
#[derive(Clone, Debug, Default, Deserialize)]
362
#[serde(default, deny_unknown_fields)]
363
pub struct Auth {
364
    /// Bearer tokens allowed to push until accounts exist (hub 0.2). Empty
365
    /// means every push is accepted, as `oxy serve` does.
366
    pub push_tokens: Vec<String>,
367
    /// For tests only: a base URL that stands in for every sign-in
368
    /// provider (`{base}/{provider}/authorize|token|userinfo`).
369
    pub signin_mock: Option<String>,
370
}
371
372
#[derive(Clone, Debug, Deserialize)]
373
#[serde(default, deny_unknown_fields)]
374
pub struct Limits {
375
    /// Largest file rendered in the browser, e.g. `2MiB`.
376
    pub render_limit: ByteSize,
377
    pub asset_limit: ByteSize,
378
    pub push_body_limit: ByteSize,
379
    /// Push protection's budget (oxygenhub#268): what one push may have
380
    /// scanned, the largest file or inserted text scanned, and how long
381
    /// the scan may take. Past them the rest goes unscanned.
382
    pub scan_limit: ByteSize,
383
    pub scan_file_limit: ByteSize,
384
    pub scan_time_ms: u64,
385
    /// The history scan's budget (oxygenhub#277): what one pass over one
386
    /// repository may scan, and for how long, before it moves on to the
387
    /// next and resumes there later. A limit of 0 turns the scan off.
388
    pub history_scan_limit: ByteSize,
389
    pub history_scan_time_ms: u64,
390
}
391
392
#[derive(Clone, Debug, Deserialize)]
393
#[serde(default, deny_unknown_fields)]
394
pub struct Retention {
395
    pub trash_days: u32,
396
    pub webhook_log_days: u32,
397
    /// The UTC hour at which every repository is garbage collected
398
    /// (spec §17.2); 24 or more turns the nightly gc off.
399
    pub gc_hour: u32,
400
}
401
402
/// A byte count written as `512`, `2MiB`, `4GiB`, `100kB`.
403
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
404
pub struct ByteSize(pub u64);
405
406
impl ByteSize {
4076×
    pub fn parse(s: &str) -> Option<ByteSize> {
4086×
        let s = s.trim();
40914×
        let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
4106×
        let unit = s[digits.len()..].trim();
4116×
        let n: u64 = digits.parse().ok()?;
4125×
        let mult = match unit {
4135×
            "" | "B" => 1,
4144×
            "k" | "kB" | "KB" => 1000,
4153×
            "KiB" | "K" => 1 << 10,
4163×
            "M" | "MB" => 1_000_000,
4173×
            "MiB" => 1 << 20,
4181×
            "G" | "GB" => 1_000_000_000,
4191×
            "GiB" => 1 << 30,
4200×
            _ => return None,
421
        };
4225×
        Some(ByteSize(n.checked_mul(mult)?))
4236×
    }
424
}
425
426
impl<'de> Deserialize<'de> for ByteSize {
4271×
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<ByteSize, D::Error> {
428
        #[derive(Deserialize)]
429
        #[serde(untagged)]
430
        enum Raw {
431
            Number(u64),
432
            Text(String),
433
        }
4341×
        match Raw::deserialize(d)? {
4350×
            Raw::Number(n) => Ok(ByteSize(n)),
4361×
            Raw::Text(t) => ByteSize::parse(&t).ok_or_else(|| serde::de::Error::custom(format!("not a byte size: {t:?}"))),
437
        }
4381×
    }
439
}
440
441
impl Default for Site {
442212×
    fn default() -> Self {
443212×
        Site { name: "OxygenHub".into(), description: "Repositories, proposals and issues for Oxygen".into() }
444212×
    }
445
}
446
447
impl Default for Server {
448215×
    fn default() -> Self {
449215×
        Server {
450215×
            bind: "127.0.0.1:7300".into(),
451215×
            tls_bind: String::new(),
452215×
            base_url: "http://127.0.0.1:7300".into(),
453215×
            behind_proxy: false,
454215×
            hsts_max_age: 0,
455215×
            metrics_token: String::new(),
456215×
            grafana_url: String::new(),
457215×
            roles: Vec::new(),
458215×
        }
459215×
    }
460
}
461
462
impl Default for Tls {
463212×
    fn default() -> Self {
464212×
        Tls { self_signed: false, cert: "keys/tls.crt".into(), key: "keys/tls.key".into() }
465212×
    }
466
}
467
468
impl Default for Limits {
469213×
    fn default() -> Self {
470213×
        let scan = crate::push_protection::Budget::default();
471213×
        let history = crate::history_scan::Budget::default();
472213×
        Limits {
473213×
            render_limit: ByteSize(2 << 20),
474213×
            asset_limit: ByteSize(512 << 20),
475213×
            push_body_limit: ByteSize(4 << 30),
476213×
            scan_limit: ByteSize(scan.bytes),
477213×
            scan_file_limit: ByteSize(scan.file),
478213×
            scan_time_ms: scan.time.as_millis() as u64,
479213×
            history_scan_limit: ByteSize(history.bytes),
480213×
            history_scan_time_ms: history.time.as_millis() as u64,
481213×
        }
482213×
    }
483
}
484
485
impl Default for Retention {
486212×
    fn default() -> Self {
487212×
        Retention { trash_days: 30, webhook_log_days: 30, gc_hour: 3 }
488212×
    }
489
}
490
491
impl Default for Config {
492212×
    fn default() -> Self {
493212×
        Config {
494212×
            data: PathBuf::from("data"),
495212×
            site: Site::default(),
496212×
            server: Server::default(),
497212×
            tls: Tls::default(),
498212×
            auth: Auth::default(),
499212×
            registration: Registration::default(),
500212×
            email: Email::default(),
501212×
            limits: Limits::default(),
502212×
            retention: Retention::default(),
503212×
            flow: Flow::default(),
504212×
            apt: Apt::default(),
505212×
            diffusion: Diffusion::default(),
506212×
            backups: Backups::default(),
507212×
            outbound: Outbound::default(),
508212×
            database: Database::default(),
509212×
            tlog: Tlog::default(),
510212×
            push: Push::default(),
511212×
        }
512212×
    }
513
}
514
515
impl Config {
516
    /// Read `<data>/hub.toml` if present, then apply environment overrides.
517209×
    pub fn load(data: &Path) -> anyhow::Result<Config> {
518209×
        let path = data.join("hub.toml");
519209×
        let mut cfg = match std::fs::read_to_string(&path) {
5201×
            Ok(text) => Config::parse(&text)?,
521208×
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Config::default(),
5220×
            Err(e) => return Err(anyhow::anyhow!("reading {}: {e}", path.display())),
523
        };
524209×
        cfg.data = data.to_path_buf();
5253762×
        cfg.apply_env(|k| std::env::var(k).ok());
526209×
        cfg.roles()?;
527208×
        Ok(cfg)
528209×
    }
529
5304×
    pub fn parse(text: &str) -> anyhow::Result<Config> {
5314×
        Ok(toml::from_str(text)?)
5324×
    }
533
534
    /// `OXYHUB_SERVER_BIND`, `OXYHUB_SITE_NAME`, … override the file.
535212×
    pub fn apply_env(&mut self, get: impl Fn(&str) -> Option<String>) {
536212×
        if let Some(v) = get("OXYHUB_DATABASE_URL") {
5370×
            self.database.url = v;
538212×
        }
539212×
        if let Some(v) = get("OXYHUB_DATABASE_SCHEMA") {
5400×
            self.database.schema = v;
541212×
        }
542212×
        if let Some(v) = get("OXYHUB_PUSH_FCM_PROJECT") {
5430×
            self.push.fcm_project = v;
544212×
        }
545212×
        if let Some(v) = get("OXYHUB_SITE_NAME") {
5461×
            self.site.name = v;
547211×
        }
548212×
        if let Some(v) = get("OXYHUB_SITE_DESCRIPTION") {
5490×
            self.site.description = v;
550212×
        }
551212×
        if let Some(v) = get("OXYHUB_SERVER_BIND") {
5520×
            self.server.bind = v;
553212×
        }
554212×
        if let Some(v) = get("OXYHUB_SERVER_TLS_BIND") {
5550×
            self.server.tls_bind = v;
556212×
        }
557212×
        if let Some(v) = get("OXYHUB_SERVER_BASE_URL") {
5580×
            self.server.base_url = v;
559212×
        }
560212×
        if let Some(v) = get("OXYHUB_SERVER_BEHIND_PROXY") {
5610×
            self.server.behind_proxy = matches!(v.as_str(), "1" | "true" | "yes");
562212×
        }
563212×
        if let Some(v) = get("OXYHUB_SERVER_HSTS_MAX_AGE") {
5640×
            self.server.hsts_max_age = v.parse().unwrap_or(0);
565212×
        }
566
        // The VM's setup gives these (oxygenhub-gcp#12): the token its
567
        // collector scrapes /metrics with, and its Grafana for Insights' link.
568212×
        if let Some(v) = get("OXYHUB_SERVER_METRICS_TOKEN") {
5691×
            self.server.metrics_token = v;
570211×
        }
571212×
        if let Some(v) = get("OXYHUB_SERVER_GRAFANA_URL") {
5721×
            self.server.grafana_url = v;
573211×
        }
574212×
        if let Some(v) = get("OXYHUB_SERVER_ROLES") {
5753×
            self.server.roles = v.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
576210×
        }
577212×
        if let Some(v) = get("OXYHUB_TLS_SELF_SIGNED") {
5780×
            self.tls.self_signed = matches!(v.as_str(), "1" | "true" | "yes");
579212×
        }
580212×
        if let Some(v) = get("OXYHUB_REGISTRATION_MODE") {
5810×
            self.registration.mode = v;
582212×
        }
583212×
        if let Some(v) = get("OXYHUB_REGISTRATION_SETUP_CODE") {
5840×
            self.registration.setup_code = v;
585212×
        }
586212×
        if let Some(v) = get("OXYHUB_AUTH_PUSH_TOKENS") {
5872×
            self.auth.push_tokens = v.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
588211×
        }
589212×
        if let Some(v) = get("OXYHUB_LIMITS_RENDER_LIMIT").and_then(|v| ByteSize::parse(&v)) {
5900×
            self.limits.render_limit = v;
591212×
        }
592212×
    }
593
594
    /// This process's roles (`[server] roles`); an unknown name is an error.
595422×
    pub fn roles(&self) -> anyhow::Result<Roles> {
596422×
        Roles::from_names(&self.server.roles).map_err(|e| anyhow::anyhow!("[server] roles: {e}"))
597422×
    }
598
5991×
    pub fn bind_addr(&self) -> anyhow::Result<Option<SocketAddr>> {
6001×
        parse_addr(&self.server.bind)
6011×
    }
602
6031×
    pub fn tls_bind_addr(&self) -> anyhow::Result<Option<SocketAddr>> {
6041×
        parse_addr(&self.server.tls_bind)
6051×
    }
606
6070×
    pub fn tls_cert(&self) -> PathBuf {
6080×
        self.data.join(&self.tls.cert)
6090×
    }
610
6110×
    pub fn tls_key(&self) -> PathBuf {
6120×
        self.data.join(&self.tls.key)
6130×
    }
614
615414×
    pub fn repos_dir(&self) -> PathBuf {
616414×
        self.data.join("repos")
617414×
    }
618
6191399×
    pub fn keys_dir(&self) -> PathBuf {
6201399×
        self.data.join("keys")
6211399×
    }
622
623207×
    pub fn db_path(&self) -> PathBuf {
624207×
        self.data.join("hub.db")
625207×
    }
626
627
    /// The base URL without a trailing slash.
62810736×
    pub fn base_url(&self) -> &str {
62910736×
        self.server.base_url.trim_end_matches('/')
63010736×
    }
631
}
632
6332×
fn parse_addr(s: &str) -> anyhow::Result<Option<SocketAddr>> {
6342×
    if s.trim().is_empty() {
6351×
        return Ok(None);
6361×
    }
6371×
    Ok(Some(s.parse().map_err(|e| anyhow::anyhow!("bind address {s:?}: {e}"))?))
6382×
}
639
640
#[cfg(test)]
641
mod tests {
642
    use super::*;
643
644
    #[test]
6451×
    fn byte_sizes() {
6461×
        assert_eq!(ByteSize::parse("2MiB"), Some(ByteSize(2 << 20)));
6471×
        assert_eq!(ByteSize::parse("512"), Some(ByteSize(512)));
6481×
        assert_eq!(ByteSize::parse("4 GiB"), Some(ByteSize(4 << 30)));
6491×
        assert_eq!(ByteSize::parse("100kB"), Some(ByteSize(100_000)));
6501×
        assert_eq!(ByteSize::parse("lots"), None);
6511×
    }
652
653
    #[test]
6541×
    fn file_then_environment() {
6551×
        let mut cfg = Config::parse("[server]\nbind = \"0.0.0.0:7300\"\n[limits]\nrender_limit = \"1MiB\"\n").unwrap();
6561×
        assert_eq!(cfg.server.bind, "0.0.0.0:7300");
6571×
        assert_eq!(cfg.limits.render_limit, ByteSize(1 << 20));
6581×
        assert_eq!(cfg.site.name, "OxygenHub");
65918×
        cfg.apply_env(|k| match k {
66018×
            "OXYHUB_SITE_NAME" => Some("Earth".into()),
66117×
            "OXYHUB_AUTH_PUSH_TOKENS" => Some("a, b".into()),
66216×
            "OXYHUB_SERVER_METRICS_TOKEN" => Some("m".into()),
66315×
            "OXYHUB_SERVER_GRAFANA_URL" => Some("https://grafana.example".into()),
66414×
            _ => None,
66518×
        });
6661×
        assert_eq!(cfg.site.name, "Earth");
6671×
        assert_eq!(cfg.auth.push_tokens, ["a", "b"]);
6681×
        assert_eq!((cfg.server.metrics_token.as_str(), cfg.server.grafana_url.as_str()), ("m", "https://grafana.example"));
6691×
        assert!(Config::parse("[nope]\nx = 1\n").is_err(), "unknown sections are errors, not silence");
6701×
    }
671
672
    #[test]
6731×
    fn roles_default_to_all_and_unknown_names_are_errors() {
6741×
        let cfg = Config::default();
6751×
        assert_eq!(cfg.roles().unwrap(), Roles { web: true, worker: true });
6761×
        assert_eq!(cfg.roles().unwrap().names(), ["web", "worker"]);
6771×
        assert_eq!(Roles::from_names(&["worker"]).unwrap(), Roles { web: false, worker: true });
6781×
        assert_eq!(Roles::from_names(&[" Web ", "web"]).unwrap().names(), ["web"]);
6791×
        assert_eq!(Roles::from_names(&["worker", "web"]).unwrap().names(), ["web", "worker"]);
6801×
        assert_eq!(Roles::from_names::<&str>(&[]).unwrap(), Roles::default());
6811×
        let e = Roles::from_names(&["web", "edge"]).unwrap_err();
6821×
        assert_eq!(e, "unknown role \"edge\": the roles are web and worker");
683
6841×
        let mut cfg = Config::parse("[server]\nroles = [\"worker\"]\n").unwrap();
6851×
        assert_eq!(cfg.roles().unwrap().names(), ["worker"]);
68618×
        cfg.apply_env(|k| (k == "OXYHUB_SERVER_ROLES").then(|| "web, worker".into()));
6871×
        assert_eq!(cfg.roles().unwrap(), Roles::default());
68818×
        cfg.apply_env(|k| (k == "OXYHUB_SERVER_ROLES").then(|| "store".into()));
6891×
        assert!(cfg.roles().unwrap_err().to_string().contains("unknown role \"store\""));
6901×
    }
691
692
    #[test]
6931×
    fn an_unknown_role_in_hub_toml_stops_loading() {
6941×
        let dir = tempfile::tempdir().unwrap();
6951×
        std::fs::write(dir.path().join("hub.toml"), "[server]\nroles = [\"wrker\"]\n").unwrap();
6961×
        let e = Config::load(dir.path()).unwrap_err().to_string();
6971×
        assert!(e.contains("unknown role \"wrker\""), "{e}");
6981×
    }
699
}