use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use owo_colors::OwoColorize;
use serde_yaml_ng::Value;
use crate::cli::{
RecipientAddArgs, RecipientBreakGlassArgs, RecipientCommand, RecipientKeygenArgs,
RecipientRemoveArgs,
};
use crate::config::{CONFIG_FILE_NAME, Config, Recipient};
use crate::error::{Error, Result};
use crate::ui::Ui;
use crate::{age, enclave, git, sops};
pub(crate) const SOPS_CONFIG_FILE_NAME: &str = ".sops.yaml";
pub(crate) const ASSUME_YES_ENV: &str = "SOPSY_ASSUME_YES";
pub(crate) fn assume_yes() -> bool {
std::env::var_os(ASSUME_YES_ENV).is_some()
}
pub fn run(ui: &Ui, command: &RecipientCommand) -> Result<()> {
match command {
RecipientCommand::Add(args) => add(ui, args),
RecipientCommand::Remove(args) => remove(ui, args),
RecipientCommand::List => list(ui),
RecipientCommand::Keygen(args) => keygen(ui, args),
RecipientCommand::BreakGlass(args) => break_glass(ui, args),
}
}
pub(crate) fn current_repo_root() -> Result<PathBuf> {
let cwd = std::env::current_dir()?;
git::repo_root(&cwd)
}
pub(crate) fn load_config(repo: &Path) -> Result<Config> {
match Config::load_from_dir(repo) {
Ok(config) => Ok(config),
Err(Error::FileNotFound(_)) => Err(Error::Validation(format!(
"{CONFIG_FILE_NAME} not found in {} — run `sopsy init` first",
repo.display()
))),
Err(other) => Err(other),
}
}
pub(crate) fn sops_config_path(repo: &Path) -> Result<PathBuf> {
let path = repo.join(SOPS_CONFIG_FILE_NAME);
if !path.exists() {
return Err(Error::Validation(format!(
"{SOPS_CONFIG_FILE_NAME} not found in {} — run `sopsy init` first",
repo.display()
)));
}
Ok(path)
}
fn add(ui: &Ui, args: &RecipientAddArgs) -> Result<()> {
ui.header("sopsy recipient add");
let repo = current_repo_root()?;
let mut config = load_config(&repo)?;
let sops_config = sops_config_path(&repo)?;
let name = match args.resolved_name() {
Some(name) => name.to_string(),
None => ui.text("Recipient name:", "--name")?,
};
let name = name.trim().to_string();
if name.is_empty() {
return Err(Error::Validation("recipient name must not be empty".into()));
}
let public_key = match args.public_key.as_deref() {
Some(key) => key.to_string(),
None => ui.text("Recipient age public key (age1…):", "--public-key")?,
};
let public_key = public_key.trim().to_string();
if public_key.is_empty() {
return Err(Error::Validation(
"recipient public key must not be empty".into(),
));
}
if config.recipient(&name).is_some() {
return Err(Error::Validation(format!(
"a recipient named `{name}` already exists"
)));
}
if let Some(existing) = config
.recipients
.iter()
.find(|r| r.public_key == public_key)
{
return Err(Error::Validation(format!(
"public key is already registered as `{}`",
existing.name
)));
}
let snapshot = ConfigSnapshot::capture(&repo, &sops_config);
let recipient = Recipient {
break_glass: args.break_glass,
..Recipient::new(&name, &public_key)
};
config.recipients.push(recipient);
config.save_to_dir(&repo)?;
ui.success(format!("recorded `{name}` in {CONFIG_FILE_NAME}"));
let modified = add_key_to_sops_yaml(&sops_config, &public_key)?;
if modified == 0 {
ui.warn(format!(
"no `age:` creation_rules matched in {SOPS_CONFIG_FILE_NAME}; left unchanged"
));
} else {
ui.success(format!(
"added the key to {modified} creation rule(s) in {SOPS_CONFIG_FILE_NAME}"
));
}
if args.break_glass {
ui.info(format!("`{name}` is marked as the break-glass recipient"));
}
if let Err(err) = run_updatekeys(ui, &repo, args.no_updatekeys) {
snapshot.restore()?;
ui.warn("rolled back configuration changes — no recipient was added");
return Err(rewrap_error(err));
}
ui.success(format!("recipient `{name}` added"));
Ok(())
}
fn remove(ui: &Ui, args: &RecipientRemoveArgs) -> Result<()> {
ui.header("sopsy recipient remove");
let repo = current_repo_root()?;
let mut config = load_config(&repo)?;
let sops_config = sops_config_path(&repo)?;
let name = match args.resolved_name() {
Some(name) => name.to_string(),
None => {
let options: Vec<String> = config.recipients.iter().map(|r| r.name.clone()).collect();
if options.is_empty() {
return Err(Error::Validation(
"there are no recipients to remove".into(),
));
}
ui.select("Recipient to remove:", "--name", options)?
}
};
let name = name.trim().to_string();
let recipient = config
.recipient(&name)
.cloned()
.ok_or_else(|| Error::Validation(format!("no recipient named `{name}`")))?;
if config.recipients.len() == 1 {
ui.warn("refusing to remove the last recipient — the repo would become undecryptable");
return Err(Error::Validation(
"cannot remove the only remaining recipient".into(),
));
}
if recipient.break_glass && config.recipients.iter().filter(|r| r.break_glass).count() == 1 {
ui.warn("refusing to remove the sole break-glass recipient");
return Err(Error::Validation(
"cannot remove the only break-glass recipient".into(),
));
}
let snapshot = ConfigSnapshot::capture(&repo, &sops_config);
config.recipients.retain(|r| r.name != name);
config.save_to_dir(&repo)?;
ui.success(format!("removed `{name}` from {CONFIG_FILE_NAME}"));
let modified = remove_key_from_sops_yaml(&sops_config, &recipient.public_key)?;
if modified == 0 {
ui.warn(format!(
"key was not present in {SOPS_CONFIG_FILE_NAME}; left unchanged"
));
} else {
ui.success(format!(
"removed the key from {modified} creation rule(s) in {SOPS_CONFIG_FILE_NAME}"
));
}
if let Err(err) = run_updatekeys(ui, &repo, args.no_updatekeys) {
snapshot.restore()?;
ui.warn("rolled back configuration changes — no recipient was removed");
return Err(rewrap_error(err));
}
ui.success(format!("recipient `{name}` removed"));
Ok(())
}
pub(crate) struct ConfigSnapshot {
files: [(PathBuf, Option<Vec<u8>>); 2],
}
impl ConfigSnapshot {
pub(crate) fn capture(repo: &Path, sops_config: &Path) -> Self {
let sopsy = repo.join(CONFIG_FILE_NAME);
ConfigSnapshot {
files: [
(sopsy.clone(), std::fs::read(&sopsy).ok()),
(sops_config.to_path_buf(), std::fs::read(sops_config).ok()),
],
}
}
pub(crate) fn restore(&self) -> Result<()> {
for (path, contents) in &self.files {
match contents {
Some(bytes) => std::fs::write(path, bytes)?,
None => {
let _ = std::fs::remove_file(path);
}
}
}
Ok(())
}
}
pub(crate) fn rewrap_error(source: Error) -> Error {
Error::Validation(format!(
"could not re-encrypt secrets for the updated recipient set, so the change \
was rolled back. Updating recipients requires decrypting the existing \
secrets — make your age key available (unlock your Secure Enclave \
identity, or set SOPS_AGE_KEY_FILE to a key that is already a recipient), \
or pass --no-updatekeys to update configuration only. Underlying error: {source}"
))
}
fn list(ui: &Ui) -> Result<()> {
ui.header("sopsy recipient list");
let repo = current_repo_root()?;
let config = match Config::load_from_dir(&repo) {
Ok(config) => config,
Err(Error::FileNotFound(_)) => {
ui.info(format!(
"no {CONFIG_FILE_NAME} found — run `sopsy init` to get started"
));
return Ok(());
}
Err(other) => return Err(other),
};
if config.recipients.is_empty() {
ui.info("no recipients are configured yet");
return Ok(());
}
let name_header = "NAME";
let key_header = "PUBLIC KEY";
let flag_header = "BREAK-GLASS";
let name_w = config
.recipients
.iter()
.map(|r| r.name.chars().count())
.chain(std::iter::once(name_header.len()))
.max()
.unwrap_or(name_header.len());
let truncated: Vec<String> = config
.recipients
.iter()
.map(|r| truncate_key(&r.public_key))
.collect();
let key_w = truncated
.iter()
.map(|k| k.chars().count())
.chain(std::iter::once(key_header.len()))
.max()
.unwrap_or(key_header.len());
let header = format!("{name_header:<name_w$} {key_header:<key_w$} {flag_header}");
if ui.color_enabled() {
println!("{}", header.bold().cyan());
} else {
println!("{header}");
}
for (recipient, key) in config.recipients.iter().zip(truncated.iter()) {
let marker = if recipient.break_glass { "★ yes" } else { "" };
let name_cell = format!("{:<name_w$}", recipient.name);
let key_cell = format!("{key:<key_w$}");
if ui.color_enabled() {
println!(
"{} {} {}",
name_cell.green().bold(),
key_cell.dimmed(),
marker.yellow().bold()
);
} else {
println!("{name_cell} {key_cell} {marker}");
}
}
Ok(())
}
fn keygen(ui: &Ui, args: &RecipientKeygenArgs) -> Result<()> {
ui.header("sopsy recipient keygen");
enclave::ensure_available()?;
let spinner = ui.spinner("Generating Secure Enclave identity (Touch ID may prompt)…");
let identity = enclave::generate_identity_with_args(&args.age_args);
spinner.finish_and_clear();
let identity = identity?;
ui.success("Generated a Secure Enclave-backed identity.");
ui.info("The private key stays in the Secure Enclave and never leaves this device.");
ui.header("Public key (share this; register with `sopsy recipient add`)");
ui.animated_line(&identity.public_key);
ui.header("Identity reference (store this where you keep your age identities)");
println!("{}", identity.identity);
Ok(())
}
fn break_glass(ui: &Ui, args: &RecipientBreakGlassArgs) -> Result<()> {
ui.header("sopsy recipient break-glass");
let repo = current_repo_root()?;
let mut config = load_config(&repo)?;
let sops_config = sops_config_path(&repo)?;
let name = args
.name
.as_deref()
.unwrap_or("break-glass")
.trim()
.to_string();
if name.is_empty() {
return Err(Error::Validation("recipient name must not be empty".into()));
}
if config.recipient(&name).is_some() {
return Err(Error::Validation(format!(
"a recipient named `{name}` already exists"
)));
}
let assume_yes = assume_yes();
if !ui.is_interactive() && !assume_yes {
return Err(Error::NonInteractive {
prompt: "press ENTER to confirm the break-glass key is stored safely".to_string(),
flag: format!("an interactive terminal (or set {ASSUME_YES_ENV} for automation)"),
});
}
let private_path = with_suffix(&args.output, "private");
let public_path = with_suffix(&args.output, "public");
for path in [&private_path, &public_path] {
if path.exists() && !args.force {
return Err(Error::Validation(format!(
"{} already exists (pass --force to overwrite)",
path.display()
)));
}
}
age::ensure_available()?;
let spinner = ui.spinner("Generating a portable age key pair for break-glass…");
let keypair = age::generate_keypair();
spinner.finish_and_clear();
let keypair = keypair?;
std::fs::write(&private_path, &keypair.identity)?;
std::fs::write(&public_path, format!("{}\n", keypair.public_key))?;
restrict_permissions(&private_path);
ui.success(format!("wrote private key to {}", private_path.display()));
ui.success(format!("wrote public key to {}", public_path.display()));
ui.header("ACTION REQUIRED — store the break-glass key offline");
ui.warn(
"Please copy these files and place them in 1Password (or another secure, offline store):",
);
ui.info(format!(" • {}", private_path.display()));
ui.info(format!(" • {}", public_path.display()));
ui.warn("Both files will be DELETED from this machine as soon as you continue.");
if assume_yes {
ui.info(format!(
"{ASSUME_YES_ENV} set — assuming the keys are stored; continuing."
));
} else {
ui.press_enter(
"Please press ENTER when you copied the keys to a secure storage (eg 1Password):",
)?;
}
std::fs::remove_file(&private_path)?;
std::fs::remove_file(&public_path)?;
ui.success("removed the local key files");
let snapshot = ConfigSnapshot::capture(&repo, &sops_config);
config.recipients.push(Recipient {
break_glass: true,
..Recipient::new(&name, &keypair.public_key)
});
config.save_to_dir(&repo)?;
ui.success(format!(
"recorded `{name}` (break-glass) in {CONFIG_FILE_NAME}"
));
let modified = add_key_to_sops_yaml(&sops_config, &keypair.public_key)?;
if modified == 0 {
ui.warn(format!(
"no `age:` creation_rules matched in {SOPS_CONFIG_FILE_NAME}; left unchanged"
));
} else {
ui.success(format!(
"added the key to {modified} creation rule(s) in {SOPS_CONFIG_FILE_NAME}"
));
}
if let Err(err) = run_updatekeys(ui, &repo, args.no_updatekeys) {
snapshot.restore()?;
ui.warn("rolled back configuration changes — break-glass recipient was not added");
return Err(rewrap_error(err));
}
ui.success(format!("break-glass recipient `{name}` added"));
Ok(())
}
fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(".");
name.push(suffix);
PathBuf::from(name)
}
fn restrict_permissions(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = std::fs::metadata(path) {
let mut perms = metadata.permissions();
perms.set_mode(0o600);
let _ = std::fs::set_permissions(path, perms);
}
}
#[cfg(not(unix))]
let _ = path;
}
pub(crate) fn run_updatekeys(ui: &Ui, repo: &Path, skip: bool) -> Result<()> {
if skip {
ui.info("skipping re-encryption (`--no-updatekeys`)");
return Ok(());
}
let globs = load_config(repo)
.map(|c| c.encrypted_globs)
.unwrap_or_else(|_| Config::default().encrypted_globs);
let files = collect_encrypted_files(repo, &globs)?;
if files.is_empty() {
ui.info("no encrypted files found to re-encrypt");
return Ok(());
}
ui.info("re-encrypting secrets for the updated recipient set (Touch ID may prompt)…");
for file in &files {
updatekeys_file(file)?;
}
ui.success(format!(
"re-encrypted {} file(s) for the updated recipient set",
files.len()
));
Ok(())
}
fn sops_command() -> Command {
let bin =
std::env::var_os(sops::SOPS_BIN_ENV).unwrap_or_else(|| OsString::from(sops::SOPS_BIN));
let mut command = Command::new(bin);
crate::keystore::configure_sops_env(&mut command);
command
}
fn updatekeys_file(file: &Path) -> Result<()> {
let ty = sops::FileType::from_path(file).as_sops_type();
let output = sops_command()
.arg("updatekeys")
.arg("-y")
.args(["--input-type", ty])
.arg(file)
.output()?;
if output.status.success() {
return Ok(());
}
Err(Error::ProcessFailed {
tool: sops::SOPS_BIN.to_string(),
code: output.status.code().unwrap_or(-1),
message: String::from_utf8_lossy(&output.stderr).trim().to_string(),
})
}
fn collect_encrypted_files(repo: &Path, globs: &[String]) -> Result<Vec<PathBuf>> {
let mut found = Vec::new();
for pattern in globs {
for path in expand_glob(repo, pattern) {
if path.is_file() && is_encrypted_file(&path) {
found.push(path);
}
}
}
found.sort();
found.dedup();
Ok(found)
}
fn expand_glob(repo: &Path, pattern: &str) -> Vec<PathBuf> {
let mut current = vec![repo.to_path_buf()];
for segment in pattern.split('/').filter(|s| !s.is_empty()) {
let mut next = Vec::new();
if segment.contains(['*', '?']) {
for dir in ¤t {
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
for entry in entries.flatten() {
let name = entry.file_name();
if glob_match(segment, &name.to_string_lossy()) {
next.push(dir.join(&name));
}
}
}
} else {
for dir in ¤t {
let candidate = dir.join(segment);
if candidate.exists() {
next.push(candidate);
}
}
}
current = next;
}
current
}
fn glob_match(pattern: &str, text: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let t: Vec<char> = text.chars().collect();
let (mut pi, mut ti) = (0usize, 0usize);
let (mut star, mut resume) = (None, 0usize);
while ti < t.len() {
if pi < p.len() && (p[pi] == '?' && t[ti] != '/' || p[pi] == t[ti]) {
pi += 1;
ti += 1;
} else if pi < p.len() && p[pi] == '*' {
star = Some(pi);
resume = ti;
pi += 1;
} else if let Some(s) = star {
if t[resume] == '/' {
return false;
}
pi = s + 1;
resume += 1;
ti = resume;
} else {
return false;
}
}
while pi < p.len() && p[pi] == '*' {
pi += 1;
}
pi == p.len()
}
fn is_encrypted_file(path: &Path) -> bool {
if path.file_name().is_some_and(|n| n == ".sops.yaml") {
return false;
}
match std::fs::read_to_string(path) {
Ok(contents) => contents.contains("ENC["),
Err(_) => false,
}
}
fn truncate_key(key: &str) -> String {
const MAX: usize = 24;
if key.chars().count() > MAX {
let head: String = key.chars().take(MAX).collect();
format!("{head}…")
} else {
key.to_string()
}
}
pub(crate) fn add_key_to_sops_yaml(path: &Path, key: &str) -> Result<usize> {
mutate_sops_yaml(path, |keys| {
if keys.iter().any(|k| k == key) {
false
} else {
keys.push(key.to_string());
true
}
})
}
fn remove_key_from_sops_yaml(path: &Path, key: &str) -> Result<usize> {
mutate_sops_yaml(path, |keys| {
let before = keys.len();
keys.retain(|k| k != key);
keys.len() != before
})
}
fn mutate_sops_yaml<F>(path: &Path, mut edit: F) -> Result<usize>
where
F: FnMut(&mut Vec<String>) -> bool,
{
let raw = std::fs::read_to_string(path)?;
let mut doc: Value = serde_yaml_ng::from_str(&raw).map_err(|source| Error::Parse {
path: path.to_path_buf(),
source,
})?;
let mut modified = 0usize;
if let Some(rules) = doc
.get_mut("creation_rules")
.and_then(Value::as_sequence_mut)
{
for rule in rules.iter_mut() {
let Some(map) = rule.as_mapping_mut() else {
continue;
};
let Some(age_val) = map.get_mut("age") else {
continue;
};
let mut keys = parse_age_keys(age_val);
if edit(&mut keys) {
modified += 1;
}
*age_val = age_keys_to_value(&keys);
}
}
let serialized = serde_yaml_ng::to_string(&doc)?;
std::fs::write(path, serialized)?;
Ok(modified)
}
fn parse_age_keys(value: &Value) -> Vec<String> {
match value {
Value::Sequence(seq) => seq
.iter()
.filter_map(Value::as_str)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
Value::String(s) => s
.split([',', '\n', ' ', '\t'])
.map(str::trim)
.filter(|p| !p.is_empty())
.map(str::to_string)
.collect(),
_ => Vec::new(),
}
}
fn age_keys_to_value(keys: &[String]) -> Value {
Value::String(keys.join(","))
}
#[cfg(test)]
mod tests {
use super::*;
use assert_fs::TempDir;
#[test]
fn with_suffix_appends_dotted_extension() {
assert_eq!(
with_suffix(Path::new("key"), "private"),
PathBuf::from("key.private")
);
assert_eq!(
with_suffix(Path::new("/tmp/bg"), "public"),
PathBuf::from("/tmp/bg.public")
);
assert_eq!(
with_suffix(Path::new("bg.key"), "private"),
PathBuf::from("bg.key.private")
);
}
#[test]
fn truncate_key_shortens_only_long_keys() {
assert_eq!(truncate_key("age1short"), "age1short");
let long = "age1".to_string() + &"x".repeat(60);
let out = truncate_key(&long);
assert!(out.ends_with('…'));
assert_eq!(out.chars().count(), 25); }
#[test]
fn parse_age_keys_handles_each_yaml_shape() {
let seq: Value = serde_yaml_ng::from_str("- age1a\n- age1b\n").unwrap();
assert_eq!(parse_age_keys(&seq), vec!["age1a", "age1b"]);
let s: Value = serde_yaml_ng::from_str("\"age1a, age1b\\nage1c\"").unwrap();
assert_eq!(parse_age_keys(&s), vec!["age1a", "age1b", "age1c"]);
let other: Value = serde_yaml_ng::from_str("{a: b}").unwrap();
assert!(parse_age_keys(&other).is_empty());
}
#[test]
fn age_keys_to_value_round_trips() {
let keys = vec!["age1a".to_string(), "age1b".to_string()];
let value = age_keys_to_value(&keys);
assert_eq!(parse_age_keys(&value), keys);
}
fn sops_yaml(body: &str) -> (TempDir, PathBuf) {
let dir = TempDir::new().unwrap();
let path = dir.path().join(".sops.yaml");
std::fs::write(&path, body).unwrap();
(dir, path)
}
#[test]
fn add_key_preserves_unrelated_yaml_and_dedupes() {
let (_dir, path) = sops_yaml(
"creation_rules:\n - path_regex: \\.enc$\n age:\n - age1a\nother: keep-me\n",
);
assert_eq!(add_key_to_sops_yaml(&path, "age1b").unwrap(), 1);
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("age1a") && raw.contains("age1b"));
assert!(raw.contains("other: keep-me"), "unrelated keys preserved");
assert!(raw.contains("path_regex"), "rule metadata preserved");
assert_eq!(add_key_to_sops_yaml(&path, "age1b").unwrap(), 0);
}
#[test]
fn remove_key_drops_only_the_named_key() {
let (_dir, path) = sops_yaml(
"creation_rules:\n - path_regex: \\.enc$\n age:\n - age1a\n - age1b\n",
);
assert_eq!(remove_key_from_sops_yaml(&path, "age1a").unwrap(), 1);
let raw = std::fs::read_to_string(&path).unwrap();
assert!(!raw.contains("age1a") && raw.contains("age1b"));
assert_eq!(remove_key_from_sops_yaml(&path, "age1zzz").unwrap(), 0);
}
#[test]
fn mutate_skips_non_mapping_and_age_less_rules() {
let (_dir, path) = sops_yaml(
"creation_rules:\n - just-a-scalar\n - path_regex: \\.no-age$\n \
- path_regex: \\.enc$\n age: \"age1a, age1b\"\n",
);
assert_eq!(add_key_to_sops_yaml(&path, "age1c").unwrap(), 1);
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("age1a") && raw.contains("age1b") && raw.contains("age1c"));
}
#[test]
fn mutate_reports_parse_errors() {
let (_dir, path) = sops_yaml("creation_rules: [unterminated\n");
let err = add_key_to_sops_yaml(&path, "age1a").unwrap_err();
assert!(matches!(err, Error::Parse { .. }));
}
#[test]
fn is_encrypted_file_detects_marker_and_skips_sops_yaml() {
let dir = TempDir::new().unwrap();
let enc = dir.path().join("secret.encrypted");
std::fs::write(&enc, "FOO=ENC[data]\n").unwrap();
assert!(is_encrypted_file(&enc));
let plain = dir.path().join("plain.txt");
std::fs::write(&plain, "FOO=bar\n").unwrap();
assert!(!is_encrypted_file(&plain));
let sops = dir.path().join(".sops.yaml");
std::fs::write(&sops, "ENC[x]\n").unwrap();
assert!(!is_encrypted_file(&sops));
assert!(!is_encrypted_file(&dir.path().join("does-not-exist")));
}
#[test]
fn glob_match_handles_stars_and_slashes() {
assert!(glob_match("*.encrypted", "deep.encrypted"));
assert!(glob_match(".env.encrypted", ".env.encrypted"));
assert!(glob_match(
"config/*.encrypted.yaml",
"config/db.encrypted.yaml"
));
assert!(!glob_match("*.encrypted", "nested/deep.encrypted"));
assert!(!glob_match("*.encrypted", "deep.txt"));
assert!(!glob_match("config/*.yaml", "config/sub/db.yaml"));
}
#[test]
fn expand_glob_is_bounded_and_segment_wise() {
let dir = TempDir::new().unwrap();
let repo = dir.path();
std::fs::write(repo.join(".env.encrypted"), "x").unwrap();
std::fs::create_dir(repo.join("config")).unwrap();
std::fs::write(repo.join("config/db.encrypted.yaml"), "x").unwrap();
std::fs::create_dir(repo.join("nested")).unwrap();
std::fs::write(repo.join("nested/deep.encrypted"), "x").unwrap();
let mut top = expand_glob(repo, "*.encrypted");
top.sort();
assert_eq!(top, vec![repo.join(".env.encrypted")]);
assert_eq!(
expand_glob(repo, "config/*.encrypted.yaml"),
vec![repo.join("config/db.encrypted.yaml")]
);
assert_eq!(
expand_glob(repo, ".env.encrypted"),
vec![repo.join(".env.encrypted")]
);
assert!(expand_glob(repo, "missing.encrypted").is_empty());
}
#[test]
fn collect_encrypted_files_finds_artifacts_regardless_of_git_status() {
let dir = TempDir::new().unwrap();
let repo = dir.path();
std::fs::write(repo.join(".env.encrypted"), "A=ENC[x]\n").unwrap();
std::fs::write(repo.join(".env.example.encrypted"), "B=ENC[y]\n").unwrap();
std::fs::write(repo.join("plain.env"), "A=b\n").unwrap();
std::fs::write(repo.join(".sops.yaml"), "ENC[ignored]\n").unwrap();
std::fs::create_dir(repo.join("config")).unwrap();
std::fs::write(repo.join("config/db.encrypted.yaml"), "k: ENC[z]\n").unwrap();
std::fs::write(repo.join("decoy.encrypted"), "not really encrypted\n").unwrap();
let globs = vec![
"*.encrypted".to_string(),
"config/*.encrypted.yaml".to_string(),
];
let found = collect_encrypted_files(repo, &globs).unwrap();
assert_eq!(
found,
vec![
repo.join(".env.encrypted"),
repo.join(".env.example.encrypted"),
repo.join("config/db.encrypted.yaml"),
],
"all ENC artifacts matching the globs; plain/.sops.yaml/decoy excluded"
);
}
}