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;