Skip to main content

oxide_batch_cli/
config.rs

1//! Typed configuration with per-value precedence.
2//!
3//! Precedence is resolved per value rather than per source, so a file may
4//! supply the repository pool size while an option supplies the page size.
5//! Validation is strict and fail closed: unknown keys are errors, bounded
6//! values are rejected outside their documented bounds, and every safe conflict
7//! is reported in one pass before a repository connection is opened.
8
9use std::collections::BTreeMap;
10use std::fmt;
11use std::path::{Path, PathBuf};
12use std::time::Duration;
13
14use crate::args::{Arguments, OutputForm};
15use crate::host::Host;
16
17/// Configuration schema version accepted in a configuration file.
18pub const CONFIG_VERSION: u64 = 1;
19
20/// Largest configuration file the CLI reads.
21const MAX_CONFIG_BYTES: usize = 256 * 1024;
22/// Largest secret an indirection file may carry.
23const MAX_SECRET_BYTES: usize = 64 * 1024;
24/// Deepest accepted nesting in a configuration file.
25const MAX_CONFIG_DEPTH: usize = 4;
26
27const MIN_CLIENT_TIMEOUT: Duration = Duration::from_secs(1);
28const MAX_CLIENT_TIMEOUT: Duration = Duration::from_hours(1);
29const DEFAULT_CLIENT_TIMEOUT: Duration = Duration::from_mins(1);
30const DEFAULT_PAGE_SIZE: u16 = 50;
31const MAX_PAGE_SIZE: u16 = 500;
32const DEFAULT_POOL_SIZE: u32 = 10;
33const MAX_POOL_SIZE: u32 = 1024;
34const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
35const MAX_CONNECT_TIMEOUT: Duration = Duration::from_mins(5);
36const DEFAULT_STATEMENT_TIMEOUT: Duration = Duration::from_secs(30);
37const MAX_STATEMENT_TIMEOUT: Duration = Duration::from_hours(24);
38const MIN_BOUNDED_DURATION: Duration = Duration::from_millis(1);
39
40/// Where one effective value came from.
41#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
42pub enum Source {
43    /// An explicit command-line option.
44    Option,
45    /// A namespaced environment variable.
46    Environment,
47    /// The configuration file.
48    File,
49    /// The documented framework default.
50    Default,
51}
52
53impl Source {
54    /// Returns the stable machine name of this source.
55    #[must_use]
56    pub const fn as_str(self) -> &'static str {
57        match self {
58            Self::Option => "option",
59            Self::Environment => "environment",
60            Self::File => "file",
61            Self::Default => "default",
62        }
63    }
64}
65
66impl fmt::Display for Source {
67    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68        formatter.write_str(self.as_str())
69    }
70}
71
72/// One effective value together with the source that supplied it.
73#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74pub struct Resolved<T> {
75    value: T,
76    source: Source,
77}
78
79impl<T> Resolved<T> {
80    const fn new(value: T, source: Source) -> Self {
81        Self { value, source }
82    }
83
84    /// Borrows the effective value.
85    pub const fn value(&self) -> &T {
86        &self.value
87    }
88
89    /// Returns the source that supplied the value.
90    #[must_use]
91    pub const fn source(&self) -> Source {
92        self.source
93    }
94}
95
96impl<T: Copy> Resolved<T> {
97    /// Returns the effective value.
98    pub const fn get(&self) -> T {
99        self.value
100    }
101}
102
103/// A configuration value whose text must never reach output.
104///
105/// `Debug` and `Display` redact the value. `config show` prints the source and
106/// a redaction marker instead.
107#[derive(Clone, Eq, PartialEq)]
108pub struct Secret(String);
109
110impl Secret {
111    /// Wraps a secret value.
112    #[must_use]
113    pub fn new(value: impl Into<String>) -> Self {
114        Self(value.into())
115    }
116
117    /// Exposes the secret at an authorized boundary.
118    ///
119    /// The only authorized boundary in this crate is repository connection
120    /// construction.
121    #[must_use]
122    pub fn expose(&self) -> &str {
123        &self.0
124    }
125}
126
127impl fmt::Debug for Secret {
128    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
129        formatter.write_str("<redacted>")
130    }
131}
132
133impl fmt::Display for Secret {
134    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
135        formatter.write_str("<redacted>")
136    }
137}
138
139/// Transport security selected for the repository connection.
140#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
141pub enum TlsSetting {
142    /// Validate the server certificate and hostname.
143    #[default]
144    VerifyFull,
145    /// Use an unencrypted connection in an explicitly isolated environment.
146    Plaintext,
147}
148
149impl TlsSetting {
150    fn parse(value: &str) -> Option<Self> {
151        match value {
152            "verify_full" => Some(Self::VerifyFull),
153            "plaintext" => Some(Self::Plaintext),
154            _ => None,
155        }
156    }
157
158    /// Returns the stable machine name of this mode.
159    #[must_use]
160    pub const fn as_str(self) -> &'static str {
161        match self {
162            Self::VerifyFull => "verify_full",
163            Self::Plaintext => "plaintext",
164        }
165    }
166}
167
168/// The closed set of configuration keys.
169///
170/// A key that is not in this table is rejected wherever it appears.
171#[derive(Clone, Copy, Debug, Eq, PartialEq)]
172struct KeySpec {
173    /// Dotted configuration path.
174    path: &'static str,
175    /// Namespaced environment variable.
176    env: &'static str,
177    /// Whether the value is secret bearing.
178    secret: bool,
179}
180
181const KEYS: &[KeySpec] = &[
182    KeySpec {
183        path: "repository.url",
184        env: "OXIDE_BATCH_REPOSITORY_URL",
185        secret: true,
186    },
187    KeySpec {
188        path: "repository.ca_certificate",
189        env: "OXIDE_BATCH_REPOSITORY_CA_CERTIFICATE",
190        secret: true,
191    },
192    KeySpec {
193        path: "repository.tls_mode",
194        env: "OXIDE_BATCH_REPOSITORY_TLS_MODE",
195        secret: false,
196    },
197    KeySpec {
198        path: "repository.pool_size",
199        env: "OXIDE_BATCH_REPOSITORY_POOL_SIZE",
200        secret: false,
201    },
202    KeySpec {
203        path: "repository.connect_timeout",
204        env: "OXIDE_BATCH_REPOSITORY_CONNECT_TIMEOUT",
205        secret: false,
206    },
207    KeySpec {
208        path: "repository.statement_timeout",
209        env: "OXIDE_BATCH_REPOSITORY_STATEMENT_TIMEOUT",
210        secret: false,
211    },
212    KeySpec {
213        path: "output.form",
214        env: "OXIDE_BATCH_OUTPUT_FORM",
215        secret: false,
216    },
217    KeySpec {
218        path: "output.page_size",
219        env: "OXIDE_BATCH_OUTPUT_PAGE_SIZE",
220        secret: false,
221    },
222    KeySpec {
223        path: "client.timeout",
224        env: "OXIDE_BATCH_CLIENT_TIMEOUT",
225        secret: false,
226    },
227];
228
229/// The suffix that supplies a value by file indirection instead of inline.
230const FILE_SUFFIX: &str = "__FILE";
231
232/// The effective configuration of one invocation.
233#[derive(Clone, Debug)]
234pub struct Configuration {
235    repository_url: Option<Resolved<Secret>>,
236    ca_certificate: Option<Resolved<Secret>>,
237    tls_mode: Resolved<TlsSetting>,
238    pool_size: Resolved<u32>,
239    connect_timeout: Resolved<Duration>,
240    statement_timeout: Resolved<Duration>,
241    output: Resolved<OutputForm>,
242    page_size: Resolved<u16>,
243    client_timeout: Resolved<Duration>,
244}
245
246impl Configuration {
247    /// Borrows the repository connection secret, when one was supplied.
248    #[must_use]
249    pub const fn repository_url(&self) -> Option<&Resolved<Secret>> {
250        self.repository_url.as_ref()
251    }
252
253    /// Borrows the PEM certificate-authority bundle, when one was supplied.
254    #[must_use]
255    pub const fn ca_certificate(&self) -> Option<&Resolved<Secret>> {
256        self.ca_certificate.as_ref()
257    }
258
259    /// Returns the selected transport security.
260    #[must_use]
261    pub const fn tls_mode(&self) -> TlsSetting {
262        self.tls_mode.get()
263    }
264
265    /// Returns the validated connection pool bound.
266    #[must_use]
267    pub const fn pool_size(&self) -> u32 {
268        self.pool_size.get()
269    }
270
271    /// Returns the validated connection establishment timeout.
272    #[must_use]
273    pub const fn connect_timeout(&self) -> Duration {
274        self.connect_timeout.get()
275    }
276
277    /// Returns the validated server-side statement timeout.
278    #[must_use]
279    pub const fn statement_timeout(&self) -> Duration {
280        self.statement_timeout.get()
281    }
282
283    /// Returns the effective output form.
284    #[must_use]
285    pub const fn output(&self) -> OutputForm {
286        self.output.get()
287    }
288
289    /// Returns the effective page bound.
290    #[must_use]
291    pub const fn page_size(&self) -> u16 {
292        self.page_size.get()
293    }
294
295    /// Returns the effective client deadline.
296    #[must_use]
297    pub const fn client_timeout(&self) -> Duration {
298        self.client_timeout.get()
299    }
300
301    /// Returns every effective value with its source and redaction status.
302    ///
303    /// The value column of a secret-bearing key is always the redaction
304    /// marker, never the value.
305    #[must_use]
306    pub fn effective(&self) -> Vec<EffectiveValue> {
307        let mut values = Vec::with_capacity(KEYS.len());
308        if let Some(resolved) = &self.repository_url {
309            values.push(EffectiveValue::secret("repository.url", resolved.source()));
310        }
311        if let Some(resolved) = &self.ca_certificate {
312            values.push(EffectiveValue::secret(
313                "repository.ca_certificate",
314                resolved.source(),
315            ));
316        }
317        values.push(EffectiveValue::plain(
318            "repository.tls_mode",
319            self.tls_mode.get().as_str().to_owned(),
320            self.tls_mode.source(),
321        ));
322        values.push(EffectiveValue::plain(
323            "repository.pool_size",
324            self.pool_size.get().to_string(),
325            self.pool_size.source(),
326        ));
327        values.push(EffectiveValue::plain(
328            "repository.connect_timeout",
329            format_duration(self.connect_timeout.get()),
330            self.connect_timeout.source(),
331        ));
332        values.push(EffectiveValue::plain(
333            "repository.statement_timeout",
334            format_duration(self.statement_timeout.get()),
335            self.statement_timeout.source(),
336        ));
337        values.push(EffectiveValue::plain(
338            "output.form",
339            self.output.get().as_str().to_owned(),
340            self.output.source(),
341        ));
342        values.push(EffectiveValue::plain(
343            "output.page_size",
344            self.page_size.get().to_string(),
345            self.page_size.source(),
346        ));
347        values.push(EffectiveValue::plain(
348            "client.timeout",
349            format_duration(self.client_timeout.get()),
350            self.client_timeout.source(),
351        ));
352        values.sort_by(|left, right| left.key.cmp(&right.key));
353        values
354    }
355}
356
357/// One row of `config show`.
358#[derive(Clone, Debug, Eq, PartialEq)]
359pub struct EffectiveValue {
360    key: String,
361    value: String,
362    source: Source,
363    redacted: bool,
364}
365
366impl EffectiveValue {
367    fn plain(key: &str, value: String, source: Source) -> Self {
368        Self {
369            key: key.to_owned(),
370            value,
371            source,
372            redacted: false,
373        }
374    }
375
376    fn secret(key: &str, source: Source) -> Self {
377        Self {
378            key: key.to_owned(),
379            value: "<redacted>".to_owned(),
380            source,
381            redacted: true,
382        }
383    }
384
385    /// Borrows the dotted configuration key.
386    #[must_use]
387    pub fn key(&self) -> &str {
388        &self.key
389    }
390
391    /// Borrows the displayable value or its redaction marker.
392    #[must_use]
393    pub fn value(&self) -> &str {
394        &self.value
395    }
396
397    /// Returns the source that supplied the value.
398    #[must_use]
399    pub const fn source(&self) -> Source {
400        self.source
401    }
402
403    /// Returns whether the displayed value is a redaction marker.
404    #[must_use]
405    pub const fn is_redacted(&self) -> bool {
406        self.redacted
407    }
408}
409
410/// One rejected configuration value.
411#[derive(Clone, Debug, Eq, PartialEq)]
412pub struct ConfigIssue {
413    key: String,
414    detail: String,
415}
416
417impl ConfigIssue {
418    fn new(key: impl Into<String>, detail: impl Into<String>) -> Self {
419        Self {
420            key: key.into(),
421            detail: detail.into(),
422        }
423    }
424
425    /// Borrows the rejected key.
426    #[must_use]
427    pub fn key(&self) -> &str {
428        &self.key
429    }
430
431    /// Borrows the safe-to-display reason.
432    #[must_use]
433    pub fn detail(&self) -> &str {
434        &self.detail
435    }
436}
437
438impl fmt::Display for ConfigIssue {
439    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
440        write!(formatter, "{}: {}", self.key, self.detail)
441    }
442}
443
444/// Every safe-to-display configuration conflict found in one pass.
445#[derive(Clone, Debug, Eq, PartialEq)]
446pub struct ConfigError {
447    issues: Vec<ConfigIssue>,
448}
449
450impl ConfigError {
451    fn single(issue: ConfigIssue) -> Self {
452        Self {
453            issues: vec![issue],
454        }
455    }
456
457    /// Borrows every reported issue.
458    #[must_use]
459    pub fn issues(&self) -> &[ConfigIssue] {
460        &self.issues
461    }
462}
463
464impl fmt::Display for ConfigError {
465    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
466        let mut first = true;
467        for issue in &self.issues {
468            if !first {
469                formatter.write_str("; ")?;
470            }
471            issue.fmt(formatter)?;
472            first = false;
473        }
474        Ok(())
475    }
476}
477
478impl std::error::Error for ConfigError {}
479
480/// Resolves the effective configuration of one invocation.
481///
482/// Resolution never opens a repository connection, so a configuration error is
483/// always reported before any connection attempt.
484///
485/// # Errors
486///
487/// Returns every safe-to-display unknown key, malformed value, out-of-bounds
488/// value, or unreadable indirection file found in one pass.
489pub fn resolve<H: Host>(host: &H, arguments: &Arguments) -> Result<Configuration, ConfigError> {
490    let mut issues = Vec::new();
491    let file = match load_file(host, arguments.config.as_deref()) {
492        Ok(values) => values,
493        Err(error) => {
494            // A file that cannot be parsed makes every file-sourced value
495            // unknowable, so resolution stops rather than silently falling
496            // back to defaults.
497            return Err(error);
498        }
499    };
500
501    let repository = resolve_repository(host, &file, &mut issues);
502
503    let output = enum_value_with_option(
504        host,
505        &file,
506        &mut issues,
507        "output.form",
508        arguments.output.as_deref(),
509        parse_output_form,
510        "human or json",
511    )
512    .unwrap_or_else(|| Resolved::new(OutputForm::default(), Source::Default));
513
514    let page_size = bounded_u16(
515        host,
516        &file,
517        &mut issues,
518        "output.page_size",
519        arguments.page_size.as_deref(),
520        1,
521        MAX_PAGE_SIZE,
522    )
523    .unwrap_or_else(|| Resolved::new(DEFAULT_PAGE_SIZE, Source::Default));
524
525    let client_timeout = bounded_duration(
526        host,
527        &file,
528        &mut issues,
529        "client.timeout",
530        arguments.timeout.as_deref(),
531        MIN_CLIENT_TIMEOUT,
532        MAX_CLIENT_TIMEOUT,
533    )
534    .unwrap_or_else(|| Resolved::new(DEFAULT_CLIENT_TIMEOUT, Source::Default));
535
536    if issues.is_empty() {
537        Ok(Configuration {
538            repository_url: repository.url,
539            ca_certificate: repository.ca_certificate,
540            tls_mode: repository.tls_mode,
541            pool_size: repository.pool_size,
542            connect_timeout: repository.connect_timeout,
543            statement_timeout: repository.statement_timeout,
544            output,
545            page_size,
546            client_timeout,
547        })
548    } else {
549        Err(ConfigError { issues })
550    }
551}
552
553/// The repository-class values of one invocation.
554///
555/// These are deployment controlled and secret bearing, and no command-line
556/// option supplies any of them, so they resolve from the environment, the
557/// configuration file, or a documented default only.
558struct RepositorySettings {
559    url: Option<Resolved<Secret>>,
560    ca_certificate: Option<Resolved<Secret>>,
561    tls_mode: Resolved<TlsSetting>,
562    pool_size: Resolved<u32>,
563    connect_timeout: Resolved<Duration>,
564    statement_timeout: Resolved<Duration>,
565}
566
567fn resolve_repository<H: Host>(
568    host: &H,
569    file: &BTreeMap<String, String>,
570    issues: &mut Vec<ConfigIssue>,
571) -> RepositorySettings {
572    let url = string_value(host, file, issues, "repository.url", None)
573        .map(|resolved| Resolved::new(Secret::new(resolved.value), resolved.source));
574    let ca_certificate = string_value(host, file, issues, "repository.ca_certificate", None)
575        .map(|resolved| Resolved::new(Secret::new(resolved.value), resolved.source));
576    let tls_mode = enum_value(
577        host,
578        file,
579        issues,
580        "repository.tls_mode",
581        TlsSetting::parse,
582        "verify_full or plaintext",
583    )
584    .unwrap_or_else(|| Resolved::new(TlsSetting::default(), Source::Default));
585    let pool_size = bounded_u32(
586        host,
587        file,
588        issues,
589        "repository.pool_size",
590        None,
591        1,
592        MAX_POOL_SIZE,
593    )
594    .unwrap_or_else(|| Resolved::new(DEFAULT_POOL_SIZE, Source::Default));
595    let connect_timeout = bounded_duration(
596        host,
597        file,
598        issues,
599        "repository.connect_timeout",
600        None,
601        MIN_BOUNDED_DURATION,
602        MAX_CONNECT_TIMEOUT,
603    )
604    .unwrap_or_else(|| Resolved::new(DEFAULT_CONNECT_TIMEOUT, Source::Default));
605    let statement_timeout = bounded_duration(
606        host,
607        file,
608        issues,
609        "repository.statement_timeout",
610        None,
611        MIN_BOUNDED_DURATION,
612        MAX_STATEMENT_TIMEOUT,
613    )
614    .unwrap_or_else(|| Resolved::new(DEFAULT_STATEMENT_TIMEOUT, Source::Default));
615    RepositorySettings {
616        url,
617        ca_certificate,
618        tls_mode,
619        pool_size,
620        connect_timeout,
621        statement_timeout,
622    }
623}
624
625fn parse_output_form(value: &str) -> Option<OutputForm> {
626    match value {
627        "human" => Some(OutputForm::Human),
628        "json" => Some(OutputForm::Json),
629        _ => None,
630    }
631}
632
633/// A raw value and the source that supplied it.
634struct RawValue {
635    value: String,
636    source: Source,
637}
638
639/// Applies per-value precedence for one key.
640///
641/// The first source that supplies the key wins, and a lower-priority source is
642/// not consulted for that key even though it may still supply another.
643fn raw_value<H: Host>(
644    host: &H,
645    file: &BTreeMap<String, String>,
646    issues: &mut Vec<ConfigIssue>,
647    path: &str,
648    option: Option<&str>,
649) -> Option<RawValue> {
650    if let Some(value) = option {
651        return Some(RawValue {
652            value: value.to_owned(),
653            source: Source::Option,
654        });
655    }
656    let spec = KEYS.iter().find(|key| key.path == path)?;
657    if let Some(value) = host.env(spec.env) {
658        return Some(RawValue {
659            value,
660            source: Source::Environment,
661        });
662    }
663    let env_file = format!("{}{FILE_SUFFIX}", spec.env);
664    if let Some(path_value) = host.env(&env_file) {
665        return read_secret_file(
666            host,
667            issues,
668            path,
669            Path::new(&path_value),
670            Source::Environment,
671        );
672    }
673    if let Some(value) = file.get(path) {
674        return Some(RawValue {
675            value: value.clone(),
676            source: Source::File,
677        });
678    }
679    let file_key = format!("{path}{FILE_SUFFIX}");
680    if let Some(path_value) = file.get(&file_key) {
681        return read_secret_file(host, issues, path, Path::new(path_value), Source::File);
682    }
683    None
684}
685
686/// Reads a value supplied by file indirection.
687fn read_secret_file<H: Host>(
688    host: &H,
689    issues: &mut Vec<ConfigIssue>,
690    path: &str,
691    file: &Path,
692    source: Source,
693) -> Option<RawValue> {
694    match host.read_file(file) {
695        Ok(bytes) if bytes.len() > MAX_SECRET_BYTES => {
696            issues.push(ConfigIssue::new(
697                path,
698                format!("the indirection file exceeds {MAX_SECRET_BYTES} bytes"),
699            ));
700            None
701        }
702        Ok(bytes) => {
703            if let Ok(value) = String::from_utf8(bytes) {
704                Some(RawValue {
705                    value: value.trim_end_matches(['\n', '\r']).to_owned(),
706                    source,
707                })
708            } else {
709                issues.push(ConfigIssue::new(
710                    path,
711                    "the indirection file is not valid UTF-8",
712                ));
713                None
714            }
715        }
716        Err(_) => {
717            // The path itself is never echoed, because a certificate or
718            // credential path is excluded from diagnostics.
719            issues.push(ConfigIssue::new(path, "the indirection file is unreadable"));
720            None
721        }
722    }
723}
724
725fn string_value<H: Host>(
726    host: &H,
727    file: &BTreeMap<String, String>,
728    issues: &mut Vec<ConfigIssue>,
729    path: &str,
730    option: Option<&str>,
731) -> Option<RawValue> {
732    let raw = raw_value(host, file, issues, path, option)?;
733    if raw.value.is_empty() {
734        issues.push(ConfigIssue::new(path, "the value must not be empty"));
735        return None;
736    }
737    Some(raw)
738}
739
740fn enum_value<H: Host, T>(
741    host: &H,
742    file: &BTreeMap<String, String>,
743    issues: &mut Vec<ConfigIssue>,
744    path: &str,
745    parse: fn(&str) -> Option<T>,
746    expected: &str,
747) -> Option<Resolved<T>> {
748    enum_value_with_option(host, file, issues, path, None, parse, expected)
749}
750
751fn enum_value_with_option<H: Host, T>(
752    host: &H,
753    file: &BTreeMap<String, String>,
754    issues: &mut Vec<ConfigIssue>,
755    path: &str,
756    option: Option<&str>,
757    parse: fn(&str) -> Option<T>,
758    expected: &str,
759) -> Option<Resolved<T>> {
760    let raw = raw_value(host, file, issues, path, option)?;
761    if let Some(value) = parse(&raw.value) {
762        Some(Resolved::new(value, raw.source))
763    } else {
764        issues.push(ConfigIssue::new(path, format!("expected {expected}")));
765        None
766    }
767}
768
769fn bounded_u16<H: Host>(
770    host: &H,
771    file: &BTreeMap<String, String>,
772    issues: &mut Vec<ConfigIssue>,
773    path: &str,
774    option: Option<&str>,
775    min: u16,
776    max: u16,
777) -> Option<Resolved<u16>> {
778    let raw = raw_value(host, file, issues, path, option)?;
779    match raw.value.parse::<u16>() {
780        Ok(value) if (min..=max).contains(&value) => Some(Resolved::new(value, raw.source)),
781        _ => {
782            issues.push(ConfigIssue::new(
783                path,
784                format!("expected an integer in {min}..={max}"),
785            ));
786            None
787        }
788    }
789}
790
791fn bounded_u32<H: Host>(
792    host: &H,
793    file: &BTreeMap<String, String>,
794    issues: &mut Vec<ConfigIssue>,
795    path: &str,
796    option: Option<&str>,
797    min: u32,
798    max: u32,
799) -> Option<Resolved<u32>> {
800    let raw = raw_value(host, file, issues, path, option)?;
801    match raw.value.parse::<u32>() {
802        Ok(value) if (min..=max).contains(&value) => Some(Resolved::new(value, raw.source)),
803        _ => {
804            issues.push(ConfigIssue::new(
805                path,
806                format!("expected an integer in {min}..={max}"),
807            ));
808            None
809        }
810    }
811}
812
813fn bounded_duration<H: Host>(
814    host: &H,
815    file: &BTreeMap<String, String>,
816    issues: &mut Vec<ConfigIssue>,
817    path: &str,
818    option: Option<&str>,
819    min: Duration,
820    max: Duration,
821) -> Option<Resolved<Duration>> {
822    let raw = raw_value(host, file, issues, path, option)?;
823    match parse_duration(&raw.value) {
824        Some(value) if value >= min && value <= max => Some(Resolved::new(value, raw.source)),
825        _ => {
826            issues.push(ConfigIssue::new(
827                path,
828                format!(
829                    "expected a duration in {}..={}",
830                    format_duration(min),
831                    format_duration(max)
832                ),
833            ));
834            None
835        }
836    }
837}
838
839/// Parses a bounded duration written as an integer and a unit.
840///
841/// The accepted units are `ms`, `s`, `m`, `h`, and `d`. A bare integer is
842/// rejected so that a unit is always explicit.
843fn parse_duration(value: &str) -> Option<Duration> {
844    let split = value
845        .find(|character: char| !character.is_ascii_digit())
846        .filter(|index| *index > 0)?;
847    let (digits, unit) = value.split_at(split);
848    let amount: u64 = digits.parse().ok()?;
849    let millis = match unit {
850        "ms" => amount,
851        "s" => amount.checked_mul(1_000)?,
852        "m" => amount.checked_mul(60 * 1_000)?,
853        "h" => amount.checked_mul(60 * 60 * 1_000)?,
854        "d" => amount.checked_mul(24 * 60 * 60 * 1_000)?,
855        _ => return None,
856    };
857    Some(Duration::from_millis(millis))
858}
859
860/// Renders a duration in the largest unit that divides it exactly.
861fn format_duration(value: Duration) -> String {
862    let millis = u64::try_from(value.as_millis()).unwrap_or(u64::MAX);
863    for (unit, scale) in [
864        ("d", 24 * 60 * 60 * 1_000_u64),
865        ("h", 60 * 60 * 1_000),
866        ("m", 60 * 1_000),
867        ("s", 1_000),
868    ] {
869        if millis >= scale && millis % scale == 0 {
870            return format!("{}{unit}", millis / scale);
871        }
872    }
873    format!("{millis}ms")
874}
875
876/// Reads and flattens the configuration file, if one was named.
877fn load_file<H: Host>(
878    host: &H,
879    path: Option<&Path>,
880) -> Result<BTreeMap<String, String>, ConfigError> {
881    let Some(path) = path else {
882        return Ok(BTreeMap::new());
883    };
884    let mode = host.file_mode(path).map_err(|_| {
885        ConfigError::single(ConfigIssue::new(
886            "config",
887            "the configuration file is unreadable",
888        ))
889    })?;
890    if let Some(mode) = mode
891        && mode & 0o077 != 0
892    {
893        return Err(ConfigError::single(ConfigIssue::new(
894            "config",
895            "the configuration file is group or world readable",
896        )));
897    }
898    let bytes = host.read_file(path).map_err(|_| {
899        ConfigError::single(ConfigIssue::new(
900            "config",
901            "the configuration file is unreadable",
902        ))
903    })?;
904    if bytes.len() > MAX_CONFIG_BYTES {
905        return Err(ConfigError::single(ConfigIssue::new(
906            "config",
907            format!("the configuration file exceeds {MAX_CONFIG_BYTES} bytes"),
908        )));
909    }
910    let document: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| {
911        ConfigError::single(ConfigIssue::new(
912            "config",
913            "the configuration file is not valid JSON",
914        ))
915    })?;
916    flatten(&document)
917}
918
919/// Flattens the document and rejects every key outside the closed set.
920fn flatten(document: &serde_json::Value) -> Result<BTreeMap<String, String>, ConfigError> {
921    let serde_json::Value::Object(root) = document else {
922        return Err(ConfigError::single(ConfigIssue::new(
923            "config",
924            "the configuration file must be a JSON object",
925        )));
926    };
927    let mut issues = Vec::new();
928    match root
929        .get("config_version")
930        .and_then(serde_json::Value::as_u64)
931    {
932        Some(version) if version == CONFIG_VERSION => {}
933        Some(_) => issues.push(ConfigIssue::new(
934            "config_version",
935            format!("expected version {CONFIG_VERSION}"),
936        )),
937        None => issues.push(ConfigIssue::new(
938            "config_version",
939            format!("the configuration file must declare version {CONFIG_VERSION}"),
940        )),
941    }
942    let mut values = BTreeMap::new();
943    for (name, value) in root {
944        if name == "config_version" {
945            continue;
946        }
947        collect(name, value, 1, &mut values, &mut issues);
948    }
949    for key in values.keys() {
950        let base = key.strip_suffix(FILE_SUFFIX).unwrap_or(key);
951        let Some(spec) = KEYS.iter().find(|candidate| candidate.path == base) else {
952            issues.push(ConfigIssue::new(key.clone(), "unknown configuration key"));
953            continue;
954        };
955        if key.ends_with(FILE_SUFFIX) && !spec.secret {
956            issues.push(ConfigIssue::new(
957                key.clone(),
958                "file indirection applies only to a secret-bearing key",
959            ));
960        }
961        if values.contains_key(base) && values.contains_key(&format!("{base}{FILE_SUFFIX}")) {
962            issues.push(ConfigIssue::new(
963                base.to_owned(),
964                "the inline value and its file indirection cannot both be supplied",
965            ));
966        }
967    }
968    if issues.is_empty() {
969        Ok(values)
970    } else {
971        issues.sort_by(|left, right| left.key.cmp(&right.key));
972        issues.dedup();
973        Err(ConfigError { issues })
974    }
975}
976
977/// Walks one configuration subtree into dotted keys.
978fn collect(
979    prefix: &str,
980    value: &serde_json::Value,
981    depth: usize,
982    values: &mut BTreeMap<String, String>,
983    issues: &mut Vec<ConfigIssue>,
984) {
985    if depth > MAX_CONFIG_DEPTH {
986        issues.push(ConfigIssue::new(
987            prefix.to_owned(),
988            format!("the configuration file nests deeper than {MAX_CONFIG_DEPTH} levels"),
989        ));
990        return;
991    }
992    match value {
993        serde_json::Value::Object(entries) => {
994            for (name, entry) in entries {
995                collect(
996                    &format!("{prefix}.{name}"),
997                    entry,
998                    depth + 1,
999                    values,
1000                    issues,
1001                );
1002            }
1003        }
1004        serde_json::Value::String(text) => {
1005            values.insert(prefix.to_owned(), text.clone());
1006        }
1007        serde_json::Value::Number(number) => {
1008            values.insert(prefix.to_owned(), number.to_string());
1009        }
1010        serde_json::Value::Bool(flag) => {
1011            values.insert(prefix.to_owned(), flag.to_string());
1012        }
1013        serde_json::Value::Null | serde_json::Value::Array(_) => {
1014            issues.push(ConfigIssue::new(
1015                prefix.to_owned(),
1016                "expected a string, number, or boolean",
1017            ));
1018        }
1019    }
1020}
1021
1022/// Parses a bounded duration written as an integer and a unit.
1023///
1024/// The accepted units are `ms`, `s`, `m`, `h`, and `d`. This is the same
1025/// grammar configuration values use, so an age bound and a timeout are written
1026/// the same way.
1027#[must_use]
1028pub fn parse_public_duration(value: &str) -> Option<Duration> {
1029    parse_duration(value)
1030}
1031
1032/// Returns the environment variable that supplies one configuration key.
1033#[must_use]
1034pub fn environment_variable(path: &str) -> Option<&'static str> {
1035    KEYS.iter().find(|key| key.path == path).map(|key| key.env)
1036}
1037
1038/// Returns every accepted configuration key in canonical order.
1039#[must_use]
1040pub fn known_keys() -> Vec<&'static str> {
1041    let mut keys: Vec<&'static str> = KEYS.iter().map(|key| key.path).collect();
1042    keys.sort_unstable();
1043    keys
1044}
1045
1046/// Returns the canonical configuration file path a deployment may use.
1047#[must_use]
1048pub fn default_config_path() -> PathBuf {
1049    PathBuf::from("oxide-batch.json")
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054    #![allow(clippy::expect_used, clippy::panic)]
1055
1056    use super::{Source, TlsSetting, format_duration, parse_duration, resolve};
1057    use crate::args::{Arguments, OutputForm};
1058    use crate::host::testing::TestHost;
1059    use std::path::PathBuf;
1060    use std::time::Duration;
1061
1062    fn arguments() -> Arguments {
1063        Arguments::default()
1064    }
1065
1066    #[test]
1067    fn defaults_apply_without_any_source() {
1068        let host = TestHost::new();
1069        let config = resolve(&host, &arguments()).expect("defaults are valid");
1070        assert_eq!(config.page_size(), 50);
1071        assert_eq!(config.output(), OutputForm::Human);
1072        assert_eq!(config.client_timeout(), Duration::from_mins(1));
1073        assert_eq!(config.tls_mode(), TlsSetting::VerifyFull);
1074    }
1075
1076    #[test]
1077    fn an_option_outranks_the_environment() {
1078        let host = TestHost::new().with_env("OXIDE_BATCH_OUTPUT_PAGE_SIZE", "10");
1079        let mut arguments = arguments();
1080        arguments.page_size = Some("25".to_owned());
1081        let config = resolve(&host, &arguments).expect("the value is valid");
1082        assert_eq!(config.page_size(), 25);
1083        assert_eq!(config.effective_source("output.page_size"), Source::Option);
1084    }
1085
1086    #[test]
1087    fn precedence_is_resolved_per_value() {
1088        let host = TestHost::new()
1089            .with_file(
1090                "/etc/oxide-batch.json",
1091                r#"{"config_version":1,"repository":{"pool_size":7},"output":{"page_size":10}}"#,
1092            )
1093            .with_env("OXIDE_BATCH_OUTPUT_FORM", "json");
1094        let mut arguments = arguments();
1095        arguments.config = Some(PathBuf::from("/etc/oxide-batch.json"));
1096        arguments.page_size = Some("25".to_owned());
1097        let config = resolve(&host, &arguments).expect("the values are valid");
1098
1099        assert_eq!(config.page_size(), 25);
1100        assert_eq!(config.effective_source("output.page_size"), Source::Option);
1101        assert_eq!(config.output(), OutputForm::Json);
1102        assert_eq!(config.effective_source("output.form"), Source::Environment);
1103        assert_eq!(config.pool_size(), 7);
1104        assert_eq!(
1105            config.effective_source("repository.pool_size"),
1106            Source::File
1107        );
1108        assert_eq!(config.client_timeout(), Duration::from_mins(1));
1109        assert_eq!(config.effective_source("client.timeout"), Source::Default);
1110    }
1111
1112    #[test]
1113    fn an_unknown_configuration_key_fails() {
1114        let host = TestHost::new().with_file(
1115            "/etc/oxide-batch.json",
1116            r#"{"config_version":1,"output":{"colour":"green"}}"#,
1117        );
1118        let mut arguments = arguments();
1119        arguments.config = Some(PathBuf::from("/etc/oxide-batch.json"));
1120        let error = resolve(&host, &arguments).expect_err("the key is unknown");
1121        assert!(
1122            error
1123                .issues()
1124                .iter()
1125                .any(|issue| issue.key() == "output.colour")
1126        );
1127    }
1128
1129    #[test]
1130    fn a_world_readable_configuration_file_is_rejected() {
1131        let host = TestHost::new()
1132            .with_file("/etc/oxide-batch.json", r#"{"config_version":1}"#)
1133            .with_mode("/etc/oxide-batch.json", 0o644);
1134        let mut arguments = arguments();
1135        arguments.config = Some(PathBuf::from("/etc/oxide-batch.json"));
1136        let error = resolve(&host, &arguments).expect_err("the file is too permissive");
1137        assert!(
1138            error
1139                .issues()
1140                .iter()
1141                .any(|issue| issue.detail().contains("group or world readable"))
1142        );
1143    }
1144
1145    #[test]
1146    fn a_missing_configuration_version_fails() {
1147        let host =
1148            TestHost::new().with_file("/etc/oxide-batch.json", r#"{"output":{"form":"json"}}"#);
1149        let mut arguments = arguments();
1150        arguments.config = Some(PathBuf::from("/etc/oxide-batch.json"));
1151        let error = resolve(&host, &arguments).expect_err("the version is required");
1152        assert!(
1153            error
1154                .issues()
1155                .iter()
1156                .any(|issue| issue.key() == "config_version")
1157        );
1158    }
1159
1160    #[test]
1161    fn every_safe_conflict_is_reported_in_one_pass() {
1162        let host = TestHost::new()
1163            .with_env("OXIDE_BATCH_OUTPUT_PAGE_SIZE", "9000")
1164            .with_env("OXIDE_BATCH_CLIENT_TIMEOUT", "4h")
1165            .with_env("OXIDE_BATCH_REPOSITORY_TLS_MODE", "maybe");
1166        let error = resolve(&host, &arguments()).expect_err("the values are out of bounds");
1167        assert_eq!(error.issues().len(), 3);
1168    }
1169
1170    #[test]
1171    fn a_secret_is_read_by_file_indirection() {
1172        let host = TestHost::new()
1173            .with_file("/run/secrets/url", "postgres://localhost/batch\n")
1174            .with_env("OXIDE_BATCH_REPOSITORY_URL__FILE", "/run/secrets/url");
1175        let config = resolve(&host, &arguments()).expect("the secret is readable");
1176        let url = config.repository_url().expect("the url is present");
1177        assert_eq!(url.value().expose(), "postgres://localhost/batch");
1178        assert_eq!(url.source(), Source::Environment);
1179    }
1180
1181    #[test]
1182    fn a_secret_never_renders_its_value() {
1183        let host =
1184            TestHost::new().with_env("OXIDE_BATCH_REPOSITORY_URL", "postgres://secret@host/db");
1185        let config = resolve(&host, &arguments()).expect("the secret is valid");
1186        let url = config.repository_url().expect("the url is present");
1187        assert_eq!(format!("{}", url.value()), "<redacted>");
1188        assert_eq!(format!("{:?}", url.value()), "<redacted>");
1189        let rendered = config
1190            .effective()
1191            .into_iter()
1192            .find(|value| value.key() == "repository.url")
1193            .expect("the row is present");
1194        assert!(rendered.is_redacted());
1195        assert_eq!(rendered.value(), "<redacted>");
1196    }
1197
1198    #[test]
1199    fn durations_round_trip_through_their_largest_exact_unit() {
1200        assert_eq!(parse_duration("30s"), Some(Duration::from_secs(30)));
1201        assert_eq!(parse_duration("5m"), Some(Duration::from_mins(5)));
1202        assert_eq!(parse_duration("1h"), Some(Duration::from_hours(1)));
1203        assert_eq!(parse_duration("250ms"), Some(Duration::from_millis(250)));
1204        assert_eq!(parse_duration("30"), None);
1205        assert_eq!(parse_duration("s"), None);
1206        assert_eq!(parse_duration("30x"), None);
1207        assert_eq!(format_duration(Duration::from_mins(5)), "5m");
1208        assert_eq!(format_duration(Duration::from_millis(1500)), "1500ms");
1209    }
1210
1211    impl super::Configuration {
1212        fn effective_source(&self, key: &str) -> Source {
1213            self.effective()
1214                .into_iter()
1215                .find(|value| value.key() == key)
1216                .map_or(Source::Default, |value| value.source())
1217        }
1218    }
1219}