use std::{
fs::File,
io::{ErrorKind, Read},
};
use camino::Utf8Path;
use color_eyre::eyre::{Report, eyre};
use postgresql_embedded::Settings;
use tracing::{info, warn};
use crate::{
error::{BootstrapError, BootstrapErrorKind, BootstrapResult},
observability::{self, LOG_TARGET, Metric, PasswordReuseOutcomeMetric},
};
const PG_VERSION_MARKER: &str = "PG_VERSION";
const MAX_PASSWORD_FILE_BYTES: u64 = 4096;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PasswordReuseOutcome {
Reused,
ExplicitPassword,
NoCluster,
}
pub fn stored_cluster_password(
data_dir: &Utf8Path,
password_file: &Utf8Path,
) -> BootstrapResult<Option<String>> {
query_stored_password(data_dir, password_file).map_err(|failure| failure.error)
}
pub(super) struct PasswordQueryFailure {
outcome: &'static str,
metric: PasswordReuseOutcomeMetric,
pub(super) error: BootstrapError,
}
fn query_stored_password(
data_dir: &Utf8Path,
password_file: &Utf8Path,
) -> Result<Option<String>, PasswordQueryFailure> {
if !has_cluster_marker(data_dir)? {
return Ok(None);
}
read_stored_password(password_file, data_dir).map(Some)
}
pub(super) fn has_cluster_marker(data_dir: &Utf8Path) -> Result<bool, PasswordQueryFailure> {
match std::fs::metadata(data_dir.join(PG_VERSION_MARKER)) {
Ok(metadata) => Ok(metadata.is_file()),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
Err(err) => Err(PasswordQueryFailure {
outcome: "probe_failed",
metric: PasswordReuseOutcomeMetric::ProbeFailed,
error: BootstrapError::new(
BootstrapErrorKind::ClusterPasswordUnreadable,
Report::new(err).wrap_err(format!(
"cannot probe {data_dir} for an existing cluster ({PG_VERSION_MARKER})"
)),
),
}),
}
}
pub fn reuse_existing_password(
settings: &mut Settings,
data_dir: &Utf8Path,
password_file: &Utf8Path,
is_password_explicit: bool,
) -> BootstrapResult<PasswordReuseOutcome> {
let outcome = if is_password_explicit {
PasswordReuseOutcome::ExplicitPassword
} else {
match query_stored_password(data_dir, password_file) {
Ok(Some(stored)) => {
settings.password = stored;
PasswordReuseOutcome::Reused
}
Ok(None) => PasswordReuseOutcome::NoCluster,
Err(failure) => {
log_failure(failure.outcome, data_dir, password_file);
observability::record(Metric::PasswordReuse(failure.metric));
return Err(failure.error);
}
}
};
log_outcome(outcome, data_dir, password_file);
observability::record(Metric::PasswordReuse(match outcome {
PasswordReuseOutcome::Reused => PasswordReuseOutcomeMetric::Reused,
PasswordReuseOutcome::ExplicitPassword => PasswordReuseOutcomeMetric::ExplicitPassword,
PasswordReuseOutcome::NoCluster => PasswordReuseOutcomeMetric::NoCluster,
}));
Ok(outcome)
}
fn log_outcome(outcome: PasswordReuseOutcome, data_dir: &Utf8Path, password_file: &Utf8Path) {
info!(
target: LOG_TARGET,
outcome = ?outcome,
data_dir = %data_dir,
password_file = %password_file,
"password_reuse"
);
}
fn log_failure(outcome: &'static str, data_dir: &Utf8Path, password_file: &Utf8Path) {
warn!(
target: LOG_TARGET,
outcome,
data_dir = %data_dir,
password_file = %password_file,
"password_reuse"
);
}
fn read_stored_password(
password_file: &Utf8Path,
data_dir: &Utf8Path,
) -> Result<String, PasswordQueryFailure> {
let raw = read_bounded(password_file).map_err(|err| {
let (kind, hint, outcome, metric) = if err.kind() == ErrorKind::NotFound {
(
BootstrapErrorKind::ClusterPasswordMissing,
"is missing",
"missing_file",
PasswordReuseOutcomeMetric::MissingFile,
)
} else {
(
BootstrapErrorKind::ClusterPasswordUnreadable,
"cannot be read",
"unreadable_file",
PasswordReuseOutcomeMetric::UnreadableFile,
)
};
PasswordQueryFailure {
outcome,
metric,
error: BootstrapError::new(
kind,
Report::new(err).wrap_err(format!(
"data directory {data_dir} already holds a cluster but its password file \
{password_file} {hint}; set PG_PASSWORD to the password that initialized it, \
or remove the stale cluster"
)),
),
}
})?;
let stored = raw.trim_end_matches(['\n', '\r']).to_owned();
if stored.is_empty() {
return Err(PasswordQueryFailure {
outcome: "empty_file",
metric: PasswordReuseOutcomeMetric::EmptyFile,
error: BootstrapError::new(
BootstrapErrorKind::ClusterPasswordEmpty,
eyre!(
"password file {password_file} is empty; set PG_PASSWORD or remove the stale \
cluster at {data_dir}"
),
),
});
}
Ok(stored)
}
#[cfg(unix)]
fn open_without_blocking(password_file: &Utf8Path) -> std::io::Result<File> {
use std::os::unix::fs::OpenOptionsExt as _;
std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK)
.open(password_file)
}
#[cfg(not(unix))]
fn open_without_blocking(password_file: &Utf8Path) -> std::io::Result<File> {
File::open(password_file)
}
fn read_bounded(password_file: &Utf8Path) -> std::io::Result<String> {
let file = open_without_blocking(password_file)?;
let metadata = file.metadata()?;
if !metadata.is_file() {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
format!("{password_file} is not a regular file"),
));
}
if metadata.len() > MAX_PASSWORD_FILE_BYTES {
return Err(oversized(password_file, metadata.len()));
}
let mut raw = String::new();
file.take(MAX_PASSWORD_FILE_BYTES + 1)
.read_to_string(&mut raw)?;
if raw.len() as u64 > MAX_PASSWORD_FILE_BYTES {
return Err(oversized(password_file, raw.len() as u64));
}
Ok(raw)
}
fn oversized(password_file: &Utf8Path, observed: u64) -> std::io::Error {
std::io::Error::new(
ErrorKind::InvalidData,
format!(
"password file {password_file} is {observed} bytes, above the \
{MAX_PASSWORD_FILE_BYTES}-byte limit"
),
)
}
#[cfg(test)]
#[path = "password_tests.rs"]
mod tests;