Skip to main content

oliphaunt_wasix/oliphaunt/
config.rs

1use std::collections::BTreeMap;
2
3use anyhow::{Result, bail, ensure};
4
5use crate::oliphaunt::interface::DebugLevel;
6
7/// PostgreSQL startup configuration applied through normal `postgres -c` GUC
8/// handling before the embedded backend starts.
9///
10/// Settings added here override `oliphaunt-wasix`'s default startup profile because
11/// they are appended after the defaults in the generated PostgreSQL argv.
12#[derive(Debug, Clone, Default, PartialEq, Eq)]
13pub struct PostgresConfig {
14    settings: BTreeMap<String, String>,
15}
16
17impl PostgresConfig {
18    /// Create an empty startup configuration.
19    pub fn new() -> Self {
20        Self::default()
21    }
22
23    /// Set or replace one PostgreSQL GUC.
24    pub fn set(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
25        self.settings.insert(name.into(), value.into());
26        self
27    }
28
29    pub(crate) fn insert(&mut self, name: impl Into<String>, value: impl Into<String>) {
30        self.settings.insert(name.into(), value.into());
31    }
32
33    #[cfg(feature = "extensions")]
34    pub(crate) fn get(&self, name: &str) -> Option<&str> {
35        self.settings.get(name).map(String::as_str)
36    }
37
38    pub(crate) fn validate(&self) -> Result<()> {
39        for (name, value) in &self.settings {
40            validate_guc_name(name)?;
41            ensure!(
42                !value.contains('\0'),
43                "Postgres config value for '{name}' must not contain NUL bytes"
44            );
45        }
46        Ok(())
47    }
48
49    pub(crate) fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
50        self.settings
51            .iter()
52            .map(|(name, value)| (name.as_str(), value.as_str()))
53    }
54
55    #[cfg(feature = "extensions")]
56    pub(crate) fn stable_entries(&self) -> Vec<(String, String)> {
57        self.settings
58            .iter()
59            .map(|(name, value)| (name.clone(), value.clone()))
60            .collect()
61    }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub(crate) struct StartupConfig {
66    pub(crate) username: String,
67    pub(crate) database: String,
68    pub(crate) debug_level: Option<DebugLevel>,
69    pub(crate) relaxed_durability: bool,
70    pub(crate) extra_args: Vec<String>,
71}
72
73impl Default for StartupConfig {
74    fn default() -> Self {
75        Self {
76            username: "postgres".to_owned(),
77            database: "template1".to_owned(),
78            debug_level: None,
79            relaxed_durability: false,
80            extra_args: Vec::new(),
81        }
82    }
83}
84
85impl StartupConfig {
86    pub(crate) fn validate(&self) -> Result<()> {
87        validate_startup_value("username", &self.username)?;
88        validate_startup_value("database", &self.database)?;
89        if let Some(level) = self.debug_level {
90            ensure!(
91                level <= 5,
92                "Postgres debug level must be between 0 and 5, got {level}"
93            );
94        }
95        for arg in &self.extra_args {
96            ensure!(
97                !arg.contains('\0'),
98                "Postgres startup argument must not contain NUL bytes"
99            );
100        }
101        Ok(())
102    }
103}
104
105fn validate_guc_name(name: &str) -> Result<()> {
106    ensure!(!name.is_empty(), "Postgres config name must not be empty");
107    ensure!(
108        !name.contains('\0') && !name.contains('='),
109        "Postgres config name '{name}' must not contain NUL bytes or '='"
110    );
111
112    for part in name.split('.') {
113        if part.is_empty() {
114            bail!("Postgres config name '{name}' contains an empty identifier part");
115        }
116        let mut chars = part.chars();
117        let first = chars.next().expect("part is non-empty");
118        if !(first == '_' || first.is_ascii_alphabetic()) {
119            bail!("Postgres config name '{name}' must start each identifier with a letter or '_'");
120        }
121        if chars.any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric())) {
122            bail!("Postgres config name '{name}' may only contain letters, digits, '_', and '.'");
123        }
124    }
125
126    Ok(())
127}
128
129fn validate_startup_value(name: &str, value: &str) -> Result<()> {
130    ensure!(
131        !value.is_empty(),
132        "Postgres startup {name} must not be empty"
133    );
134    ensure!(
135        !value.contains('\0'),
136        "Postgres startup {name} must not contain NUL bytes"
137    );
138    Ok(())
139}
140
141#[cfg(test)]
142mod tests {
143    use super::PostgresConfig;
144
145    #[test]
146    fn validates_builtin_and_extension_guc_names() {
147        PostgresConfig::new()
148            .set("synchronous_commit", "off")
149            .set("pg_stat_statements.track", "all")
150            .validate()
151            .unwrap();
152    }
153
154    #[test]
155    fn rejects_invalid_guc_names_before_startup() {
156        let err = PostgresConfig::new()
157            .set("bad=name", "off")
158            .validate()
159            .expect_err("invalid GUC name should be rejected");
160        assert!(err.to_string().contains("must not contain"));
161    }
162}