use clap::Args as ClapArgs;
use serde_json::{json, Value};
use std::collections::BTreeSet;
use std::io::{IsTerminal, Read};
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;
use wait_timeout::ChildExt;
use crate::audit;
use crate::cli::Global;
use crate::envelope;
use crate::error::{AkmError, Result};
use crate::keychain;
#[derive(Debug, ClapArgs)]
pub struct Args {
#[arg(long, value_name = "PATH")]
pub from: PathBuf,
#[arg(long, value_delimiter = ',')]
pub only: Vec<String>,
#[arg(long)]
pub dry_run: bool,
}
pub fn run(args: Args, global: &Global) -> Result<u8> {
let source = args.from.canonicalize()?;
if source == std::env::current_exe()?.canonicalize()? {
return Err(AkmError::BadInput(
"--from must name a different, trusted older AKM binary".into(),
));
}
for name in &args.only {
keychain::validate_name(name).map_err(|e| AkmError::BadInput(e.to_string()))?;
}
let names: BTreeSet<String> = if args.only.is_empty() {
let result = source_json(&source, &["list", "--json"])?;
let values = result["data"]["keys"]
.as_array()
.ok_or_else(|| source_error("list response has no key names"))?;
values
.iter()
.map(|name| {
let name = name
.as_str()
.ok_or_else(|| source_error("invalid key name response"))?;
keychain::validate_name(name)
.map_err(|_| source_error("invalid key name response"))?;
Ok(name.to_owned())
})
.collect::<Result<_>>()?
} else {
args.only.into_iter().collect()
};
let existing: BTreeSet<_> = keychain::primary_names()?.into_iter().collect();
let mut migrated = Vec::new();
let mut skipped = Vec::new();
for name in names {
if existing.contains(&name) {
if !args.dry_run {
keychain::get_with_status(&name)?;
}
skipped.push(name);
continue;
}
if !args.dry_run {
let response = source_json(&source, &["get", &name, "--raw", "--json"])?;
let value = source_value(&response, &name)?;
if !keychain::create_if_absent(&name, value)? {
keychain::get_with_status(&name)?;
skipped.push(name);
continue;
}
if keychain::get_with_status(&name)? != value {
return Err(source_error(
"destination verification failed; originals are preserved",
));
}
let mut entry = audit::entry_base("migrate", "ok");
entry.keys = vec![name.clone()];
entry.input_mode = Some("pipe");
if let Err(error) = audit::append(&entry) {
if !global.quiet {
eprintln!("akm: warning: audit log write failed: {error}");
}
}
}
migrated.push(name);
}
if global.json || !std::io::stdout().is_terminal() {
println!(
"{}",
envelope::ok(json!({"dry_run":args.dry_run,
"migrated":migrated,"skipped":skipped,"originals_preserved":true}))
);
} else if !global.quiet {
eprintln!(
"akm: {} {} keys; {} already present; originals preserved",
if args.dry_run {
"would migrate"
} else {
"migrated"
},
migrated.len(),
skipped.len()
);
}
Ok(0)
}
fn source_error(message: &str) -> AkmError {
AkmError::KeychainUnavailable(format!("migration: {message}"))
}
fn source_value<'a>(response: &'a Value, name: &str) -> Result<&'a str> {
let data = &response["data"];
if data["name"] != name || data["masked"] != false {
return Err(source_error(
"source did not return the requested unmasked item",
));
}
data["value"]
.as_str()
.filter(|v| !v.is_empty() && !v.contains('\0'))
.ok_or_else(|| source_error("source returned an invalid value"))
}
fn source_json(path: &Path, args: &[&str]) -> Result<Value> {
const MAX_RESPONSE: u64 = 16 * 1024 * 1024;
let mut child = Command::new(path)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.process_group(0)
.spawn()?;
let stdout = child.stdout.take().expect("piped stdout");
let reader = std::thread::spawn(move || {
let mut bytes = Vec::new();
stdout
.take(MAX_RESPONSE + 1)
.read_to_end(&mut bytes)
.map(|_| bytes)
});
let waited = child.wait_timeout(Duration::from_secs(5));
unsafe {
libc::kill(-(child.id() as i32), libc::SIGKILL);
}
if !matches!(waited, Ok(Some(_))) {
let _ = child.wait();
}
let bytes = reader
.join()
.map_err(|_| source_error("source reader failed"))??;
let status = waited?.ok_or_else(|| {
source_error("source timed out after 5 seconds; rerun with a trusted, working older binary")
})?;
if !status.success() || bytes.len() as u64 > MAX_RESPONSE {
return Err(source_error(
"source failed; no values were printed and original items are unchanged",
));
}
let result: Value =
serde_json::from_slice(&bytes).map_err(|_| source_error("invalid source response"))?;
if result["status"] != "ok" {
return Err(source_error("source returned an error"));
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verifies_source_identity_and_exact_value() {
let good = json!({"data":{"name":"EXAMPLE","masked":false,"value":"space\n'quote'\n"}});
assert_eq!(source_value(&good, "EXAMPLE").unwrap(), "space\n'quote'\n");
assert!(source_value(&good, "DIFFERENT").is_err());
assert!(source_value(
&json!({"data":{"name":"EXAMPLE","masked":true,"value":"masked"}}),
"EXAMPLE"
)
.is_err());
assert!(source_value(
&json!({"data":{"name":"EXAMPLE","masked":false,"value":"\0"}}),
"EXAMPLE"
)
.is_err());
}
}