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