use std::ffi::OsString;
use std::path::Path;
use std::process::Command;
use crate::cli::InitArgs;
use crate::config::{Config, Recipient};
use crate::error::{Error, Result};
use crate::sops::{self, FileType};
use crate::ui::Ui;
use crate::{enclave, git};
const DEFAULT_RECIPIENT_NAME: &str = "primary";
const ENV_EXAMPLE_TEMPLATE: &str = "\
# Example environment variables for this project.
# Copy this file to `.env`, fill in real values, then encrypt with sopsy.
# `.env` itself is gitignored and must never be committed in plaintext.
DATABASE_URL=postgres://localhost:5432/myapp
API_KEY=replace-me
";
pub fn run(ui: &Ui, args: &InitArgs) -> Result<()> {
ui.header("sopsy init — bootstrapping your encrypted repository");
let cwd = std::env::current_dir()?;
let root = git::repo_root(&cwd).map_err(|_| {
Error::Validation(
"sopsy init must run inside a git repository (run `git init` first)".to_string(),
)
})?;
ui.success(format!("Git repository: {}", root.display()));
sops::ensure_available()?;
ui.success("Found `sops`.");
let recipient = acquire_recipient(ui, args)?;
ui.header("Your repository recipient");
ui.info(format!("name: {}", recipient.name));
ui.animated_line(&recipient.public_key);
let sops_yaml = root.join(".sops.yaml");
if sops_yaml.exists() && !args.force {
ui.warn(".sops.yaml already exists; leaving it untouched (pass --force to overwrite).");
} else {
std::fs::write(&sops_yaml, render_sops_yaml(&recipient.public_key))?;
ui.success("Wrote .sops.yaml creation rules.");
}
let env_example = root.join(".env.example");
if env_example.exists() {
ui.info(".env.example already present; keeping it.");
} else {
std::fs::write(&env_example, ENV_EXAMPLE_TEMPLATE)?;
ui.success("Created .env.example.");
}
let env_encrypted = root.join(".env.encrypted");
if env_encrypted.exists() && !args.force {
ui.info(".env.encrypted already present; leaving it untouched (pass --force to recreate).");
} else {
let seed = read_seed(&root)?;
std::fs::write(&env_encrypted, seed)?;
let spinner = ui.spinner("Encrypting .env.encrypted with sops…");
let result = sops::encrypt_in_place(&env_encrypted, FileType::Dotenv);
spinner.finish_and_clear();
result?;
ui.success("Encrypted .env.encrypted.");
}
let mut gitignore_changed = false;
for pattern in [
".env",
".env.*",
"!.env.example",
"!.env.encrypted",
"*.key",
"*.pem",
] {
gitignore_changed |= git::ensure_gitignored(&root, pattern)?;
}
if gitignore_changed {
ui.success("Updated .gitignore to keep plaintext secrets out of git.");
} else {
ui.info(".gitignore already protects plaintext secrets.");
}
let config = Config {
recipients: vec![recipient.clone()],
sops_version: detect_sops_version(),
..Config::default()
};
let config_path = config.save_to_dir(&root)?;
ui.success(format!("Wrote {}.", config_path.display()));
print_summary(ui, &recipient);
Ok(())
}
fn acquire_recipient(ui: &Ui, args: &InitArgs) -> Result<Recipient> {
let name = args
.recipient_name
.clone()
.unwrap_or_else(|| DEFAULT_RECIPIENT_NAME.to_string());
if let Some(public_key) = args.public_key.as_deref() {
ui.success(format!("Using supplied age public key for `{name}`."));
return Ok(Recipient::new(name, public_key));
}
if args.no_generate {
return Err(Error::Validation(
"no recipient key available: pass --public-key <age1...>, \
or drop --no-generate to create a Secure Enclave identity"
.to_string(),
));
}
if ui.is_interactive() {
let generate = ui.confirm(
"Generate a new Secure Enclave-backed identity? (No = paste an existing public key)",
"--public-key",
true,
)?;
if !generate {
let public_key = ui.text("Paste your age public key (age1...)", "--public-key")?;
return Ok(Recipient::new(name, public_key));
}
}
enclave::ensure_available()?;
let spinner = ui.spinner("Generating Secure Enclave identity (Touch ID may prompt)…");
let identity = enclave::generate_identity(None);
spinner.finish_and_clear();
let identity = identity?;
ui.success("Created a Secure Enclave-backed identity.");
ui.info("The private key stays in the Secure Enclave and never leaves this device.");
Ok(Recipient::new(name, identity.public_key))
}
fn render_sops_yaml(age_recipients: &str) -> String {
format!(
"# Managed by sopsy. Maps encrypted files to their age recipients.\n\
creation_rules:\n\
\x20\x20- path_regex: '\\.env\\.encrypted$'\n\
\x20\x20\x20\x20age: '{age_recipients}'\n\
\x20\x20- path_regex: '\\.encrypted$'\n\
\x20\x20\x20\x20age: '{age_recipients}'\n"
)
}
fn read_seed(root: &Path) -> Result<String> {
let dotenv = root.join(".env");
if dotenv.exists() {
return Ok(std::fs::read_to_string(dotenv)?);
}
let example = root.join(".env.example");
if example.exists() {
return Ok(std::fs::read_to_string(example)?);
}
Ok(ENV_EXAMPLE_TEMPLATE.to_string())
}
fn detect_sops_version() -> Option<String> {
let bin =
std::env::var_os(sops::SOPS_BIN_ENV).unwrap_or_else(|| OsString::from(sops::SOPS_BIN));
let output = Command::new(bin).arg("--version").output().ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
text.split_whitespace().nth(1).map(str::to_string)
}
fn print_summary(ui: &Ui, recipient: &Recipient) {
ui.header("All set — your repository is ready");
ui.success("sops configured (.sops.yaml)");
ui.success("plaintext .env ignored by git");
ui.success("secrets encrypted (.env.encrypted)");
ui.success(format!(
"recipient `{}` recorded in .sopsy.yml",
recipient.name
));
println!();
ui.warn("> [!IMPORTANT] Break-glass: create a separate emergency age key pair and");
ui.warn("> store it offline (e.g. in 1Password), shared with only a few admins, then");
ui.warn("> register it via `sopsy recipient add break-glass --break-glass`. Without it,");
ui.warn("> losing your Secure Enclave device means losing access to every secret.");
ui.animated_line("Happy encrypting!");
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
fn render_sops_yaml_embeds_recipients() {
let yaml = render_sops_yaml("age1aaa,age1bbb");
assert!(yaml.contains("creation_rules:"));
assert!(yaml.contains("age1aaa,age1bbb"));
assert!(yaml.contains(r"\.env\.encrypted$"));
}
#[test]
fn read_seed_prefers_dotenv_then_example_then_template() {
let dir = assert_fs::TempDir::new().unwrap();
let root = dir.path();
assert_eq!(read_seed(root).unwrap(), ENV_EXAMPLE_TEMPLATE);
std::fs::write(root.join(".env.example"), "EXAMPLE=1\n").unwrap();
assert_eq!(read_seed(root).unwrap(), "EXAMPLE=1\n");
std::fs::write(root.join(".env"), "REAL=2\n").unwrap();
assert_eq!(read_seed(root).unwrap(), "REAL=2\n");
}
fn write_fake_sops(dir: &Path, body: &str) -> std::path::PathBuf {
let script = dir.join("fake-sops");
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&script).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script, perms).unwrap();
}
script
}
#[test]
#[serial]
fn detect_sops_version_parses_real_output() {
let dir = assert_fs::TempDir::new().unwrap();
let fake = write_fake_sops(dir.path(), "echo 'sops 3.13.1 (latest)'\n");
unsafe {
std::env::set_var(sops::SOPS_BIN_ENV, &fake);
}
assert_eq!(detect_sops_version().as_deref(), Some("3.13.1"));
unsafe {
std::env::remove_var(sops::SOPS_BIN_ENV);
}
}
#[test]
#[serial]
fn detect_sops_version_handles_failures() {
let dir = assert_fs::TempDir::new().unwrap();
let failing = write_fake_sops(dir.path(), "exit 1\n");
unsafe {
std::env::set_var(sops::SOPS_BIN_ENV, &failing);
}
assert!(detect_sops_version().is_none());
let blank = write_fake_sops(dir.path(), "echo ''\n");
unsafe {
std::env::set_var(sops::SOPS_BIN_ENV, &blank);
}
assert!(detect_sops_version().is_none());
unsafe {
std::env::set_var(sops::SOPS_BIN_ENV, "/nonexistent/sops-xyz");
}
assert!(detect_sops_version().is_none());
unsafe {
std::env::remove_var(sops::SOPS_BIN_ENV);
}
}
}