Skip to main content

pg_embedded_setup_unpriv/bootstrap/prepare/
password.rs

1//! Reuses the superuser password of an existing cluster.
2//!
3//! `postgresql_embedded::Settings::default()` generates a fresh random password
4//! on every bootstrap, but a data directory that already holds a cluster keeps
5//! the password its `initdb` was given. Without this step every later
6//! bootstrap on a host with prior state starts a server nobody can log in to.
7//! The password `initdb` used is the one `postgresql_embedded` wrote to the
8//! password file, so it is read back from there.
9
10use std::{
11    fs::File,
12    io::{ErrorKind, Read},
13};
14
15use camino::Utf8Path;
16use color_eyre::eyre::{Report, eyre};
17use postgresql_embedded::Settings;
18use tracing::{info, warn};
19
20use crate::{
21    error::{BootstrapError, BootstrapErrorKind, BootstrapResult},
22    observability::{self, LOG_TARGET, Metric, PasswordReuseOutcomeMetric},
23};
24
25/// Marker `initdb` leaves in a data directory.
26const PG_VERSION_MARKER: &str = "PG_VERSION";
27
28/// Most a password file may hold before the read is refused.
29///
30/// `postgresql_embedded` writes one password and a newline, so the real file
31/// is tens of bytes. The cap exists because [`stored_cluster_password`] is
32/// public and takes any path: without it a caller could aim the bootstrap at
33/// an arbitrarily large file and the read would allocate all of it before
34/// anything rejected the contents. Four kibibytes leaves room for any password
35/// a person or a generator would produce.
36const MAX_PASSWORD_FILE_BYTES: u64 = 4096;
37
38/// Why the bootstrap did or did not adopt a stored password; a bounded label
39/// for the `password_reuse` tracing event.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum PasswordReuseOutcome {
42    /// The stored password was adopted.
43    Reused,
44    /// The caller supplied `PG_PASSWORD`, which always wins.
45    ExplicitPassword,
46    /// The data directory holds no cluster, so a fresh password is fine.
47    NoCluster,
48}
49
50/// Reads the password an existing cluster in `data_dir` was initialized with.
51///
52/// This is the query half of password reuse: it reads the filesystem and
53/// changes nothing, including emitting nothing. A caller that wants the
54/// `password_reuse` failure event calls [`reuse_existing_password`], which is
55/// the command half and publishes on every branch. `Ok(None)` means the data directory holds no
56/// cluster (no `PG_VERSION` marker). A cluster whose password file is missing,
57/// unreadable, or empty is an error, because a server started against it
58/// could not be logged in to.
59///
60/// # Errors
61///
62/// Returns an error naming the data directory, the password file and the
63/// remedies (`PG_PASSWORD`, or removing the stale cluster).
64///
65/// # Examples
66///
67/// ```no_run
68/// use camino::Utf8Path;
69/// use pg_embedded_setup_unpriv::stored_cluster_password;
70///
71/// # fn main() -> pg_embedded_setup_unpriv::BootstrapResult<()> {
72/// let stored = stored_cluster_password(
73///     Utf8Path::new("/var/tmp/pg-embed-1000/data"),
74///     Utf8Path::new("/var/tmp/pg-embed-1000/install/.pgpass"),
75/// )?;
76/// assert!(stored.is_none() || stored.is_some_and(|password| !password.is_empty()));
77/// # Ok(())
78/// # }
79/// ```
80pub fn stored_cluster_password(
81    data_dir: &Utf8Path,
82    password_file: &Utf8Path,
83) -> BootstrapResult<Option<String>> {
84    query_stored_password(data_dir, password_file).map_err(|failure| failure.error)
85}
86
87/// A failed query, carrying the bounded label its event would use.
88///
89/// The label travels with the error instead of being emitted here, so the
90/// query stays free of side effects and the command decides what to publish.
91pub(super) struct PasswordQueryFailure {
92    /// Bounded `outcome` label: `probe_failed`, `missing_file`,
93    /// `unreadable_file` or `empty_file`.
94    outcome: &'static str,
95    /// The same outcome as a bounded metric label.
96    metric: PasswordReuseOutcomeMetric,
97    /// The categorized error to return.
98    pub(super) error: BootstrapError,
99}
100
101/// The query half proper: reads, categorizes, and emits nothing.
102fn query_stored_password(
103    data_dir: &Utf8Path,
104    password_file: &Utf8Path,
105) -> Result<Option<String>, PasswordQueryFailure> {
106    if !has_cluster_marker(data_dir)? {
107        return Ok(None);
108    }
109    read_stored_password(password_file, data_dir).map(Some)
110}
111
112/// Probes the `PG_VERSION` marker, treating only "not found" as "no
113/// cluster"; a permission failure or any other I/O error is propagated so a
114/// temporarily unsearchable data directory cannot masquerade as a fresh one.
115pub(super) fn has_cluster_marker(data_dir: &Utf8Path) -> Result<bool, PasswordQueryFailure> {
116    match std::fs::metadata(data_dir.join(PG_VERSION_MARKER)) {
117        Ok(metadata) => Ok(metadata.is_file()),
118        Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
119        Err(err) => Err(PasswordQueryFailure {
120            outcome: "probe_failed",
121            metric: PasswordReuseOutcomeMetric::ProbeFailed,
122            error: BootstrapError::new(
123                BootstrapErrorKind::ClusterPasswordUnreadable,
124                Report::new(err).wrap_err(format!(
125                    "cannot probe {data_dir} for an existing cluster ({PG_VERSION_MARKER})"
126                )),
127            ),
128        }),
129    }
130}
131
132/// Aligns `settings.password` with the cluster already present in
133/// `data_dir`, unless the caller supplied an explicit password.
134///
135/// This is the command half: it consults the query, mutates only
136/// `settings.password`, and is the only place a `password_reuse` event is
137/// emitted or a [`Metric::PasswordReuse`] recorded, on success and on every
138/// failure branch. Exactly one of each per call. Returns the outcome so
139/// callers and the event can report which branch was taken.
140///
141/// # Errors
142///
143/// Propagates [`stored_cluster_password`]'s error when the data directory
144/// holds a cluster but its password file is missing, unreadable or empty.
145///
146/// # Examples
147///
148/// ```no_run
149/// use camino::Utf8Path;
150/// use pg_embedded_setup_unpriv::{PasswordReuseOutcome, PgEnvCfg, reuse_existing_password};
151/// use postgresql_embedded::Settings;
152///
153/// # fn main() -> pg_embedded_setup_unpriv::BootstrapResult<()> {
154/// let cfg = PgEnvCfg::default();
155/// let mut settings = cfg.to_settings()?;
156/// let outcome = reuse_existing_password(
157///     &mut settings,
158///     Utf8Path::new("/var/tmp/pg-embed-1000/data"),
159///     Utf8Path::new("/var/tmp/pg-embed-1000/install/.pgpass"),
160///     cfg.password.is_some(),
161/// )?;
162/// assert!(outcome != PasswordReuseOutcome::Reused || !settings.password.is_empty());
163/// # Ok(())
164/// # }
165/// ```
166pub fn reuse_existing_password(
167    settings: &mut Settings,
168    data_dir: &Utf8Path,
169    password_file: &Utf8Path,
170    is_password_explicit: bool,
171) -> BootstrapResult<PasswordReuseOutcome> {
172    let outcome = if is_password_explicit {
173        PasswordReuseOutcome::ExplicitPassword
174    } else {
175        // The query publishes nothing, so the command emits the failure event
176        // on its behalf before propagating the error.
177        match query_stored_password(data_dir, password_file) {
178            Ok(Some(stored)) => {
179                settings.password = stored;
180                PasswordReuseOutcome::Reused
181            }
182            Ok(None) => PasswordReuseOutcome::NoCluster,
183            Err(failure) => {
184                log_failure(failure.outcome, data_dir, password_file);
185                observability::record(Metric::PasswordReuse(failure.metric));
186                return Err(failure.error);
187            }
188        }
189    };
190    log_outcome(outcome, data_dir, password_file);
191    observability::record(Metric::PasswordReuse(match outcome {
192        PasswordReuseOutcome::Reused => PasswordReuseOutcomeMetric::Reused,
193        PasswordReuseOutcome::ExplicitPassword => PasswordReuseOutcomeMetric::ExplicitPassword,
194        PasswordReuseOutcome::NoCluster => PasswordReuseOutcomeMetric::NoCluster,
195    }));
196    Ok(outcome)
197}
198
199/// Emits the bounded `password_reuse` event; no path or secret is a label.
200fn log_outcome(outcome: PasswordReuseOutcome, data_dir: &Utf8Path, password_file: &Utf8Path) {
201    info!(
202        target: LOG_TARGET,
203        outcome = ?outcome,
204        data_dir = %data_dir,
205        password_file = %password_file,
206        "password_reuse"
207    );
208}
209
210/// Emits the bounded `password_reuse` failure event before the error is
211/// returned; `outcome` is one of `probe_failed`, `missing_file`,
212/// `unreadable_file` or `empty_file`, and no secret is ever a label.
213fn log_failure(outcome: &'static str, data_dir: &Utf8Path, password_file: &Utf8Path) {
214    warn!(
215        target: LOG_TARGET,
216        outcome,
217        data_dir = %data_dir,
218        password_file = %password_file,
219        "password_reuse"
220    );
221}
222
223/// Reads and trims the stored password, turning I/O and emptiness into a
224/// categorized error that keeps the original `io::Error` as its source.
225fn read_stored_password(
226    password_file: &Utf8Path,
227    data_dir: &Utf8Path,
228) -> Result<String, PasswordQueryFailure> {
229    let raw = read_bounded(password_file).map_err(|err| {
230        let (kind, hint, outcome, metric) = if err.kind() == ErrorKind::NotFound {
231            (
232                BootstrapErrorKind::ClusterPasswordMissing,
233                "is missing",
234                "missing_file",
235                PasswordReuseOutcomeMetric::MissingFile,
236            )
237        } else {
238            (
239                BootstrapErrorKind::ClusterPasswordUnreadable,
240                "cannot be read",
241                "unreadable_file",
242                PasswordReuseOutcomeMetric::UnreadableFile,
243            )
244        };
245        PasswordQueryFailure {
246            outcome,
247            metric,
248            error: BootstrapError::new(
249                kind,
250                Report::new(err).wrap_err(format!(
251                    "data directory {data_dir} already holds a cluster but its password file \
252                     {password_file} {hint}; set PG_PASSWORD to the password that initialized it, \
253                     or remove the stale cluster"
254                )),
255            ),
256        }
257    })?;
258    let stored = raw.trim_end_matches(['\n', '\r']).to_owned();
259    if stored.is_empty() {
260        return Err(PasswordQueryFailure {
261            outcome: "empty_file",
262            metric: PasswordReuseOutcomeMetric::EmptyFile,
263            error: BootstrapError::new(
264                BootstrapErrorKind::ClusterPasswordEmpty,
265                eyre!(
266                    "password file {password_file} is empty; set PG_PASSWORD or remove the stale \
267                     cluster at {data_dir}"
268                ),
269            ),
270        });
271    }
272    Ok(stored)
273}
274
275/// Opens the password file without blocking on a FIFO.
276///
277/// `O_NONBLOCK` makes the open return at once even when the path names a FIFO
278/// with no writer, so the handle can be classified rather than the bootstrap
279/// hanging. Read-only is the direction that matters: a read-only open of a
280/// writerless FIFO succeeds immediately under `O_NONBLOCK`, and the caller
281/// then refuses it on the handle's own type. The flag changes nothing for a
282/// regular file, which is the only kind the caller goes on to read.
283#[cfg(unix)]
284fn open_without_blocking(password_file: &Utf8Path) -> std::io::Result<File> {
285    use std::os::unix::fs::OpenOptionsExt as _;
286
287    std::fs::OpenOptions::new()
288        .read(true)
289        .custom_flags(libc::O_NONBLOCK)
290        .open(password_file)
291}
292
293/// Opens the password file; no flag is needed off Unix.
294///
295/// Windows has no filesystem FIFO, so an ordinary open already returns
296/// without waiting for a peer and the caller's classification of the opened
297/// handle carries the same guarantee.
298#[cfg(not(unix))]
299fn open_without_blocking(password_file: &Utf8Path) -> std::io::Result<File> {
300    File::open(password_file)
301}
302
303/// Reads at most [`MAX_PASSWORD_FILE_BYTES`] from a regular file.
304///
305/// Three refusals, all reported as ordinary [`std::io::Error`]s so the caller
306/// keeps its existing "missing" versus "unreadable" split:
307///
308/// - anything that is not a regular file, which is decided from the opened handle rather than from
309///   the path. Classifying the path and then opening it leaves a window in which the path is
310///   replaced, and a FIFO substituted into that window would hang the bootstrap on Unix; opening
311///   first with `O_NONBLOCK` closes the window without reintroducing the hang, because the
312///   classification and the read then use the one file the open resolved.
313/// - a file whose recorded length already exceeds the cap, which is refused without reading a byte.
314/// - a file that grows past the cap between the two, which the bounded read catches by asking for
315///   one byte more than the cap and rejecting a full buffer.
316fn read_bounded(password_file: &Utf8Path) -> std::io::Result<String> {
317    let file = open_without_blocking(password_file)?;
318    let metadata = file.metadata()?;
319    if !metadata.is_file() {
320        return Err(std::io::Error::new(
321            ErrorKind::InvalidInput,
322            format!("{password_file} is not a regular file"),
323        ));
324    }
325    if metadata.len() > MAX_PASSWORD_FILE_BYTES {
326        return Err(oversized(password_file, metadata.len()));
327    }
328
329    let mut raw = String::new();
330    file.take(MAX_PASSWORD_FILE_BYTES + 1)
331        .read_to_string(&mut raw)?;
332    if raw.len() as u64 > MAX_PASSWORD_FILE_BYTES {
333        return Err(oversized(password_file, raw.len() as u64));
334    }
335    Ok(raw)
336}
337
338/// The error both size refusals report.
339fn oversized(password_file: &Utf8Path, observed: u64) -> std::io::Error {
340    std::io::Error::new(
341        ErrorKind::InvalidData,
342        format!(
343            "password file {password_file} is {observed} bytes, above the \
344             {MAX_PASSWORD_FILE_BYTES}-byte limit"
345        ),
346    )
347}
348
349#[cfg(test)]
350#[path = "password_tests.rs"]
351mod tests;