pg_embedded_setup_unpriv/bootstrap/
env.rs

1//! Parses environment variables used by the bootstrapper and surfaces the
2//! resulting configuration for the filesystem preparers.
3
4use std::env::{self, VarError};
5use std::fs;
6use std::path::PathBuf;
7use std::time::Duration;
8
9use camino::{Utf8Path, Utf8PathBuf};
10
11use crate::error::{BootstrapError, BootstrapErrorKind, BootstrapResult};
12
13#[cfg(unix)]
14use std::os::unix::fs::PermissionsExt;
15
16pub(super) const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(15);
17const MAX_SHUTDOWN_TIMEOUT_SECS: u64 = 600;
18const SHUTDOWN_TIMEOUT_ENV: &str = "PG_SHUTDOWN_TIMEOUT_SECS";
19
20pub(super) fn shutdown_timeout_from_env() -> BootstrapResult<Duration> {
21    match env::var(SHUTDOWN_TIMEOUT_ENV) {
22        Ok(raw) => {
23            let trimmed = raw.trim();
24            if trimmed.is_empty() {
25                return Err(BootstrapError::from(color_eyre::eyre::eyre!(
26                    "{SHUTDOWN_TIMEOUT_ENV} is present but empty"
27                )));
28            }
29
30            let seconds: u64 = trimmed.parse().map_err(|err| {
31                BootstrapError::from(color_eyre::eyre::eyre!(
32                    "failed to parse {SHUTDOWN_TIMEOUT_ENV} from '{trimmed}': {err}"
33                ))
34            })?;
35
36            if seconds == 0 {
37                return Err(BootstrapError::from(color_eyre::eyre::eyre!(
38                    "{SHUTDOWN_TIMEOUT_ENV} must be at least 1 second (received {trimmed})"
39                )));
40            }
41
42            if seconds > MAX_SHUTDOWN_TIMEOUT_SECS {
43                return Err(BootstrapError::from(color_eyre::eyre::eyre!(
44                    "{SHUTDOWN_TIMEOUT_ENV} must be {MAX_SHUTDOWN_TIMEOUT_SECS} seconds or less (received {trimmed})"
45                )));
46            }
47
48            Ok(Duration::from_secs(seconds))
49        }
50        Err(VarError::NotPresent) => Ok(DEFAULT_SHUTDOWN_TIMEOUT),
51        Err(VarError::NotUnicode(value)) => Err(BootstrapError::from(color_eyre::eyre::eyre!(
52            "{SHUTDOWN_TIMEOUT_ENV} must contain a valid UTF-8 value (received {:?})",
53            value
54        ))),
55    }
56}
57
58pub(super) fn worker_binary_from_env() -> BootstrapResult<Option<Utf8PathBuf>> {
59    let Some(raw) = env::var_os("PG_EMBEDDED_WORKER") else {
60        return Ok(None);
61    };
62
63    let path = Utf8PathBuf::from_path_buf(PathBuf::from(&raw)).map_err(|_| {
64        let invalid_value = raw.to_string_lossy().to_string();
65        BootstrapError::from(color_eyre::eyre::eyre!(
66            "PG_EMBEDDED_WORKER contains a non-UTF-8 value: {invalid_value:?}. \
67             Provide a UTF-8 encoded absolute path to the worker binary."
68        ))
69    })?;
70
71    validate_worker_binary(&path)?;
72    Ok(Some(path))
73}
74
75fn validate_worker_binary(path: &Utf8PathBuf) -> BootstrapResult<()> {
76    let metadata = fs::metadata(path.as_std_path()).map_err(|err| {
77        if err.kind() == std::io::ErrorKind::NotFound {
78            return BootstrapError::new(
79                BootstrapErrorKind::WorkerBinaryMissing,
80                color_eyre::eyre::eyre!("failed to access PG_EMBEDDED_WORKER at {path}: {err}"),
81            );
82        }
83
84        BootstrapError::from(color_eyre::eyre::eyre!(
85            "failed to access PG_EMBEDDED_WORKER at {path}: {err}"
86        ))
87    })?;
88
89    if !metadata.is_file() {
90        return Err(BootstrapError::from(color_eyre::eyre::eyre!(
91            "PG_EMBEDDED_WORKER must reference a regular file: {path}"
92        )));
93    }
94
95    #[cfg(unix)]
96    {
97        if metadata.permissions().mode() & 0o111 == 0 {
98            return Err(BootstrapError::from(color_eyre::eyre::eyre!(
99                "PG_EMBEDDED_WORKER must be executable: {path}"
100            )));
101        }
102    }
103
104    Ok(())
105}
106
107#[derive(Debug, Clone)]
108pub(super) struct TimezoneEnv {
109    pub(super) dir: Option<Utf8PathBuf>,
110    pub(super) zone: String,
111}
112
113#[derive(Debug, Clone)]
114pub struct TestBootstrapEnvironment {
115    /// Effective home directory for the `PostgreSQL` user during the tests.
116    pub home: Utf8PathBuf,
117    /// Directory used for cached `PostgreSQL` artefacts.
118    pub xdg_cache_home: Utf8PathBuf,
119    /// Directory used for `PostgreSQL` runtime state, such as sockets.
120    pub xdg_runtime_dir: Utf8PathBuf,
121    /// Location of the generated `.pgpass` file.
122    pub pgpass_file: Utf8PathBuf,
123    /// Resolved time zone database directory, if discovery succeeded.
124    pub tz_dir: Option<Utf8PathBuf>,
125    /// Time zone identifier exported via the `TZ` environment variable.
126    pub timezone: String,
127}
128
129impl TestBootstrapEnvironment {
130    pub(super) fn from_components(
131        xdg: XdgDirs,
132        pgpass_file: Utf8PathBuf,
133        timezone: TimezoneEnv,
134    ) -> Self {
135        Self {
136            home: xdg.home,
137            xdg_cache_home: xdg.cache,
138            xdg_runtime_dir: xdg.runtime,
139            pgpass_file,
140            tz_dir: timezone.dir,
141            timezone: timezone.zone,
142        }
143    }
144
145    /// Returns the prepared environment variables as key/value pairs.
146    ///
147    /// # Examples
148    /// ```
149    /// use pg_embedded_setup_unpriv::TestBootstrapEnvironment;
150    /// use camino::Utf8PathBuf;
151    ///
152    /// let env = TestBootstrapEnvironment {
153    ///     home: Utf8PathBuf::from("/tmp/home"),
154    ///     xdg_cache_home: Utf8PathBuf::from("/tmp/home/cache"),
155    ///     xdg_runtime_dir: Utf8PathBuf::from("/tmp/home/run"),
156    ///     pgpass_file: Utf8PathBuf::from("/tmp/home/.pgpass"),
157    ///     tz_dir: None,
158    ///     timezone: "UTC".into(),
159    /// };
160    /// assert_eq!(env.to_env().len(), 6);
161    /// ```
162    #[must_use]
163    pub fn to_env(&self) -> Vec<(String, Option<String>)> {
164        let mut env = vec![
165            ("HOME".into(), Some(self.home.as_str().into())),
166            (
167                "XDG_CACHE_HOME".into(),
168                Some(self.xdg_cache_home.as_str().into()),
169            ),
170            (
171                "XDG_RUNTIME_DIR".into(),
172                Some(self.xdg_runtime_dir.as_str().into()),
173            ),
174            ("PGPASSFILE".into(), Some(self.pgpass_file.as_str().into())),
175        ];
176
177        env.push((
178            "TZDIR".into(),
179            self.tz_dir.as_ref().map(|dir| dir.as_str().into()),
180        ));
181
182        env.push(("TZ".into(), Some(self.timezone.clone())));
183
184        env
185    }
186}
187
188#[derive(Debug, Clone)]
189pub(super) struct XdgDirs {
190    pub(super) home: Utf8PathBuf,
191    pub(super) cache: Utf8PathBuf,
192    pub(super) runtime: Utf8PathBuf,
193}
194
195pub(super) fn prepare_timezone_env() -> BootstrapResult<TimezoneEnv> {
196    const DEFAULT_TIMEZONE: &str = "UTC";
197
198    let tz_dir = if let Some(dir) = env::var_os("TZDIR") {
199        let path = Utf8PathBuf::from_path_buf(PathBuf::from(dir)).map_err(
200            |_| -> crate::error::BootstrapError {
201                color_eyre::eyre::eyre!("TZDIR must be valid UTF-8").into()
202            },
203        )?;
204        if !path.exists() {
205            return Err(color_eyre::eyre::eyre!(
206                "time zone database not found at {}. Set TZDIR or install tzdata.",
207                path
208            )
209            .into());
210        }
211        Some(path)
212    } else {
213        discover_timezone_dir()?
214    };
215
216    let timezone = match env::var("TZ") {
217        Ok(value) if !value.trim().is_empty() => value,
218        Ok(_) | Err(std::env::VarError::NotPresent) => DEFAULT_TIMEZONE.to_owned(),
219        Err(std::env::VarError::NotUnicode(_)) => {
220            return Err(color_eyre::eyre::eyre!("TZ must be valid UTF-8").into());
221        }
222    };
223
224    Ok(TimezoneEnv {
225        dir: tz_dir,
226        zone: timezone,
227    })
228}
229
230fn discover_timezone_dir() -> BootstrapResult<Option<Utf8PathBuf>> {
231    #[cfg(unix)]
232    {
233        static CANDIDATES: [&str; 4] = [
234            "/usr/share/zoneinfo",
235            "/usr/lib/zoneinfo",
236            "/etc/zoneinfo",
237            "/share/zoneinfo",
238        ];
239
240        let candidate = CANDIDATES
241            .iter()
242            .map(Utf8Path::new)
243            .find(|path| path.exists())
244            .ok_or_else(|| -> crate::error::BootstrapError {
245                color_eyre::eyre::eyre!(
246                    "time zone database not found. Set TZDIR or install tzdata."
247                )
248                .into()
249            })?;
250
251        Ok(Some(candidate.to_owned()))
252    }
253
254    #[cfg(not(unix))]
255    {
256        Ok(None)
257    }
258}