use std::ffi::OsString;
use std::path::Path;
use std::process::Command;
use crate::error::{Error, Result};
pub const SOPS_BIN: &str = "sops";
pub const SOPS_BIN_ENV: &str = "SOPSY_SOPS_BIN";
fn sops_bin() -> OsString {
std::env::var_os(SOPS_BIN_ENV).unwrap_or_else(|| OsString::from(SOPS_BIN))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum FileType {
Dotenv,
Yaml,
Json,
Ini,
Binary,
}
impl FileType {
pub fn as_sops_type(self) -> &'static str {
match self {
FileType::Dotenv => "dotenv",
FileType::Yaml => "yaml",
FileType::Json => "json",
FileType::Ini => "ini",
FileType::Binary => "binary",
}
}
pub fn extension_hint(self) -> &'static str {
match self {
FileType::Dotenv => ".env, .env.*, *.env",
FileType::Yaml => ".yaml, .yml",
FileType::Json => ".json",
FileType::Ini => ".ini",
FileType::Binary => "anything else (whole-file)",
}
}
pub fn all() -> [FileType; 5] {
[
FileType::Dotenv,
FileType::Yaml,
FileType::Json,
FileType::Ini,
FileType::Binary,
]
}
pub fn from_path(path: &Path) -> Self {
let original = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let name = if original.to_ascii_lowercase().ends_with(".encrypted") {
original[..original.len() - ".encrypted".len()].to_string()
} else {
original
};
let lower = name.to_ascii_lowercase();
if lower == ".env" || lower.starts_with(".env.") || lower.ends_with(".env") {
return FileType::Dotenv;
}
match Path::new(&name)
.extension()
.map(|e| e.to_string_lossy().to_ascii_lowercase())
.as_deref()
{
Some("yaml") | Some("yml") => FileType::Yaml,
Some("json") => FileType::Json,
Some("ini") => FileType::Ini,
_ => FileType::Binary,
}
}
}
fn check_status(output: &std::process::Output) -> Result<()> {
if output.status.success() {
return Ok(());
}
Err(Error::ProcessFailed {
tool: SOPS_BIN.to_string(),
code: output.status.code().unwrap_or(-1),
message: String::from_utf8_lossy(&output.stderr).trim().to_string(),
})
}
pub fn ensure_available() -> Result<()> {
let bin = sops_bin();
if which::which(&bin).is_ok() {
return Ok(());
}
Err(Error::ToolNotFound(format!(
"{} (install it with `brew install sops`)",
bin.to_string_lossy()
)))
}
pub fn edit(file: &Path, editor: Option<&str>, sops_args: &[String]) -> Result<()> {
let mut command = Command::new(sops_bin());
crate::keystore::configure_sops_env(&mut command);
if let Some(editor) = editor {
command.env("EDITOR", editor);
}
command.args(sops_args);
command.arg(file);
let status = command.status()?;
if status.success() {
return Ok(());
}
Err(Error::ProcessFailed {
tool: SOPS_BIN.to_string(),
code: status.code().unwrap_or(-1),
message: format!(
"sops exited unsuccessfully while editing {}",
file.display()
),
})
}
pub fn encrypt_in_place(file: &Path, file_type: FileType) -> Result<()> {
let ty = file_type.as_sops_type();
let mut command = Command::new(sops_bin());
crate::keystore::configure_sops_env(&mut command);
let output = command
.args(["--encrypt", "--input-type", ty, "--output-type", ty])
.arg("--in-place")
.arg(file)
.output()?;
check_status(&output)
}
pub fn encrypt_to_string(
file: &Path,
file_type: FileType,
filename_override: &Path,
) -> Result<String> {
let ty = file_type.as_sops_type();
let mut command = Command::new(sops_bin());
crate::keystore::configure_sops_env(&mut command);
let output = command
.args(["--encrypt", "--input-type", ty, "--output-type", ty])
.arg("--filename-override")
.arg(filename_override)
.arg(file)
.output()?;
check_status(&output)?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub fn decrypt(file: &Path, file_type: FileType) -> Result<String> {
let ty = file_type.as_sops_type();
let mut command = Command::new(sops_bin());
crate::keystore::configure_sops_env(&mut command);
let output = command
.args(["--decrypt", "--input-type", ty, "--output-type", ty])
.arg(file)
.output()?;
check_status(&output)?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn from_path_detects_dotenv() {
for name in [".env", ".env.production", ".env.local", "service.env"] {
assert_eq!(
FileType::from_path(&PathBuf::from(name)),
FileType::Dotenv,
"{name} should be dotenv"
);
}
}
#[test]
fn from_path_detects_structured_formats() {
assert_eq!(
FileType::from_path(&PathBuf::from("config.yaml")),
FileType::Yaml
);
assert_eq!(
FileType::from_path(&PathBuf::from("config.yml")),
FileType::Yaml
);
assert_eq!(
FileType::from_path(&PathBuf::from("config.json")),
FileType::Json
);
assert_eq!(
FileType::from_path(&PathBuf::from("app.ini")),
FileType::Ini
);
}
#[test]
fn from_path_strips_encrypted_suffix_for_inner_type() {
assert_eq!(
FileType::from_path(&PathBuf::from(".env.encrypted")),
FileType::Dotenv
);
assert_eq!(
FileType::from_path(&PathBuf::from("config.json.encrypted")),
FileType::Json
);
assert_eq!(
FileType::from_path(&PathBuf::from("helm.yml.ENCRYPTED")),
FileType::Yaml
);
assert_eq!(
FileType::from_path(&PathBuf::from("app.ini.encrypted")),
FileType::Ini
);
assert_eq!(
FileType::from_path(&PathBuf::from("secret.encrypted")),
FileType::Binary
);
}
#[test]
fn sops_type_includes_ini() {
assert_eq!(FileType::Ini.as_sops_type(), "ini");
assert_eq!(FileType::all().len(), 5);
}
#[test]
fn from_path_falls_back_to_binary() {
assert_eq!(
FileType::from_path(&PathBuf::from("secret.pem")),
FileType::Binary
);
assert_eq!(
FileType::from_path(&PathBuf::from("README")),
FileType::Binary
);
}
#[test]
fn sops_type_strings_are_stable() {
assert_eq!(FileType::Dotenv.as_sops_type(), "dotenv");
assert_eq!(FileType::Yaml.as_sops_type(), "yaml");
assert_eq!(FileType::Json.as_sops_type(), "json");
assert_eq!(FileType::Binary.as_sops_type(), "binary");
}
#[test]
fn from_path_is_case_insensitive() {
for (name, expected) in [
(".ENV", FileType::Dotenv),
(".Env.Production", FileType::Dotenv),
("Service.ENV", FileType::Dotenv),
("CONFIG.YAML", FileType::Yaml),
("Config.YML", FileType::Yaml),
("Data.JSON", FileType::Json),
] {
assert_eq!(
FileType::from_path(&PathBuf::from(name)),
expected,
"{name} misclassified"
);
}
}
#[test]
fn from_path_handles_pathless_names() {
assert_eq!(FileType::from_path(&PathBuf::from("")), FileType::Binary);
}
}