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;
14pub mod extensions;
15mod fs;
16#[cfg(all(test, feature = "loom-tests"))]
17mod loom_model;
18pub mod observability;
19#[cfg(all(
20    unix,
21    any(
22        target_os = "linux",
23        target_os = "android",
24        target_os = "freebsd",
25        target_os = "openbsd",
26        target_os = "dragonfly",
27    ),
28))]
29mod privileges;
30#[doc(hidden)]
31pub mod test_support;
32#[doc(hidden)]
33pub mod worker;
34pub(crate) mod worker_process;
35
36#[doc(hidden)]
37pub mod worker_process_test_api {
38    //! Integration test shims for worker process orchestration.
39
40    use crate::worker_process;
41    #[cfg(all(
42        unix,
43        any(
44            target_os = "linux",
45            target_os = "android",
46            target_os = "freebsd",
47            target_os = "openbsd",
48            target_os = "dragonfly",
49        ),
50        any(test, doc, feature = "privileged-tests"),
51    ))]
52    use crate::worker_process::PrivilegeDropGuard as InnerPrivilegeDropGuard;
53    pub use crate::{cluster::WorkerOperation, worker_process::WorkerRequestArgs};
54
55    /// Test-visible wrapper around the internal worker request.
56    ///
57    /// Use this helper when integration tests need to exercise worker process
58    /// orchestration without exposing the internals as part of the public API.
59    pub struct WorkerRequest<'a>(worker_process::WorkerRequest<'a>);
60
61    impl<'a> WorkerRequest<'a> {
62        /// Constructs a worker request for invoking an operation in tests.
63        ///
64        /// # Examples
65        ///
66        /// ```ignore
67        /// # use std::time::Duration;
68        /// # use camino::Utf8Path;
69        /// # use postgresql_embedded::Settings;
70        /// # use pg_embedded_setup_unpriv::{
71        /// #     WorkerOperation,
72        /// #     worker_process_test_api::{WorkerRequest, WorkerRequestArgs},
73        /// # };
74        /// # let worker = Utf8Path::new("/tmp/worker");
75        /// # let settings = Settings::default();
76        /// # let env_vars: Vec<(String, Option<String>)> = Vec::new();
77        /// let args = WorkerRequestArgs {
78        ///     worker,
79        ///     settings: &settings,
80        ///     env_vars: &env_vars,
81        ///     operation: WorkerOperation::Setup,
82        ///     timeout: Duration::from_secs(1),
83        /// };
84        /// let request = WorkerRequest::new(args);
85        /// # let _ = request;
86        /// ```
87        #[must_use]
88        pub const fn new(args: WorkerRequestArgs<'a>) -> Self {
89            Self(worker_process::WorkerRequest::new(args))
90        }
91
92        /// Returns a reference to the wrapped worker request.
93        pub(crate) const fn inner(&self) -> &worker_process::WorkerRequest<'a> { &self.0 }
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
147use std::ffi::OsString;
148
149pub use bootstrap::{
150    CleanupMode,
151    ExecutionMode,
152    ExecutionPrivileges,
153    PasswordReuseOutcome,
154    TestBootstrapEnvironment,
155    TestBootstrapSettings,
156    bootstrap_for_tests,
157    default_paths_under,
158    detect_execution_privileges,
159    find_timezone_dir,
160    reuse_existing_password,
161    run,
162    stored_cluster_password,
163};
164use camino::Utf8PathBuf;
165#[cfg(any(doc, test, feature = "cluster-unit-tests", feature = "dev-worker"))]
166#[doc(hidden)]
167pub use cluster::WorkerInvoker;
168#[cfg(any(test, feature = "cluster-unit-tests"))]
169#[doc(hidden)]
170pub use cluster::WorkerOperation;
171pub use cluster::{
172    ClusterGuard,
173    ClusterHandle,
174    ConnectionMetadata,
175    DatabaseName,
176    TemporaryDatabase,
177    TestCluster,
178    TestClusterConnection,
179};
180use color_eyre::eyre::{Context, eyre};
181#[doc(hidden)]
182pub use error::BootstrapResult;
183pub use error::{
184    BootstrapError,
185    BootstrapErrorKind,
186    PgEmbeddedError as Error,
187    PgEmbeddedError,
188    PrivilegeError,
189    PrivilegeResult,
190    Result,
191};
192use ortho_config::OrthoConfig;
193use postgresql_embedded::{Settings, VersionReq};
194#[cfg(feature = "privileged-tests")]
195#[cfg(all(
196    unix,
197    any(
198        target_os = "linux",
199        target_os = "android",
200        target_os = "freebsd",
201        target_os = "openbsd",
202        target_os = "dragonfly",
203    ),
204))]
205#[expect(
206    deprecated,
207    reason = "with_temp_euid() remains exported for backward compatibility whilst deprecated"
208)]
209pub use privileges::with_temp_euid;
210#[cfg(all(
211    unix,
212    any(
213        target_os = "linux",
214        target_os = "android",
215        target_os = "freebsd",
216        target_os = "openbsd",
217        target_os = "dragonfly",
218    ),
219))]
220pub use privileges::{
221    default_paths_for,
222    default_root_for,
223    make_data_dir_private,
224    make_dir_accessible,
225    nobody_uid,
226};
227use serde::{Deserialize, Serialize};
228
229#[doc(hidden)]
230pub use crate::env::ScopedEnv;
231use crate::error::{ConfigError, ConfigResult};
232/// Resolves a path to an ambient directory handle paired with the relative path component.
233///
234/// This function provides capability-based filesystem access by opening paths relative to
235/// ambient authority. Absolute paths are opened relative to their parent directory; relative
236/// paths reuse the current working directory.
237///
238/// # Returns
239///
240/// Returns a tuple containing:
241/// - A [`cap_std::fs::Dir`] handle for the parent directory
242/// - A [`camino::Utf8PathBuf`] with the relative component
243///
244/// For absolute paths like `/foo/bar`, returns `(Dir("/foo"), "bar")`.
245/// For relative paths like `baz/qux`, returns `(Dir("."), "baz/qux")`.
246/// For root paths like `/`, returns `(Dir("/"), "")` with an empty relative component.
247///
248/// # Errors
249///
250/// Returns an error if the path cannot be opened as a directory or if path operations fail.
251///
252/// # Examples
253///
254/// ```no_run
255/// use camino::Utf8Path;
256/// use pg_embedded_setup_unpriv::ambient_dir_and_path;
257///
258/// # fn main() -> color_eyre::Result<()> {
259/// let (dir, relative) = ambient_dir_and_path(Utf8Path::new("./data"))?;
260/// // Use dir handle for capability-based operations on relative path
261/// # Ok(())
262/// # }
263/// ```
264pub use crate::fs::ambient_dir_and_path;
265
266/// Captures `PostgreSQL` settings supplied via environment variables.
267#[derive(Debug, Clone, Serialize, Deserialize, OrthoConfig, Default)]
268#[ortho_config(prefix = "PG")]
269///
270/// # Examples
271/// ```
272/// use pg_embedded_setup_unpriv::PgEnvCfg;
273///
274/// let cfg = PgEnvCfg::default();
275/// assert!(cfg.port.is_none());
276/// ```
277pub struct PgEnvCfg {
278    /// Optional semver requirement that constrains the `PostgreSQL` version.
279    pub version_req: Option<String>,
280    /// Port assigned to the embedded `PostgreSQL` server.
281    pub port: Option<u16>,
282    /// Name of the administrative user created for the cluster.
283    pub superuser: Option<String>,
284    /// Password provisioned for the administrative user.
285    pub password: Option<String>,
286    /// Directory used for `PostgreSQL` data files when provided.
287    pub data_dir: Option<Utf8PathBuf>,
288    /// Directory containing the `PostgreSQL` binaries when provided.
289    pub runtime_dir: Option<Utf8PathBuf>,
290    /// Locale applied to `initdb` when specified.
291    pub locale: Option<String>,
292    /// Encoding applied to `initdb` when specified.
293    pub encoding: Option<String>,
294    /// Directory for sharing downloaded `PostgreSQL` binaries across test runs.
295    ///
296    /// When `Some`, this explicit path is used directly by `TestCluster`, bypassing
297    /// the automatic resolution chain. When `None`, the cache directory is resolved
298    /// in the following order:
299    ///
300    /// 1. `PG_BINARY_CACHE_DIR` environment variable (if set and non-empty)
301    /// 2. `$XDG_CACHE_HOME/pg-embedded/binaries` (if `XDG_CACHE_HOME` is set)
302    /// 3. `$HOME/.cache/pg-embedded/binaries` (if `HOME` is set)
303    /// 4. `/tmp/pg-embedded/binaries` (final fallback)
304    pub binary_cache_dir: Option<Utf8PathBuf>,
305    /// Base directory under which the default `install` and `data`
306    /// directories are created (`PG_EMBED_ROOT`).
307    ///
308    /// When unset the base is `/var/tmp/pg-embed-{uid}`, which every project
309    /// on a host shares per user. Set it to give a project or a test run its
310    /// own tree. `PG_RUNTIME_DIR` and `PG_DATA_DIR` still override the two
311    /// leaves individually.
312    pub embed_root: Option<Utf8PathBuf>,
313    /// `max_connections` for the server (`PG_MAX_CONNECTIONS`).
314    ///
315    /// Test clusters default to 20; set this when parallel test runners need
316    /// more. Applied to every bootstrap, test or not. Values below
317    /// [`MIN_MAX_CONNECTIONS`] are rejected when the settings are built.
318    pub max_connections: Option<u32>,
319    /// Comma-separated extension names to install before the server starts
320    /// (`PG_EXTENSIONS`, for example `vector`). Empty or unset disables the
321    /// extension hook.
322    pub extensions: Option<String>,
323    /// Location of the extension manifest (`PG_EXTENSIONS_MANIFEST`): an
324    /// `https://` URL, a loopback `http://` URL, or a filesystem path, which
325    /// is the rule the archives it names go through. Required when
326    /// `extensions` is set.
327    pub extensions_manifest: Option<String>,
328    /// Lower-case hex SHA-256 of the manifest bytes
329    /// (`PG_EXTENSIONS_MANIFEST_SHA256`). Required for a URL manifest,
330    /// loopback included; optional for a filesystem path.
331    pub extensions_manifest_sha256: Option<String>,
332    /// Directory holding verified extension archives between runs
333    /// (`PG_EXTENSIONS_CACHE_DIR`). Defaults to
334    /// `$XDG_CACHE_HOME/pg-embedded/extensions` with the same fallbacks as
335    /// [`binary_cache_dir`](Self::binary_cache_dir).
336    pub extensions_cache_dir: Option<Utf8PathBuf>,
337}
338
339impl PgEnvCfg {
340    /// Loads configuration from environment variables without parsing CLI arguments.
341    ///
342    /// # Errors
343    /// Returns an error when environment parsing fails or derived configuration
344    /// cannot be represented using UTF-8 paths.
345    pub fn load() -> ConfigResult<Self> {
346        let args = [OsString::from("pg-embedded-setup-unpriv")];
347        Self::load_from_iter(args).map_err(|err| ConfigError::from(eyre!(err)))
348    }
349
350    /// Converts the configuration into a complete `postgresql_embedded::Settings` object.
351    ///
352    /// Applies version, connection, path, and locale settings from the current configuration.
353    /// Returns an error if the version requirement is invalid. This variant does not apply
354    /// test-specific worker limits.
355    ///
356    /// # Examples
357    /// ```no_run
358    /// use pg_embedded_setup_unpriv::PgEnvCfg;
359    ///
360    /// let cfg = PgEnvCfg::default();
361    /// let settings = cfg.to_settings()?;
362    /// # Ok::<(), pg_embedded_setup_unpriv::Error>(())
363    /// ```
364    ///
365    /// # Returns
366    /// A fully configured `Settings` instance on success, or an error if configuration fails.
367    ///
368    /// # Errors
369    /// Returns an error when the semantic version requirement cannot be parsed,
370    /// or when `PG_MAX_CONNECTIONS` is below [`MIN_MAX_CONNECTIONS`].
371    pub fn to_settings(&self) -> Result<Settings> { self.to_settings_with_context(false) }
372
373    /// Converts the configuration into `Settings`, applying test-only worker limits.
374    ///
375    /// Use this helper for ephemeral test clusters where resource limits are desirable.
376    ///
377    /// # Examples
378    /// ```no_run
379    /// use pg_embedded_setup_unpriv::PgEnvCfg;
380    ///
381    /// let cfg = PgEnvCfg::default();
382    /// let settings = cfg.to_settings_for_tests()?;
383    /// # Ok::<(), pg_embedded_setup_unpriv::Error>(())
384    /// ```
385    ///
386    /// # Errors
387    /// Returns an error when the semantic version requirement cannot be parsed,
388    /// or when `PG_MAX_CONNECTIONS` is below [`MIN_MAX_CONNECTIONS`].
389    pub fn to_settings_for_tests(&self) -> Result<Settings> { self.to_settings_with_context(true) }
390
391    /// Converts the configuration into `Settings`, optionally applying test limits.
392    ///
393    /// Set `for_tests` to `true` to apply the worker limits intended for ephemeral
394    /// test clusters.
395    ///
396    /// # Examples
397    /// ```no_run
398    /// use pg_embedded_setup_unpriv::PgEnvCfg;
399    ///
400    /// let cfg = PgEnvCfg::default();
401    /// let settings = cfg.to_settings_with_context(true)?;
402    /// # Ok::<(), pg_embedded_setup_unpriv::Error>(())
403    /// ```
404    ///
405    /// # Errors
406    /// Returns an error when the semantic version requirement cannot be parsed,
407    /// or when `PG_MAX_CONNECTIONS` is below [`MIN_MAX_CONNECTIONS`].
408    pub fn to_settings_with_context(&self, for_tests: bool) -> Result<Settings> {
409        // Disable the internal postgresql_embedded timeout. This crate wraps lifecycle
410        // operations with tokio::time::timeout using setup_timeout/start_timeout from
411        // TestBootstrapSettings, providing consistent timeout behaviour for both
412        // privileged (subprocess) and unprivileged (in-process) execution paths.
413        // The default 5-second timeout is too short for initdb on slower systems.
414        let mut s = Settings {
415            timeout: None,
416            ..Settings::default()
417        };
418
419        self.apply_version(&mut s)?;
420        self.apply_connection(&mut s);
421        self.apply_paths(&mut s);
422        self.apply_locale(&mut s);
423        if for_tests {
424            Self::apply_worker_limits(&mut s);
425        }
426        self.apply_max_connections(&mut s)?;
427
428        Ok(s)
429    }
430
431    fn apply_version(&self, settings: &mut Settings) -> ConfigResult<()> {
432        if let Some(ref vr) = self.version_req {
433            settings.version =
434                VersionReq::parse(vr).context("PG_VERSION_REQ invalid semver spec")?;
435        }
436        Ok(())
437    }
438
439    fn apply_connection(&self, settings: &mut Settings) {
440        if let Some(p) = self.port {
441            settings.port = p;
442        }
443        if let Some(ref u) = self.superuser {
444            settings.username.clone_from(u);
445        }
446        if let Some(ref pw) = self.password {
447            settings.password.clone_from(pw);
448        }
449    }
450
451    fn apply_paths(&self, settings: &mut Settings) {
452        if let Some(ref dir) = self.data_dir {
453            settings.data_dir = dir.clone().into_std_path_buf();
454        }
455        if let Some(ref dir) = self.runtime_dir {
456            settings.installation_dir = dir.clone().into_std_path_buf();
457        }
458    }
459
460    /// Applies locale and encoding settings to the `PostgreSQL` configuration if specified
461    /// in the environment.
462    ///
463    /// Inserts the `locale` and `encoding` values into the settings configuration map when
464    /// present in the environment configuration.
465    fn apply_locale(&self, settings: &mut Settings) {
466        if let Some(ref loc) = self.locale {
467            settings.configuration.insert("locale".into(), loc.clone());
468        }
469        if let Some(ref enc) = self.encoding {
470            settings
471                .configuration
472                .insert("encoding".into(), enc.clone());
473        }
474    }
475
476    /// Applies an explicit `max_connections`, replacing the test default of 20.
477    ///
478    /// `PostgreSQL` keeps `superuser_reserved_connections` (3 by default) below
479    /// `max_connections`, so anything under [`MIN_MAX_CONNECTIONS`] would make
480    /// the server refuse to start; reject it here with a clear message.
481    fn apply_max_connections(&self, settings: &mut Settings) -> ConfigResult<()> {
482        let Some(limit) = self.max_connections else {
483            return Ok(());
484        };
485        if limit < MIN_MAX_CONNECTIONS {
486            return Err(ConfigError::from(eyre!(
487                "PG_MAX_CONNECTIONS must be at least {MIN_MAX_CONNECTIONS} (got {limit}); \
488                 PostgreSQL reserves three superuser slots below max_connections"
489            )));
490        }
491        settings
492            .configuration
493            .insert("max_connections".into(), limit.to_string());
494        Ok(())
495    }
496
497    fn apply_worker_limits(settings: &mut Settings) {
498        for (key, value) in WORKER_LIMIT_DEFAULTS {
499            settings
500                .configuration
501                .entry(key.to_owned())
502                .or_insert_with(|| value.to_owned());
503        }
504    }
505}
506
507/// Smallest `PG_MAX_CONNECTIONS` the helper accepts: one ordinary slot above
508/// `PostgreSQL`'s default of three reserved superuser connections.
509pub const MIN_MAX_CONNECTIONS: u32 = 4;
510const WORKER_LIMIT_DEFAULTS: [(&str, &str); 8] = [
511    ("max_connections", "20"),
512    ("max_worker_processes", "2"),
513    ("max_parallel_workers", "0"),
514    ("max_parallel_workers_per_gather", "0"),
515    ("max_parallel_maintenance_workers", "0"),
516    ("autovacuum", "off"),
517    ("max_wal_senders", "0"),
518    ("max_replication_slots", "0"),
519];