Skip to main content

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