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