use std::ffi::OsString;
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use crate::cli::{InitArgs, RecipientBreakGlassArgs, RecipientCommand};
use crate::commands::recipient::system_username;
use crate::config::{Config, Recipient};
use crate::error::{Error, Result};
use crate::sops::{self, FileType};
use crate::ui::Ui;
use crate::{enclave, git, keystore};
const DEFAULT_RECIPIENT_NAME: &str = "admin";
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(),
)
})?;
guard_repo_root(ui, &cwd, &root, args)?;
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));
if let Some(username) = &recipient.username {
ui.info(format!("owner: {username}"));
}
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 mut gitignore_changed = false;
for pattern in [
".env",
".env.*",
"!.env.example",
"!*.encrypted",
"*.key",
"*.pem",
"*.private",
"*.public",
] {
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 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)?;
let seed_file = tempfile::NamedTempFile::new()?;
std::fs::write(seed_file.path(), &seed)?;
let spinner = ui.spinner("Encrypting .env.encrypted with sops…");
let ciphertext =
sops::encrypt_to_string(seed_file.path(), FileType::Dotenv, &env_encrypted);
spinner.finish_and_clear();
std::fs::write(&env_encrypted, ciphertext?)?;
ui.success("Encrypted .env.encrypted.");
}
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()));
maybe_setup_break_glass(ui, &root, args)?;
print_summary(ui, &recipient);
Ok(())
}
fn maybe_setup_break_glass(ui: &Ui, root: &Path, args: &InitArgs) -> Result<()> {
let want = if args.no_break_glass {
false
} else if args.break_glass {
true
} else if ui.is_interactive() {
ui.confirm(
"Set up a break-glass emergency key now? (strongly recommended)",
"--break-glass",
true,
)?
} else {
false
};
if !want {
ui.warn("No break-glass key yet. Create one ASAP with:");
ui.warn(" sopsy recipient break-glass -o break-glass");
return Ok(());
}
let break_glass_args = RecipientBreakGlassArgs {
output: root.join("break-glass"),
name: None,
force: false,
no_updatekeys: false,
};
crate::commands::recipient::run(ui, &RecipientCommand::BreakGlass(break_glass_args))
}
fn guard_repo_root(ui: &Ui, cwd: &Path, root: &Path, args: &InitArgs) -> Result<()> {
let same = std::fs::canonicalize(cwd).ok() == std::fs::canonicalize(root).ok();
if same {
return Ok(());
}
ui.warn(format!(
"{} is not a git repository; the nearest one is {}.",
cwd.display(),
root.display()
));
if keystore::home_dir().and_then(|h| std::fs::canonicalize(h).ok())
== std::fs::canonicalize(root).ok()
{
ui.warn("That is your HOME directory — sopsy would manage all of it as a secrets repo.");
}
ui.warn("If you meant to start a new repo here, run `git init` in this directory first.");
let proceed = if args.force {
true
} else if ui.is_interactive() {
ui.confirm(
&format!("Initialise sopsy in {} anyway?", root.display()),
"--force",
false,
)?
} else {
false
};
if !proceed {
return Err(Error::Validation(format!(
"aborted: {} is not a git repository — run `git init` here first",
cwd.display()
)));
}
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_with_optional_username(
name,
public_key,
args.username.clone(),
));
}
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_with_optional_username(
name,
public_key,
args.username.clone(),
));
}
}
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.");
let keys_path = keystore::store_identity(&name, &identity.public_key, &identity.identity)?;
ui.success(format!("Stored your identity in {}.", keys_path.display()));
ui.info("It is safe on disk: it only works on this device, behind Touch ID.");
ui.header("Your newly generated public key");
ui.animated_line(&identity.public_key);
ui.pause(Duration::from_secs(2));
let username = resolve_username(ui, args)?;
Ok(make_recipient(name, identity.public_key, username))
}
fn make_recipient(name: String, public_key: String, username: Option<String>) -> Recipient {
match username {
Some(username) => Recipient::with_username(name, public_key, username),
None => Recipient::new(name, public_key),
}
}
fn recipient_with_optional_username(
name: String,
public_key: impl Into<String>,
username: Option<String>,
) -> Recipient {
let username = username.and_then(|u| {
let u = u.trim().to_string();
(!u.is_empty()).then_some(u)
});
make_recipient(name, public_key.into(), username)
}
fn resolve_username(ui: &Ui, args: &InitArgs) -> Result<Option<String>> {
let default = args
.username
.clone()
.map(|u| u.trim().to_string())
.filter(|u| !u.is_empty())
.or_else(system_username);
if ui.is_interactive() {
let default_str = default.clone().unwrap_or_default();
let entered = ui.text_with_default(
"Your name (recorded as this key's owner):",
"--username",
&default_str,
)?;
let entered = entered.trim().to_string();
Ok((!entered.is_empty()).then_some(entered))
} else {
Ok(default)
}
}
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);
}
}
}