Skip to main content

pg_embedded_setup_unpriv/
error.rs

1//! Domain error types for the embedded `PostgreSQL` bootstrapper.
2
3use color_eyre::Report;
4use thiserror::Error;
5
6/// Result alias for operations that may return a [`PgEmbeddedError`].
7pub type Result<T> = std::result::Result<T, PgEmbeddedError>;
8
9/// Result alias for bootstrap-specific fallible operations.
10pub type BootstrapResult<T> = std::result::Result<T, BootstrapError>;
11
12/// Result alias for privilege-management fallible operations.
13pub type PrivilegeResult<T> = std::result::Result<T, PrivilegeError>;
14
15/// Result alias for configuration fallible operations.
16pub type ConfigResult<T> = std::result::Result<T, ConfigError>;
17
18/// Top-level error exposed by the crate.
19#[derive(Debug, Error)]
20pub enum PgEmbeddedError {
21    /// Indicates bootstrap initialization failed.
22    #[error("bootstrap failed: {0}")]
23    Bootstrap(#[from] BootstrapError),
24    /// Indicates privilege management failed.
25    #[error("privilege management failed: {0}")]
26    Privilege(#[from] PrivilegeError),
27    /// Indicates configuration parsing failed.
28    #[error("configuration parsing failed: {0}")]
29    Config(#[from] ConfigError),
30}
31
32/// Categorizes bootstrap failures so callers can branch on structured errors.
33#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
34pub enum BootstrapErrorKind {
35    /// Represents errors without a more specific semantic meaning.
36    #[default]
37    Other,
38    /// Indicates the configured worker binary is missing from disk.
39    WorkerBinaryMissing,
40    /// Indicates a PATH entry used for worker discovery is not valid UTF-8.
41    WorkerBinaryPathNonUtf8,
42    /// The data directory holds a cluster but its password file does not exist.
43    ClusterPasswordMissing,
44    /// The data directory holds a cluster but its password file, or the
45    /// cluster marker itself, cannot be read.
46    ClusterPasswordUnreadable,
47    /// The data directory holds a cluster but its password file is empty.
48    ClusterPasswordEmpty,
49    /// `PG_EXTENSIONS*` is set but incomplete or malformed (no manifest, a
50    /// URL manifest without a digest, a bad name, or a bad digest).
51    ///
52    /// The digest is required for every URL manifest, loopback `http://`
53    /// included, and optional for a filesystem path.
54    ExtensionConfigInvalid,
55    /// The extension manifest could not be read from its path or URL.
56    ExtensionManifestUnavailable,
57    /// The manifest bytes do not hash to `PG_EXTENSIONS_MANIFEST_SHA256`.
58    ExtensionManifestDigestMismatch,
59    /// The manifest is not valid JSON, has the wrong schema version, or is
60    /// missing a field.
61    ExtensionManifestInvalid,
62    /// The manifest offers no artifact for the requested name, the running
63    /// `PostgreSQL` major and minor, and the compile target.
64    ExtensionUnavailable,
65    /// The extension archive could not be downloaded and no cached copy exists.
66    ExtensionArchiveUnavailable,
67    /// The archive bytes do not hash to the digest recorded in the manifest.
68    ExtensionArchiveDigestMismatch,
69    /// The archive contains a forbidden entry, escapes its prefixes, or does
70    /// not match the file list recorded in the manifest.
71    ExtensionArchiveInvalid,
72    /// Writing the extension files into the installation tree failed.
73    ExtensionInstallFailed,
74}
75
76impl BootstrapErrorKind {
77    /// Returns whether the failure came from the extension hook rather than
78    /// from the `PostgreSQL` installation itself.
79    pub(crate) const fn is_extension(self) -> bool {
80        matches!(
81            self,
82            Self::ExtensionConfigInvalid
83                | Self::ExtensionManifestUnavailable
84                | Self::ExtensionManifestDigestMismatch
85                | Self::ExtensionManifestInvalid
86                | Self::ExtensionUnavailable
87                | Self::ExtensionArchiveUnavailable
88                | Self::ExtensionArchiveDigestMismatch
89                | Self::ExtensionArchiveInvalid
90                | Self::ExtensionInstallFailed
91        )
92    }
93}
94
95/// Captures bootstrap-specific failures.
96#[derive(Debug, Error)]
97#[error("{report}")]
98pub struct BootstrapError {
99    kind: BootstrapErrorKind,
100    #[source]
101    report: Report,
102}
103
104impl BootstrapError {
105    /// Constructs a new bootstrap error with the provided kind and diagnostic
106    /// report.
107    #[must_use]
108    pub const fn new(kind: BootstrapErrorKind, report: Report) -> Self { Self { kind, report } }
109
110    /// Returns the semantic category for this bootstrap failure.
111    #[must_use]
112    pub const fn kind(&self) -> BootstrapErrorKind { self.kind }
113
114    /// Extracts the underlying diagnostic report.
115    pub fn into_report(self) -> Report { self.report }
116
117    /// Borrows the underlying diagnostic report, so its cause chain can be
118    /// inspected without consuming the error.
119    pub(crate) const fn report(&self) -> &Report { &self.report }
120}
121
122/// A lifecycle operation that did not finish within its configured timeout.
123///
124/// Carried as a typed cause, rather than as text, so a caller deciding
125/// whether a failure is worth retrying can recognize it by type.
126#[derive(Debug, Error)]
127#[error("{context}: operation timed out after {seconds:.1}s")]
128pub(crate) struct LifecycleTimeout {
129    /// The operation that timed out, such as `postgresql_embedded::start()`.
130    pub(crate) context: &'static str,
131    /// The timeout that elapsed, in seconds.
132    pub(crate) seconds: f64,
133}
134
135/// A lifecycle failure that ran binaries copied from the shared binary cache.
136///
137/// The cache holds an extracted installation tree, so a retry would copy the
138/// same tree again. The marker tells the shared-cluster retry to treat I/O and
139/// archive failures after a cache hit as deterministic, and tells the reader
140/// how to clear the entry.
141#[derive(Debug, Error)]
142#[error(
143    "the PostgreSQL binaries came from the shared binary cache ({version}); remove that cache \
144     entry to download them afresh"
145)]
146pub(crate) struct CachedBinariesUsed {
147    /// The version requirement the cached entry satisfied.
148    pub(crate) version: String,
149}
150
151impl From<Report> for BootstrapError {
152    fn from(report: Report) -> Self { Self::new(BootstrapErrorKind::Other, report) }
153}
154
155impl From<PrivilegeError> for BootstrapError {
156    fn from(err: PrivilegeError) -> Self {
157        let PrivilegeError(report) = err;
158        Self::new(BootstrapErrorKind::Other, report)
159    }
160}
161
162impl From<ConfigError> for BootstrapError {
163    fn from(err: ConfigError) -> Self {
164        let ConfigError(report) = err;
165        Self::new(BootstrapErrorKind::Other, report)
166    }
167}
168
169impl From<PgEmbeddedError> for BootstrapError {
170    fn from(err: PgEmbeddedError) -> Self {
171        match err {
172            PgEmbeddedError::Bootstrap(inner) => inner,
173            PgEmbeddedError::Privilege(inner) => inner.into(),
174            PgEmbeddedError::Config(inner) => inner.into(),
175        }
176    }
177}
178
179/// Captures privilege-management failures.
180#[derive(Debug, Error)]
181#[error(transparent)]
182pub struct PrivilegeError(#[from] Report);
183
184/// Captures configuration failures.
185#[derive(Debug, Error)]
186#[error(transparent)]
187pub struct ConfigError(#[from] Report);
188
189#[cfg(test)]
190mod tests {
191    //! Unit tests for error display formats.
192
193    use color_eyre::eyre::eyre;
194    use rstest::rstest;
195
196    use super::*;
197
198    #[rstest]
199    #[case::bootstrap(
200        "PG_EMBEDDED_WORKER must be set",
201        "bootstrap failed:",
202        |msg: &str| PgEmbeddedError::Bootstrap(BootstrapError::from(eyre!("{}", msg)))
203    )]
204    #[case::privilege(
205        "failed to drop privileges",
206        "privilege management failed:",
207        |msg: &str| PgEmbeddedError::Privilege(PrivilegeError::from(eyre!("{}", msg)))
208    )]
209    #[case::config(
210        "invalid port number",
211        "configuration parsing failed:",
212        |msg: &str| PgEmbeddedError::Config(ConfigError::from(eyre!("{}", msg)))
213    )]
214    fn pg_embedded_error_includes_inner_message(
215        #[case] inner_message: &str,
216        #[case] expected_prefix: &str,
217        #[case] constructor: fn(&str) -> PgEmbeddedError,
218    ) {
219        let pg_err = constructor(inner_message);
220
221        let display = pg_err.to_string();
222
223        assert!(
224            display.contains(expected_prefix),
225            "expected '{expected_prefix}' prefix, got: {display}"
226        );
227        assert!(
228            display.contains(inner_message),
229            "expected inner message '{inner_message}' in display, got: {display}"
230        );
231    }
232
233    #[test]
234    fn bootstrap_error_displays_report_message() {
235        let inner_message = "database connection failed";
236        let err = BootstrapError::from(eyre!(inner_message));
237
238        let display = err.to_string();
239
240        assert!(
241            display.contains(inner_message),
242            "expected '{inner_message}' in display, got: {display}"
243        );
244    }
245
246    #[test]
247    fn bootstrap_error_kind_defaults_to_other() {
248        assert_eq!(BootstrapErrorKind::default(), BootstrapErrorKind::Other);
249    }
250
251    #[test]
252    fn bootstrap_error_preserves_kind_and_into_report() {
253        let err = BootstrapError::new(
254            BootstrapErrorKind::WorkerBinaryMissing,
255            eyre!("worker gone"),
256        );
257        assert_eq!(err.kind(), BootstrapErrorKind::WorkerBinaryMissing);
258        assert!(err.into_report().to_string().contains("worker gone"));
259    }
260
261    #[rstest]
262    #[case::config(BootstrapErrorKind::ExtensionConfigInvalid)]
263    #[case::manifest_unavailable(BootstrapErrorKind::ExtensionManifestUnavailable)]
264    #[case::manifest_digest(BootstrapErrorKind::ExtensionManifestDigestMismatch)]
265    #[case::manifest_invalid(BootstrapErrorKind::ExtensionManifestInvalid)]
266    #[case::unavailable(BootstrapErrorKind::ExtensionUnavailable)]
267    #[case::archive_unavailable(BootstrapErrorKind::ExtensionArchiveUnavailable)]
268    #[case::archive_digest(BootstrapErrorKind::ExtensionArchiveDigestMismatch)]
269    #[case::archive_invalid(BootstrapErrorKind::ExtensionArchiveInvalid)]
270    #[case::install_failed(BootstrapErrorKind::ExtensionInstallFailed)]
271    fn extension_error_kinds_survive_wrapping(#[case] kind: BootstrapErrorKind) {
272        let err = BootstrapError::new(kind, eyre!("extension detail"));
273        assert_eq!(err.kind(), kind);
274        let wrapped = BootstrapError::from(PgEmbeddedError::Bootstrap(err));
275        assert_eq!(wrapped.kind(), kind);
276        assert!(wrapped.to_string().contains("extension detail"));
277    }
278
279    #[test]
280    fn bootstrap_error_from_privilege_error_is_other() {
281        let privilege = PrivilegeError::from(eyre!("no privileges"));
282        let err = BootstrapError::from(privilege);
283        assert_eq!(err.kind(), BootstrapErrorKind::Other);
284        assert!(err.to_string().contains("no privileges"));
285    }
286
287    #[test]
288    fn bootstrap_error_from_config_error_is_other() {
289        let config = ConfigError::from(eyre!("bad config"));
290        let err = BootstrapError::from(config);
291        assert_eq!(err.kind(), BootstrapErrorKind::Other);
292        assert!(err.to_string().contains("bad config"));
293    }
294
295    #[rstest]
296    #[case::bootstrap(
297        |report| PgEmbeddedError::Bootstrap(BootstrapError::new(
298            BootstrapErrorKind::WorkerBinaryPathNonUtf8,
299            report,
300        )),
301        BootstrapErrorKind::WorkerBinaryPathNonUtf8,
302    )]
303    #[case::privilege(
304        |report| PgEmbeddedError::Privilege(PrivilegeError::from(report)),
305        BootstrapErrorKind::Other,
306    )]
307    #[case::config(
308        |report| PgEmbeddedError::Config(ConfigError::from(report)),
309        BootstrapErrorKind::Other,
310    )]
311    fn bootstrap_error_from_pg_embedded_error_maps_each_variant(
312        #[case] constructor: fn(Report) -> PgEmbeddedError,
313        #[case] expected_kind: BootstrapErrorKind,
314    ) {
315        let pg_err = constructor(eyre!("inner detail"));
316        let err = BootstrapError::from(pg_err);
317        assert_eq!(err.kind(), expected_kind);
318        assert!(err.to_string().contains("inner detail"));
319    }
320}