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