1use 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
17pub const CONFIG_VERSION: u64 = 1;
19
20const MAX_CONFIG_BYTES: usize = 256 * 1024;
22const MAX_SECRET_BYTES: usize = 64 * 1024;
24const 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
42pub enum Source {
43 Option,
45 Environment,
47 File,
49 Default,
51}
52
53impl Source {
54 #[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#[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 pub const fn value(&self) -> &T {
86 &self.value
87 }
88
89 #[must_use]
91 pub const fn source(&self) -> Source {
92 self.source
93 }
94}
95
96impl<T: Copy> Resolved<T> {
97 pub const fn get(&self) -> T {
99 self.value
100 }
101}
102
103#[derive(Clone, Eq, PartialEq)]
108pub struct Secret(String);
109
110impl Secret {
111 #[must_use]
113 pub fn new(value: impl Into<String>) -> Self {
114 Self(value.into())
115 }
116
117 #[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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
141pub enum TlsSetting {
142 #[default]
144 VerifyFull,
145 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 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
172struct KeySpec {
173 path: &'static str,
175 env: &'static str,
177 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
229const FILE_SUFFIX: &str = "__FILE";
231
232#[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 #[must_use]
249 pub const fn repository_url(&self) -> Option<&Resolved<Secret>> {
250 self.repository_url.as_ref()
251 }
252
253 #[must_use]
255 pub const fn ca_certificate(&self) -> Option<&Resolved<Secret>> {
256 self.ca_certificate.as_ref()
257 }
258
259 #[must_use]
261 pub const fn tls_mode(&self) -> TlsSetting {
262 self.tls_mode.get()
263 }
264
265 #[must_use]
267 pub const fn pool_size(&self) -> u32 {
268 self.pool_size.get()
269 }
270
271 #[must_use]
273 pub const fn connect_timeout(&self) -> Duration {
274 self.connect_timeout.get()
275 }
276
277 #[must_use]
279 pub const fn statement_timeout(&self) -> Duration {
280 self.statement_timeout.get()
281 }
282
283 #[must_use]
285 pub const fn output(&self) -> OutputForm {
286 self.output.get()
287 }
288
289 #[must_use]
291 pub const fn page_size(&self) -> u16 {
292 self.page_size.get()
293 }
294
295 #[must_use]
297 pub const fn client_timeout(&self) -> Duration {
298 self.client_timeout.get()
299 }
300
301 #[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#[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 #[must_use]
387 pub fn key(&self) -> &str {
388 &self.key
389 }
390
391 #[must_use]
393 pub fn value(&self) -> &str {
394 &self.value
395 }
396
397 #[must_use]
399 pub const fn source(&self) -> Source {
400 self.source
401 }
402
403 #[must_use]
405 pub const fn is_redacted(&self) -> bool {
406 self.redacted
407 }
408}
409
410#[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 #[must_use]
427 pub fn key(&self) -> &str {
428 &self.key
429 }
430
431 #[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#[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 #[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
480pub 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 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
553struct 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
633struct RawValue {
635 value: String,
636 source: Source,
637}
638
639fn 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
686fn 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 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
839fn 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
860fn 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
876fn 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
919fn 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
977fn 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#[must_use]
1028pub fn parse_public_duration(value: &str) -> Option<Duration> {
1029 parse_duration(value)
1030}
1031
1032#[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#[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#[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}