use std::path::{Path, PathBuf};
use miette::{Context as _, IntoDiagnostic as _, Result, bail};
use tracing::{debug, info};
use bestool_tamanu::{
find_tamanu,
secret_key::{
SecretKeyLocation, locate, podman_secret_exists, read_podman_secret, write_podman_secret,
},
};
const KEY_FILE_ENTRY: &str = "config-key";
pub(super) async fn location(
config: &super::method::TamanuSecretKeyConfig,
) -> Result<SecretKeyLocation> {
if let Some(path) = &config.path {
return classify_target(path);
}
let (_, root) = find_tamanu(config.root.as_deref()).await?;
locate(&root, config.package.as_deref()).await
}
pub(super) fn classify_target(path: &Path) -> Result<SecretKeyLocation> {
if path.is_dir() {
bail!(
"{} is a directory; the capture is a single key value, so name the key file itself",
path.display()
);
}
Ok(SecretKeyLocation::KeyFile(path.to_path_buf()))
}
pub(super) async fn stage(
location: &SecretKeyLocation,
backup_type: &str,
parent: &Path,
) -> Result<PathBuf> {
let staged = parent.join(format!("secret-key.{backup_type}"));
if staged.exists() {
tokio::fs::remove_dir_all(&staged).await.ok();
}
tokio::fs::create_dir_all(&staged)
.await
.into_diagnostic()
.wrap_err_with(|| format!("creating {}", staged.display()))?;
let into = staged.join(KEY_FILE_ENTRY);
match location {
SecretKeyLocation::KeyFile(key) => {
tokio::fs::copy(key, &into)
.await
.into_diagnostic()
.wrap_err_with(|| format!("copying {} to {}", key.display(), into.display()))?;
}
SecretKeyLocation::PodmanSecret(name) => {
let value = read_podman_secret(name).await?;
tokio::fs::write(&into, &value)
.await
.into_diagnostic()
.wrap_err_with(|| format!("writing {}", into.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
tokio::fs::set_permissions(&into, std::fs::Permissions::from_mode(0o600))
.await
.into_diagnostic()?;
}
}
}
debug!(location = %location, staged = %staged.display(), "staged the tamanu secret key");
Ok(staged)
}
#[cfg(target_os = "linux")]
pub(super) fn stage_parent() -> PathBuf {
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("/var/cache"))
.join("bestool")
.join("backup-staging")
}
#[cfg(not(target_os = "linux"))]
pub(super) fn stage_parent() -> PathBuf {
std::env::temp_dir()
}
pub(super) async fn lay_down(
staging: &Path,
location: &SecretKeyLocation,
clobber: bool,
) -> Result<()> {
let captured = staged_key(staging)?;
match location {
SecretKeyLocation::KeyFile(to) => place_file(&captured, to, clobber).await,
SecretKeyLocation::PodmanSecret(name) => place_secret(&captured, name, clobber).await,
}
}
fn staged_key(staging: &Path) -> Result<PathBuf> {
let key = staging.join(KEY_FILE_ENTRY);
if key.is_file() {
return Ok(key);
}
bail!(
"the restored tree at {} does not hold {KEY_FILE_ENTRY}, \
so it is not a tamanu_secret_key capture",
staging.display()
)
}
async fn place_file(from: &Path, to: &Path, clobber: bool) -> Result<()> {
if to.exists() && !clobber {
bail!(
"{} already holds a key; refusing to overwrite without confirmation \
(pass --clobber-existing-data-yes-i-am-sure, or confirm interactively)",
to.display()
);
}
if let Some(parent) = to.parent() {
tokio::fs::create_dir_all(parent).await.ok();
}
if to.exists() {
let aside = super::method::with_extension_suffix(to, "old");
tokio::fs::rename(to, &aside)
.await
.into_diagnostic()
.wrap_err_with(|| format!("moving {} aside to {}", to.display(), aside.display()))?;
}
tokio::fs::rename(from, to)
.await
.into_diagnostic()
.wrap_err_with(|| format!("moving the restored key into {}", to.display()))?;
info!(key = %to.display(), "restored the tamanu config key");
Ok(())
}
async fn place_secret(from: &Path, name: &str, clobber: bool) -> Result<()> {
let value = tokio::fs::read(from)
.await
.into_diagnostic()
.wrap_err_with(|| format!("reading the restored key at {}", from.display()))?;
if podman_secret_exists(name).await? {
if !clobber {
bail!(
"podman secret {name} already holds a key; refusing to overwrite without \
confirmation (pass --clobber-existing-data-yes-i-am-sure, or confirm \
interactively)"
);
}
let displaced = read_podman_secret(name).await?;
write_podman_secret(&format!("{name}.old"), &displaced).await?;
}
write_podman_secret(name, &value).await?;
info!(secret = name, "restored the tamanu config key");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
struct Scratch(Vec<String>);
#[cfg(target_os = "linux")]
impl Drop for Scratch {
fn drop(&mut self) {
for secret in &self.0 {
let _ = std::process::Command::new("podman")
.args(["secret", "rm", secret])
.status();
}
}
}
#[cfg(target_os = "linux")]
#[tokio::test]
#[ignore = "needs podman and root; run on a containerised host"]
async fn a_podman_secret_round_trips_through_a_capture() {
let name = format!("bestool-test-key-{}", std::process::id());
let displaced = format!("{name}.old");
let _guard = Scratch(vec![name.clone(), displaced.clone()]);
write_podman_secret(&name, b"captured-key")
.await
.expect("create the scratch secret");
let parent = tempfile::tempdir().expect("staging parent");
let location = SecretKeyLocation::PodmanSecret(name.clone());
let staged = stage(&location, "test-secret-key", parent.path())
.await
.expect("stage the podman secret");
let entry = staged.join(KEY_FILE_ENTRY);
assert_eq!(
std::fs::read(&entry).expect("read the staged key"),
b"captured-key",
"the staged tree holds the secret's value"
);
{
use std::os::unix::fs::PermissionsExt as _;
let mode = std::fs::metadata(&entry).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "a staged key is readable by root alone");
}
write_podman_secret(&name, b"live-key")
.await
.expect("replace the live secret");
lay_down(&staged, &location, true)
.await
.expect("restore the captured key");
assert_eq!(
read_podman_secret(&name).await.expect("read the restored secret"),
b"captured-key"
);
assert_eq!(
read_podman_secret(&displaced)
.await
.expect("read the displaced secret"),
b"live-key",
"the key the restore displaced is kept beside it"
);
}
#[cfg(target_os = "linux")]
#[tokio::test]
#[ignore = "needs podman and root; run on a containerised host"]
async fn a_restore_refuses_to_displace_a_secret_without_clobber() {
let name = format!("bestool-test-gate-{}", std::process::id());
let displaced = format!("{name}.old");
let _guard = Scratch(vec![name.clone(), displaced.clone()]);
write_podman_secret(&name, b"live-key")
.await
.expect("create the scratch secret");
let parent = tempfile::tempdir().expect("staging parent");
let location = SecretKeyLocation::PodmanSecret(name.clone());
let staged = stage(&location, "test-secret-key", parent.path())
.await
.expect("stage the podman secret");
let refused = lay_down(&staged, &location, false).await;
assert!(refused.is_err(), "a live key is displaced only on request");
assert_eq!(
read_podman_secret(&name).await.expect("read the live secret"),
b"live-key",
"the refused restore left the live key alone"
);
assert!(
!podman_secret_exists(&displaced)
.await
.expect("check for a displaced secret"),
"a refused restore keeps nothing aside"
);
}
#[test]
fn a_target_names_a_key_file_never_a_directory() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("secrets");
std::fs::create_dir_all(&dir).unwrap();
let file = tmp.path().join("tamanu.key");
std::fs::write(&file, "k").unwrap();
assert!(classify_target(&dir).is_err());
assert!(matches!(
classify_target(&file).unwrap(),
SecretKeyLocation::KeyFile(_)
));
assert!(matches!(
classify_target(&tmp.path().join("absent")).unwrap(),
SecretKeyLocation::KeyFile(_)
));
}
#[test]
fn a_restored_tree_must_hold_the_key_entry() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
std::fs::create_dir_all(&capture).unwrap();
std::fs::write(capture.join(KEY_FILE_ENTRY), "k").unwrap();
assert!(staged_key(&capture).is_ok());
let empty = tmp.path().join("empty");
std::fs::create_dir_all(&empty).unwrap();
assert!(staged_key(&empty).is_err());
}
#[tokio::test]
async fn a_key_file_capture_is_the_one_file_under_its_entry_name() {
let tmp = tempfile::tempdir().unwrap();
let key = tmp.path().join("tamanu.key");
std::fs::write(&key, "secret").unwrap();
let staged = stage(
&SecretKeyLocation::KeyFile(key),
"test-key-file",
tmp.path(),
)
.await
.unwrap();
assert_eq!(
std::fs::read_to_string(staged.join(KEY_FILE_ENTRY)).unwrap(),
"secret"
);
}
#[tokio::test]
async fn a_restored_key_keeps_the_displaced_one_beside_it() {
let tmp = tempfile::tempdir().unwrap();
let staging = tmp.path().join("staging");
std::fs::create_dir_all(&staging).unwrap();
std::fs::write(staging.join(KEY_FILE_ENTRY), "new").unwrap();
let target = tmp.path().join("config").join("tamanu.key");
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
std::fs::write(&target, "old").unwrap();
let location = SecretKeyLocation::KeyFile(target.clone());
assert!(lay_down(&staging, &location, false).await.is_err());
assert_eq!(std::fs::read_to_string(&target).unwrap(), "old");
lay_down(&staging, &location, true).await.unwrap();
assert_eq!(std::fs::read_to_string(&target).unwrap(), "new");
assert_eq!(
std::fs::read_to_string(super::super::method::with_extension_suffix(&target, "old"))
.unwrap(),
"old"
);
}
}