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