use crate::proc;
use anyhow::{Context, Result};
use std::io::{self, BufRead, Write};
use std::process::Command;
const ACCOUNT: &str = "moor";
fn service_name(project: &str, name: &str) -> String {
format!("moor-{project}-{name}")
}
pub fn get(project: &str, name: &str) -> Result<Option<String>> {
let service = service_name(project, name);
let result = proc::run_capture(
"security",
&["find-generic-password", "-a", ACCOUNT, "-s", &service, "-w"],
);
let (status, out) = match result {
Ok(v) => v,
Err(_) => return Ok(None),
};
if !status.success() {
return Ok(None);
}
let value = out.trim_end_matches('\n').to_string();
if value.is_empty() {
Ok(None)
} else {
Ok(Some(value))
}
}
pub fn set(project: &str, name: &str) -> Result<()> {
let service = service_name(project, name);
print!("Value for {name} (project '{project}'): ");
io::stdout().flush().ok();
let value = read_hidden_line()?;
let value = value.trim();
if value.is_empty() {
anyhow::bail!("empty value — not stored");
}
let status = proc::run_capture(
"security",
&[
"add-generic-password",
"-a",
ACCOUNT,
"-s",
&service,
"-w",
value,
"-U",
],
)
.context("running `security add-generic-password`")?
.0;
proc::require_success("security add-generic-password", status)?;
println!(
"stored {name} for project '{project}' in the macOS Keychain ({} characters — check that matches what you meant to paste).",
value.len()
);
Ok(())
}
pub fn unset(project: &str, name: &str) -> Result<()> {
let service = service_name(project, name);
let _ = proc::run_capture(
"security",
&["delete-generic-password", "-a", ACCOUNT, "-s", &service],
);
println!("removed {name} for project '{project}' from the macOS Keychain (if it was there).");
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
pub enum Source {
Env,
Keychain,
Missing,
}
pub fn status(project: &str, secret_names: &[String]) -> Vec<(String, Source)> {
secret_names
.iter()
.map(|name| {
let source = if std::env::var(name).is_ok() {
Source::Env
} else if matches!(get(project, name), Ok(Some(_))) {
Source::Keychain
} else {
Source::Missing
};
(name.clone(), source)
})
.collect()
}
pub fn resolve_into_env(project: &str, secret_names: &[String]) {
for name in secret_names {
if std::env::var(name).is_ok() {
continue;
}
if let Ok(Some(value)) = get(project, name) {
std::env::set_var(name, value);
}
}
}
fn read_hidden_line() -> Result<String> {
use std::process::Stdio;
let echo_disabled = Command::new("stty")
.arg("-echo")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
let mut line = String::new();
io::stdin().lock().read_line(&mut line)?;
if echo_disabled {
let _ = Command::new("stty")
.arg("echo")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
println!();
}
Ok(line)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn service_name_is_scoped_by_project_and_secret_name() {
assert_eq!(
service_name("my-app", "GITHUB_TOKEN"),
"moor-my-app-GITHUB_TOKEN"
);
assert_ne!(
service_name("project-a", "API_KEY"),
service_name("project-b", "API_KEY"),
"two projects must never collide on the same Keychain service name"
);
}
#[test]
fn status_reports_env_before_checking_keychain() {
std::env::set_var("MOOR_TEST_STATUS_SECRET", "some-value");
let result = status("some-project", &["MOOR_TEST_STATUS_SECRET".to_string()]);
assert_eq!(
result,
vec![("MOOR_TEST_STATUS_SECRET".to_string(), Source::Env)]
);
std::env::remove_var("MOOR_TEST_STATUS_SECRET");
}
#[test]
fn status_reports_missing_when_neither_env_nor_keychain_has_it() {
std::env::remove_var("MOOR_TEST_STATUS_SECRET_UNSET");
let result = status(
"moor-secrets-test-project-that-does-not-exist",
&["MOOR_TEST_STATUS_SECRET_UNSET".to_string()],
);
assert_eq!(
result,
vec![("MOOR_TEST_STATUS_SECRET_UNSET".to_string(), Source::Missing)]
);
}
}