pg_embedded_setup_unpriv/
lib.rs

1//! Facilitates preparing an embedded `PostgreSQL` instance while dropping root
2//! privileges.
3//!
4//! The library owns the lifecycle for configuring paths, permissions, and
5//! process identity so the bundled `PostgreSQL` binaries can initialise safely
6//! under an unprivileged account.
7
8mod bootstrap;
9pub mod cache;
10mod cluster;
11mod env;
12mod error;
13mod fs;
14mod observability;
15#[cfg(all(
16    unix,
17    any(
18        target_os = "linux",
19        target_os = "android",
20        target_os = "freebsd",
21        target_os = "openbsd",
22        target_os = "dragonfly",
23    ),
24))]
25mod privileges;
26#[doc(hidden)]
27pub mod test_support;
28#[doc(hidden)]
29pub mod worker;
30pub(crate) mod worker_process;
31
32#[doc(hidden)]
33pub mod worker_process_test_api {
34    //! Integration test shims for worker process orchestration.
35
36    pub use crate::cluster::WorkerOperation;
37    use crate::worker_process;
38    pub use crate::worker_process::WorkerRequestArgs;
39
40    #[cfg(all(
41        unix,
42        any(
43            target_os = "linux",
44            target_os = "android",
45            target_os = "freebsd",
46            target_os = "openbsd",
47            target_os = "dragonfly",
48        ),
49        any(test, doc, feature = "privileged-tests"),
50    ))]
51    use crate::worker_process::PrivilegeDropGuard as InnerPrivilegeDropGuard;
52
53    /// Test-visible wrapper around the internal worker request.
54    ///
55    /// Use this helper when integration tests need to exercise worker process
56    /// orchestration without exposing the internals as part of the public API.
57    pub struct WorkerRequest<'a>(worker_process::WorkerRequest<'a>);
58
59    impl<'a> WorkerRequest<'a> {
60        /// Constructs a worker request for invoking an operation in tests.
61        ///
62        /// # Examples
63        ///
64        /// ```ignore
65        /// # use std::time::Duration;
66        /// # use camino::Utf8Path;
67        /// # use postgresql_embedded::Settings;
68        /// # use pg_embedded_setup_unpriv::{
69        /// #     WorkerOperation,
70        /// #     worker_process_test_api::{WorkerRequest, WorkerRequestArgs},
71        /// # };
72        /// # let worker = Utf8Path::new("/tmp/worker");
73        /// # let settings = Settings::default();
74        /// # let env_vars: Vec<(String, Option<String>)> = Vec::new();
75        /// let args = WorkerRequestArgs {
76        ///     worker,
77        ///     settings: &settings,
78        ///     env_vars: &env_vars,
79        ///     operation: WorkerOperation::Setup,
80        ///     timeout: Duration::from_secs(1),
81        /// };
82        /// let request = WorkerRequest::new(args);
83        /// # let _ = request;
84        /// ```
85        #[must_use]
86        pub const fn new(args: WorkerRequestArgs<'a>) -> Self {
87            Self(worker_process::WorkerRequest::new(args))
88        }
89
90        /// Returns a reference to the wrapped worker request.
91        pub(crate) const fn inner(&self) -> &worker_process::WorkerRequest<'a> {
92            &self.0
93        }
94    }
95
96    /// Executes a worker request whilst returning crate-level errors.
97    pub fn run(request: &WorkerRequest<'_>) -> crate::BootstrapResult<()> {
98        worker_process::run(request.inner())
99    }
100
101    /// Guard that restores the privilege-drop toggle when tests finish.
102    #[cfg(all(
103        unix,
104        any(
105            target_os = "linux",
106            target_os = "android",
107            target_os = "freebsd",
108            target_os = "openbsd",
109            target_os = "dragonfly",
110        ),
111        any(test, doc, feature = "privileged-tests"),
112    ))]
113    pub struct PrivilegeDropGuard {
114        _inner: InnerPrivilegeDropGuard,
115    }
116
117    /// Temporarily disables privilege dropping so tests can run deterministic
118    /// worker binaries without adjusting file ownership.
119    #[cfg(all(
120        unix,
121        any(
122            target_os = "linux",
123            target_os = "android",
124            target_os = "freebsd",
125            target_os = "openbsd",
126            target_os = "dragonfly",
127        ),
128        any(test, doc, feature = "privileged-tests"),
129    ))]
130    #[must_use]
131    pub fn disable_privilege_drop_for_tests() -> PrivilegeDropGuard {
132        PrivilegeDropGuard {
133            _inner: worker_process::disable_privilege_drop_for_tests(),
134        }
135    }
136
137    /// Renders a worker failure for assertion-friendly error strings.
138    #[must_use]
139    pub fn render_failure_for_tests(
140        context: &str,
141        output: &std::process::Output,
142    ) -> crate::BootstrapError {
143        worker_process::render_failure_for_tests(context, output)
144    }
145}
146
147/// Resolves a path to an ambient directory handle paired with the relative path component.
148///
149/// This function provides capability-based filesystem access by opening paths relative to
150/// ambient authority. Absolute paths are opened relative to their parent directory; relative
151/// paths reuse the current working directory.
152///
153/// # Returns
154///
155/// Returns a tuple containing:
156/// - A [`cap_std::fs::Dir`] handle for the parent directory
157/// - A [`camino::Utf8PathBuf`] with the relative component
158///
159/// For absolute paths like `/foo/bar`, returns `(Dir("/foo"), "bar")`.
160/// For relative paths like `baz/qux`, returns `(Dir("."), "baz/qux")`.
161/// For root paths like `/`, returns `(Dir("/"), "")` with an empty relative component.
162///
163/// # Errors
164///
165/// Returns an error if the path cannot be opened as a directory or if path operations fail.
166///
167/// # Examples
168///
169/// ```no_run
170/// use pg_embedded_setup_unpriv::ambient_dir_and_path;
171/// use camino::Utf8Path;
172///
173/// # fn main() -> color_eyre::Result<()> {
174/// let (dir, relative) = ambient_dir_and_path(Utf8Path::new("./data"))?;
175/// // Use dir handle for capability-based operations on relative path
176/// # Ok(())
177/// # }
178/// ```
179pub use crate::fs::ambient_dir_and_path;
180
181#[doc(hidden)]
182pub use crate::env::ScopedEnv;
183pub use bootstrap::{
184    ExecutionMode, ExecutionPrivileges, TestBootstrapEnvironment, TestBootstrapSettings,
185    bootstrap_for_tests, detect_execution_privileges, find_timezone_dir, run,
186};
187#[cfg(any(doc, test, feature = "cluster-unit-tests", feature = "dev-worker"))]
188#[doc(hidden)]
189pub use cluster::WorkerInvoker;
190#[cfg(any(test, feature = "cluster-unit-tests"))]
191#[doc(hidden)]
192pub use cluster::WorkerOperation;
193pub use cluster::{
194    ConnectionMetadata, DatabaseName, TemporaryDatabase, TestCluster, TestClusterConnection,
195};
196#[doc(hidden)]
197pub use error::BootstrapResult;
198pub use error::PgEmbeddedError as Error;
199pub use error::{
200    BootstrapError, BootstrapErrorKind, PgEmbeddedError, PrivilegeError, PrivilegeResult, Result,
201};
202#[cfg(feature = "privileged-tests")]
203#[cfg(all(
204    unix,
205    any(
206        target_os = "linux",
207        target_os = "android",
208        target_os = "freebsd",
209        target_os = "openbsd",
210        target_os = "dragonfly",
211    ),
212))]
213#[expect(
214    deprecated,
215    reason = "with_temp_euid() remains exported for backward compatibility whilst deprecated"
216)]
217pub use privileges::with_temp_euid;
218#[cfg(all(
219    unix,
220    any(
221        target_os = "linux",
222        target_os = "android",
223        target_os = "freebsd",
224        target_os = "openbsd",
225        target_os = "dragonfly",
226    ),
227))]
228pub use privileges::{default_paths_for, make_data_dir_private, make_dir_accessible, nobody_uid};
229
230use color_eyre::eyre::{Context, eyre};
231use ortho_config::OrthoConfig;
232use postgresql_embedded::{Settings, VersionReq};
233use serde::{Deserialize, Serialize};
234
235use crate::error::{ConfigError, ConfigResult};
236use camino::Utf8PathBuf;
237use std::ffi::OsString;
238
239/// Captures `PostgreSQL` settings supplied via environment variables.
240#[derive(Debug, Clone, Serialize, Deserialize, OrthoConfig, Default)]
241#[ortho_config(prefix = "PG")]
242///
243/// # Examples
244/// ```
245/// use pg_embedded_setup_unpriv::PgEnvCfg;
246///
247/// let cfg = PgEnvCfg::default();
248/// assert!(cfg.port.is_none());
249/// ```
250pub struct PgEnvCfg {
251    /// Optional semver requirement that constrains the `PostgreSQL` version.
252    pub version_req: Option<String>,
253    /// Port assigned to the embedded `PostgreSQL` server.
254    pub port: Option<u16>,
255    /// Name of the administrative user created for the cluster.
256    pub superuser: Option<String>,
257    /// Password provisioned for the administrative user.
258    pub password: Option<String>,
259    /// Directory used for `PostgreSQL` data files when provided.
260    pub data_dir: Option<Utf8PathBuf>,
261    /// Directory containing the `PostgreSQL` binaries when provided.
262    pub runtime_dir: Option<Utf8PathBuf>,
263    /// Locale applied to `initdb` when specified.
264    pub locale: Option<String>,
265    /// Encoding applied to `initdb` when specified.
266    pub encoding: Option<String>,
267    /// Directory for sharing downloaded `PostgreSQL` binaries across test runs.
268    ///
269    /// When `Some`, this explicit path is used directly by `TestCluster`, bypassing
270    /// the automatic resolution chain. When `None`, the cache directory is resolved
271    /// in the following order:
272    ///
273    /// 1. `PG_BINARY_CACHE_DIR` environment variable (if set and non-empty)
274    /// 2. `$XDG_CACHE_HOME/pg-embedded/binaries` (if `XDG_CACHE_HOME` is set)
275    /// 3. `$HOME/.cache/pg-embedded/binaries` (if `HOME` is set)
276    /// 4. `/tmp/pg-embedded/binaries` (final fallback)
277    pub binary_cache_dir: Option<Utf8PathBuf>,
278}
279
280impl PgEnvCfg {
281    /// Loads configuration from environment variables without parsing CLI arguments.
282    ///
283    /// # Errors
284    /// Returns an error when environment parsing fails or derived configuration
285    /// cannot be represented using UTF-8 paths.
286    pub fn load() -> ConfigResult<Self> {
287        let args = [OsString::from("pg-embedded-setup-unpriv")];
288        Self::load_from_iter(args).map_err(|err| ConfigError::from(eyre!(err)))
289    }
290
291    /// Converts the configuration into a complete `postgresql_embedded::Settings` object.
292    ///
293    /// Applies version, connection, path, and locale settings from the current configuration.
294    /// Returns an error if the version requirement is invalid.
295    ///
296    /// # Returns
297    /// A fully configured `Settings` instance on success, or an error if configuration fails.
298    ///
299    /// # Errors
300    /// Returns an error when the semantic version requirement cannot be parsed.
301    pub fn to_settings(&self) -> Result<Settings> {
302        // Disable the internal postgresql_embedded timeout. This crate wraps lifecycle
303        // operations with tokio::time::timeout using setup_timeout/start_timeout from
304        // TestBootstrapSettings, providing consistent timeout behaviour for both
305        // privileged (subprocess) and unprivileged (in-process) execution paths.
306        // The default 5-second timeout is too short for initdb on slower systems.
307        let mut s = Settings {
308            timeout: None,
309            ..Settings::default()
310        };
311
312        self.apply_version(&mut s)?;
313        self.apply_connection(&mut s);
314        self.apply_paths(&mut s);
315        self.apply_locale(&mut s);
316
317        Ok(s)
318    }
319
320    fn apply_version(&self, settings: &mut Settings) -> ConfigResult<()> {
321        if let Some(ref vr) = self.version_req {
322            settings.version =
323                VersionReq::parse(vr).context("PG_VERSION_REQ invalid semver spec")?;
324        }
325        Ok(())
326    }
327
328    fn apply_connection(&self, settings: &mut Settings) {
329        if let Some(p) = self.port {
330            settings.port = p;
331        }
332        if let Some(ref u) = self.superuser {
333            settings.username.clone_from(u);
334        }
335        if let Some(ref pw) = self.password {
336            settings.password.clone_from(pw);
337        }
338    }
339
340    fn apply_paths(&self, settings: &mut Settings) {
341        if let Some(ref dir) = self.data_dir {
342            settings.data_dir = dir.clone().into_std_path_buf();
343        }
344        if let Some(ref dir) = self.runtime_dir {
345            settings.installation_dir = dir.clone().into_std_path_buf();
346        }
347    }
348
349    /// Applies locale and encoding settings to the `PostgreSQL` configuration if specified
350    /// in the environment.
351    ///
352    /// Inserts the `locale` and `encoding` values into the settings configuration map when
353    /// present in the environment configuration.
354    fn apply_locale(&self, settings: &mut Settings) {
355        if let Some(ref loc) = self.locale {
356            settings.configuration.insert("locale".into(), loc.clone());
357        }
358        if let Some(ref enc) = self.encoding {
359            settings
360                .configuration
361                .insert("encoding".into(), enc.clone());
362        }
363    }
364}