use std::collections::HashMap;
use std::fs;
use std::io::{self, Write};
use std::path::Path;
use thiserror::Error;
use super::expand::{EnvContext, expand_path};
use crate::config::SecretValue;
#[derive(Error, Debug)]
pub enum SecretError {
#[error("Failed to read secret from file: {0}")]
FileReadError(#[from] std::io::Error),
#[error("Required secret '{0}' was not provided")]
MissingRequired(String),
#[error("Failed to read user input")]
InputError,
#[error("Secret file not found: {0}")]
FileNotFound(String),
}
#[derive(Debug, Clone)]
pub struct SecretsConfig {
pub ci_mode: bool,
pub fail_on_missing: bool,
}
impl Default for SecretsConfig {
fn default() -> Self {
Self {
ci_mode: std::env::var("CI").is_ok()
|| std::env::var("JARVY_CI").is_ok()
|| std::env::var("JARVY_TEST_MODE").is_ok(),
fail_on_missing: true,
}
}
}
pub fn collect_secrets(
secrets: &HashMap<String, SecretValue>,
ctx: &EnvContext,
config: &SecretsConfig,
) -> Result<HashMap<String, String>, SecretError> {
let mut result = HashMap::new();
for (name, secret_config) in secrets {
match resolve_secret(name, secret_config, ctx, config) {
Ok(Some(value)) => {
result.insert(name.clone(), value);
}
Ok(None) => {
}
Err(e) => {
if config.fail_on_missing {
return Err(e);
}
eprintln!("Warning: Could not resolve secret '{}': {}", name, e);
}
}
}
Ok(result)
}
#[cfg(test)]
#[cfg(unix)]
mod permissive_perms_tests {
use super::*;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
fn make_secret_file(mode: u32) -> tempfile::NamedTempFile {
let mut tmp = tempfile::NamedTempFile::new().expect("tempfile");
write!(tmp, "supersecret").unwrap();
let mut perms = tmp.as_file().metadata().unwrap().permissions();
perms.set_mode(mode);
std::fs::set_permissions(tmp.path(), perms).unwrap();
tmp
}
fn build_ctx() -> EnvContext {
EnvContext::new()
}
#[test]
fn resolve_secret_with_0600_does_not_warn_about_perms() {
let tmp = make_secret_file(0o600);
let secret = SecretValue::FromFile {
from_file: tmp.path().to_string_lossy().to_string(),
};
let ctx = build_ctx();
let result = resolve_secret("TEST_SECRET", &secret, &ctx, &SecretsConfig::default());
assert_eq!(result.unwrap(), Some("supersecret".to_string()));
}
#[test]
fn resolve_secret_with_0644_still_returns_value() {
let tmp = make_secret_file(0o644);
let secret = SecretValue::FromFile {
from_file: tmp.path().to_string_lossy().to_string(),
};
let ctx = build_ctx();
let result = resolve_secret("TEST_SECRET", &secret, &ctx, &SecretsConfig::default());
assert_eq!(result.unwrap(), Some("supersecret".to_string()));
}
}
fn resolve_secret(
name: &str,
config: &SecretValue,
ctx: &EnvContext,
secrets_config: &SecretsConfig,
) -> Result<Option<String>, SecretError> {
match config {
SecretValue::FromFile { from_file } => {
let path = expand_path(from_file, ctx);
if !path.exists() {
return Err(SecretError::FileNotFound(path.display().to_string()));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = fs::metadata(&path) {
let mode = metadata.permissions().mode();
if mode & 0o077 != 0 {
let safe_path = crate::network::redact_home(&path.display().to_string());
tracing::warn!(
event = "secret.permissive_perms",
path = %safe_path,
mode = format!("{:o}", mode & 0o777),
secret_name = %name,
"secret file has permissive permissions; chmod 600 recommended"
);
}
}
}
let content = fs::read_to_string(&path)?;
Ok(Some(content.trim().to_string()))
}
SecretValue::Prompt {
env,
required,
description,
} => {
if let Some(env_var) = env {
if let Ok(value) = std::env::var(env_var) {
if !value.is_empty() {
return Ok(Some(value));
}
}
}
if let Ok(value) = std::env::var(name) {
if !value.is_empty() {
return Ok(Some(value));
}
}
if secrets_config.ci_mode {
if *required {
return Err(SecretError::MissingRequired(name.to_string()));
}
return Ok(None);
}
prompt_secret(name, description.as_deref(), *required)
}
SecretValue::Simple(marker) => {
if let Ok(value) = std::env::var(name) {
if !value.is_empty() {
return Ok(Some(value));
}
}
if marker != name {
if let Ok(value) = std::env::var(marker) {
if !value.is_empty() {
return Ok(Some(value));
}
}
}
if secrets_config.ci_mode {
return Err(SecretError::MissingRequired(name.to_string()));
}
prompt_secret(name, None, true)
}
}
}
fn prompt_secret(
name: &str,
description: Option<&str>,
required: bool,
) -> Result<Option<String>, SecretError> {
if let Some(desc) = description {
println!("{} ({})", name, desc);
}
print!("Enter value for {}: ", name);
io::stdout().flush()?;
let password = rpassword::read_password().map_err(|_| SecretError::InputError)?;
if password.is_empty() {
if required {
Err(SecretError::MissingRequired(name.to_string()))
} else {
Ok(None)
}
} else {
Ok(Some(password))
}
}
#[allow(dead_code)] pub fn load_secret_from_file(path: &Path) -> Result<String, SecretError> {
if !path.exists() {
return Err(SecretError::FileNotFound(path.display().to_string()));
}
let content = fs::read_to_string(path)?;
Ok(content.trim().to_string())
}
#[allow(dead_code)] pub fn preview_secrets(secrets: &HashMap<String, SecretValue>) -> Vec<String> {
secrets.keys().cloned().collect()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_load_secret_from_file() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("secret.txt");
fs::write(&file_path, "my_secret_value\n").unwrap();
let result = load_secret_from_file(&file_path).unwrap();
assert_eq!(result, "my_secret_value");
}
#[test]
fn test_load_secret_from_file_not_found() {
let result = load_secret_from_file(Path::new("/nonexistent/path/secret.txt"));
assert!(matches!(result, Err(SecretError::FileNotFound(_))));
}
#[test]
#[allow(unsafe_code)]
fn test_secrets_config_default_ci_detection() {
let ci_was_set = std::env::var("CI").is_ok();
unsafe {
std::env::set_var("JARVY_TEST_MODE", "1");
}
let config = SecretsConfig::default();
assert!(config.ci_mode);
if !ci_was_set {
unsafe {
std::env::remove_var("CI");
}
}
}
#[test]
fn test_resolve_secret_from_file() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("secret.txt");
fs::write(&file_path, "file_secret_value").unwrap();
let ctx = EnvContext::new();
let config = SecretsConfig {
ci_mode: true,
fail_on_missing: true,
};
let secret_config = SecretValue::FromFile {
from_file: file_path.to_string_lossy().to_string(),
};
let result = resolve_secret("TEST_SECRET", &secret_config, &ctx, &config).unwrap();
assert_eq!(result, Some("file_secret_value".to_string()));
}
#[test]
#[allow(unsafe_code)]
fn test_resolve_secret_from_env() {
unsafe {
std::env::set_var("TEST_SECRET_ENV", "env_value");
}
let ctx = EnvContext::new();
let config = SecretsConfig {
ci_mode: true,
fail_on_missing: true,
};
let secret_config = SecretValue::Prompt {
env: Some("TEST_SECRET_ENV".to_string()),
required: true,
description: None,
};
let result = resolve_secret("MY_SECRET", &secret_config, &ctx, &config).unwrap();
assert_eq!(result, Some("env_value".to_string()));
unsafe {
std::env::remove_var("TEST_SECRET_ENV");
}
}
#[test]
fn test_resolve_secret_ci_mode_required() {
let ctx = EnvContext::new();
let config = SecretsConfig {
ci_mode: true,
fail_on_missing: true,
};
let secret_config = SecretValue::Prompt {
env: None,
required: true,
description: None,
};
let result = resolve_secret("MISSING_SECRET", &secret_config, &ctx, &config);
assert!(matches!(result, Err(SecretError::MissingRequired(_))));
}
#[test]
fn test_resolve_secret_ci_mode_optional() {
let ctx = EnvContext::new();
let config = SecretsConfig {
ci_mode: true,
fail_on_missing: true,
};
let secret_config = SecretValue::Prompt {
env: None,
required: false,
description: None,
};
let result = resolve_secret("OPTIONAL_SECRET", &secret_config, &ctx, &config).unwrap();
assert!(result.is_none());
}
#[test]
fn test_preview_secrets() {
let mut secrets = HashMap::new();
secrets.insert("SECRET_A".to_string(), SecretValue::Simple("a".to_string()));
secrets.insert("SECRET_B".to_string(), SecretValue::Simple("b".to_string()));
let preview = preview_secrets(&secrets);
assert_eq!(preview.len(), 2);
assert!(preview.contains(&"SECRET_A".to_string()));
assert!(preview.contains(&"SECRET_B".to_string()));
}
#[test]
fn test_collect_secrets_from_files() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("secret.txt");
fs::write(&file_path, "collected_secret").unwrap();
let mut secrets = HashMap::new();
secrets.insert(
"FILE_SECRET".to_string(),
SecretValue::FromFile {
from_file: file_path.to_string_lossy().to_string(),
},
);
let ctx = EnvContext::new();
let config = SecretsConfig {
ci_mode: true,
fail_on_missing: true,
};
let result = collect_secrets(&secrets, &ctx, &config).unwrap();
assert_eq!(
result.get("FILE_SECRET"),
Some(&"collected_secret".to_string())
);
}
}