use camino::Utf8PathBuf;
use color_eyre::eyre::Result;
use rstest::{fixture, rstest};
use super::*;
macro_rules! assert_explicit_password_kept {
($dir:expr) => {{
let dir = $dir;
let mut settings = Settings {
password: "explicit".into(),
..Settings::default()
};
let outcome =
reuse_existing_password(&mut settings, &dir.data_dir, &dir.password_file, true)
.expect("an explicit password must not consult the stored file");
assert_eq!(outcome, PasswordReuseOutcome::ExplicitPassword);
assert_eq!(settings.password, "explicit");
}};
}
struct Scratch {
_temp: tempfile::TempDir,
data_dir: Utf8PathBuf,
password_file: Utf8PathBuf,
}
impl Scratch {
fn with_cluster(&self) -> Result<()> {
std::fs::write(self.data_dir.join(PG_VERSION_MARKER), "17\n")?;
Ok(())
}
fn with_stored(&self, value: &str) -> Result<()> {
std::fs::write(&self.password_file, value)?;
Ok(())
}
}
#[fixture]
fn scratch() -> Result<Scratch> {
let temp = tempfile::tempdir()?;
let root = Utf8PathBuf::from_path_buf(temp.path().to_path_buf())
.map_err(|path| eyre!("non-UTF-8 tempdir {}", path.display()))?;
let data_dir = root.join("data");
std::fs::create_dir_all(&data_dir)?;
Ok(Scratch {
_temp: temp,
data_dir,
password_file: root.join(".pgpass"),
})
}
fn arrange(scratch: &Scratch, with_cluster: bool, stored: Option<&str>) -> Result<()> {
if with_cluster {
scratch.with_cluster()?;
}
if let Some(value) = stored {
scratch.with_stored(value)?;
}
Ok(())
}
struct ReuseCase {
with_cluster: bool,
stored: Option<&'static str>,
explicit: bool,
expected: PasswordReuseOutcome,
expected_password: &'static str,
}
#[rstest]
#[case::existing_cluster(ReuseCase { with_cluster: true, stored: Some("kept-secret\n"), explicit: false, expected: PasswordReuseOutcome::Reused, expected_password: "kept-secret" })]
#[case::explicit_wins(ReuseCase { with_cluster: true, stored: Some("kept-secret"), explicit: true, expected: PasswordReuseOutcome::ExplicitPassword, expected_password: "fresh" })]
#[case::no_cluster(ReuseCase { with_cluster: false, stored: None, explicit: false, expected: PasswordReuseOutcome::NoCluster, expected_password: "fresh" })]
fn reuse_rules(scratch: Result<Scratch>, #[case] case: ReuseCase) {
let dir = scratch.expect("scratch");
arrange(&dir, case.with_cluster, case.stored).expect("arrange");
let mut settings = Settings {
password: "fresh".into(),
..Settings::default()
};
let adopted = reuse_existing_password(
&mut settings,
&dir.data_dir,
&dir.password_file,
case.explicit,
)
.expect("no error");
assert_eq!(adopted, case.expected);
assert_eq!(settings.password, case.expected_password);
}
#[rstest]
fn no_cluster_yields_none_without_reading(scratch: Result<Scratch>) {
let dir = scratch.expect("scratch");
let stored = stored_cluster_password(&dir.data_dir, &dir.password_file)
.expect("no cluster is not an error");
assert_eq!(stored, None);
}
#[rstest]
#[case::missing(None, "is missing", BootstrapErrorKind::ClusterPasswordMissing)]
#[case::empty(Some(""), "is empty", BootstrapErrorKind::ClusterPasswordEmpty)]
fn missing_or_empty_password_file_fails(
scratch: Result<Scratch>,
#[case] stored: Option<&str>,
#[case] needle: &str,
#[case] kind: BootstrapErrorKind,
) {
let dir = scratch.expect("scratch");
arrange(&dir, true, stored).expect("arrange");
let mut settings = Settings::default();
let err = reuse_existing_password(&mut settings, &dir.data_dir, &dir.password_file, false)
.expect_err("must fail");
let message = err.to_string();
assert!(message.contains(needle), "{message}");
assert!(message.contains("PG_PASSWORD"), "{message}");
assert!(
message.contains(dir.data_dir.as_str()),
"the message must name the data directory: {message}"
);
assert_eq!(err.kind(), kind);
}
#[cfg(unix)]
#[rstest]
fn unreadable_password_file_is_an_error(scratch: Result<Scratch>) {
use std::os::unix::fs::PermissionsExt;
if nix::unistd::geteuid().is_root() {
return; }
let dir = scratch.expect("scratch");
arrange(&dir, true, Some("kept-secret")).expect("arrange");
std::fs::set_permissions(&dir.password_file, std::fs::Permissions::from_mode(0o000))
.expect("chmod");
let err = stored_cluster_password(&dir.data_dir, &dir.password_file)
.expect_err("an unreadable password file must not read as missing");
assert_eq!(err.kind(), BootstrapErrorKind::ClusterPasswordUnreadable);
assert!(err.to_string().contains("cannot be read"), "{err}");
assert!(
err.to_string().contains(dir.data_dir.as_str()),
"the message must name the data directory: {err}"
);
}
#[cfg(unix)]
#[rstest]
fn unsearchable_data_dir_is_an_error(scratch: Result<Scratch>) {
use std::os::unix::fs::PermissionsExt;
if nix::unistd::geteuid().is_root() {
return; }
let dir = scratch.expect("scratch");
std::fs::set_permissions(&dir.data_dir, std::fs::Permissions::from_mode(0o000)).expect("chmod");
let outcome = stored_cluster_password(&dir.data_dir, &dir.password_file);
std::fs::set_permissions(&dir.data_dir, std::fs::Permissions::from_mode(0o700))
.expect("restore");
let err = outcome.expect_err("EACCES must not read as no cluster");
assert_eq!(err.kind(), BootstrapErrorKind::ClusterPasswordUnreadable);
assert!(
err.to_string().contains(dir.data_dir.as_str()),
"the message must name the data directory: {err}"
);
}
#[rstest]
#[case::missing_stored_file(None)]
#[case::empty_stored_file(Some(""))]
fn explicit_password_survives_an_invalid_stored_file(
scratch: Result<Scratch>,
#[case] stored: Option<&str>,
) {
let dir = scratch.expect("scratch");
arrange(&dir, true, stored).expect("arrange");
assert_explicit_password_kept!(&dir);
}
#[cfg(unix)]
#[rstest]
fn explicit_password_survives_an_unreadable_stored_file(scratch: Result<Scratch>) {
use std::os::unix::fs::PermissionsExt;
if nix::unistd::geteuid().is_root() {
return; }
let dir = scratch.expect("scratch");
arrange(&dir, true, Some("kept-secret")).expect("arrange");
std::fs::set_permissions(&dir.password_file, std::fs::Permissions::from_mode(0o000))
.expect("chmod");
assert_explicit_password_kept!(&dir);
}
#[rstest]
fn an_oversized_password_file_is_refused(scratch: Result<Scratch>) {
let dir = scratch.expect("scratch");
dir.with_cluster().expect("cluster");
let oversized = "x".repeat(usize::try_from(MAX_PASSWORD_FILE_BYTES).expect("cap fits") + 1);
dir.with_stored(&oversized).expect("stored");
let err = stored_cluster_password(&dir.data_dir, &dir.password_file)
.expect_err("an oversized password file must be refused");
assert_eq!(err.kind(), BootstrapErrorKind::ClusterPasswordUnreadable);
}
#[rstest]
fn a_password_file_at_the_cap_is_read(scratch: Result<Scratch>) {
let dir = scratch.expect("scratch");
dir.with_cluster().expect("cluster");
let at_cap = "x".repeat(usize::try_from(MAX_PASSWORD_FILE_BYTES).expect("cap fits"));
dir.with_stored(&at_cap).expect("stored");
let stored = stored_cluster_password(&dir.data_dir, &dir.password_file)
.expect("a password file at the cap must be read");
assert_eq!(stored, Some(at_cap));
}
#[cfg(unix)]
#[rstest]
fn a_non_regular_password_file_is_refused(scratch: Result<Scratch>) {
let dir = scratch.expect("scratch");
dir.with_cluster().expect("cluster");
nix::unistd::mkfifo(
dir.password_file.as_std_path(),
nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
)
.expect("mkfifo");
let err = stored_cluster_password(&dir.data_dir, &dir.password_file)
.expect_err("a FIFO must not be read as a password file");
assert_eq!(err.kind(), BootstrapErrorKind::ClusterPasswordUnreadable);
assert!(
format!("{err:?}").contains("not a regular file"),
"the refusal must name the file type, not a read failure: {err:?}"
);
}
#[path = "password_metrics_tests.rs"]
mod metrics;
#[path = "password_tracing_tests.rs"]
mod tracing_events;