use crate::error::{NonoError, Result};
use std::collections::HashMap;
use std::process::{Command, Stdio};
use std::time::Duration;
use zeroize::Zeroizing;
const OP_TIMEOUT: Duration = Duration::from_secs(30);
pub struct LoadedSecret {
pub env_var: String,
pub value: Zeroizing<String>,
}
pub const DEFAULT_SERVICE: &str = "nono";
const OP_URI_PREFIX: &str = "op://";
const ENV_URI_PREFIX: &str = "env://";
const DANGEROUS_ENV_VAR_NAMES: &[&str] = &[
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"LD_AUDIT",
"DYLD_INSERT_LIBRARIES",
"DYLD_LIBRARY_PATH",
"DYLD_FRAMEWORK_PATH",
"BASH_ENV",
"ENV",
"IFS",
"CDPATH",
"PROMPT_COMMAND",
"NODE_OPTIONS",
"NODE_PATH",
"PYTHONSTARTUP",
"PYTHONPATH",
"PERL5OPT",
"PERL5LIB",
"RUBYOPT",
"RUBYLIB",
"JAVA_TOOL_OPTIONS",
"_JAVA_OPTIONS",
"DOTNET_STARTUP_HOOKS",
"GOFLAGS",
"PATH",
"HOME",
"SHELL",
];
const FORBIDDEN_URI_CHARS: &[char] = &[
';', '|', '&', '$', '`', '(', ')', '{', '}', '<', '>', '!', '\\', '"', '\'', '\n', '\r', '\0',
];
#[must_use = "loaded secrets should be used to set environment variables"]
pub fn load_secrets(
service: &str,
mappings: &HashMap<String, String>,
) -> Result<Vec<LoadedSecret>> {
let mut secrets = Vec::with_capacity(mappings.len());
for (account, env_var) in mappings {
tracing::debug!("Loading secret '{}' -> ${}", account, env_var);
let secret = load_secret_by_ref(service, account)?;
secrets.push(LoadedSecret {
env_var: env_var.clone(),
value: secret,
});
}
Ok(secrets)
}
#[must_use = "loaded secret should be used or explicitly dropped"]
pub fn load_secret_by_ref(service: &str, credential_ref: &str) -> Result<Zeroizing<String>> {
if credential_ref.starts_with(ENV_URI_PREFIX) {
load_from_env(credential_ref)
} else if credential_ref.starts_with(OP_URI_PREFIX) {
load_from_op(credential_ref)
} else {
load_single_secret(service, credential_ref)
}
}
pub fn validate_op_uri(uri: &str) -> Result<()> {
let path = uri.strip_prefix(OP_URI_PREFIX).ok_or_else(|| {
NonoError::ConfigParse(format!(
"credential reference '{}' does not start with '{}'",
uri, OP_URI_PREFIX
))
})?;
if let Some(bad) = path.chars().find(|c| FORBIDDEN_URI_CHARS.contains(c)) {
return Err(NonoError::ConfigParse(format!(
"1Password URI contains forbidden character {:?}: {}",
bad, uri
)));
}
if path.contains('?') || path.contains('#') {
return Err(NonoError::ConfigParse(format!(
"1Password URI must not contain query strings or fragments: {}",
uri
)));
}
let segments: Vec<&str> = path.split('/').collect();
if segments.len() < 3 {
return Err(NonoError::ConfigParse(format!(
"1Password URI must have at least vault/item/field segments: {}",
uri
)));
}
if segments.iter().any(|s| s.is_empty()) {
return Err(NonoError::ConfigParse(format!(
"1Password URI has empty path segment: {}",
uri
)));
}
Ok(())
}
#[must_use]
pub fn is_op_uri(credential_ref: &str) -> bool {
credential_ref.starts_with(OP_URI_PREFIX)
}
#[must_use]
pub fn is_env_uri(credential_ref: &str) -> bool {
credential_ref.starts_with(ENV_URI_PREFIX)
}
pub fn validate_env_uri(uri: &str) -> Result<()> {
let var_name = uri.strip_prefix(ENV_URI_PREFIX).ok_or_else(|| {
NonoError::ConfigParse(format!(
"credential reference '{}' does not start with '{}'",
uri, ENV_URI_PREFIX
))
})?;
if var_name.is_empty() {
return Err(NonoError::ConfigParse(
"env:// URI has empty variable name".to_string(),
));
}
if let Some(bad) = var_name
.chars()
.find(|c| !c.is_ascii_alphanumeric() && *c != '_')
{
return Err(NonoError::ConfigParse(format!(
"env:// variable name contains invalid character {:?}: {}",
bad, uri
)));
}
if DANGEROUS_ENV_VAR_NAMES
.iter()
.any(|&d| d.eq_ignore_ascii_case(var_name))
{
return Err(NonoError::ConfigParse(format!(
"env:// cannot read dangerous environment variable: {}",
var_name
)));
}
Ok(())
}
pub fn validate_destination_env_var(var_name: &str) -> Result<()> {
if var_name.is_empty() {
return Err(NonoError::ConfigParse(
"destination environment variable name cannot be empty".to_string(),
));
}
if let Some(bad) = var_name
.chars()
.find(|c| !c.is_ascii_alphanumeric() && *c != '_')
{
return Err(NonoError::ConfigParse(format!(
"destination environment variable name contains invalid character {:?}: {}",
bad, var_name
)));
}
if DANGEROUS_ENV_VAR_NAMES
.iter()
.any(|&d| d.eq_ignore_ascii_case(var_name))
{
return Err(NonoError::ConfigParse(format!(
"destination environment variable '{}' is on the blocklist of dangerous variables",
var_name
)));
}
Ok(())
}
fn load_from_env(uri: &str) -> Result<Zeroizing<String>> {
validate_env_uri(uri)?;
let var_name = uri
.strip_prefix(ENV_URI_PREFIX)
.ok_or_else(|| NonoError::ConfigParse(format!("invalid env:// URI: {}", uri)))?;
match std::env::var(var_name) {
Ok(value) if value.is_empty() => Err(NonoError::SecretNotFound(format!(
"environment variable '{}' is set but empty",
var_name
))),
Ok(value) => {
tracing::debug!("Loaded secret from environment variable '{}'", var_name);
Ok(Zeroizing::new(value))
}
Err(std::env::VarError::NotPresent) => Err(NonoError::SecretNotFound(format!(
"environment variable '{}' is not set",
var_name
))),
Err(std::env::VarError::NotUnicode(_)) => Err(NonoError::KeystoreAccess(format!(
"environment variable '{}' contains non-UTF-8 data",
var_name
))),
}
}
fn load_single_secret(service: &str, account: &str) -> Result<Zeroizing<String>> {
let entry = keyring::Entry::new(service, account).map_err(|e| {
NonoError::KeystoreAccess(format!(
"Failed to access keystore for '{}': {}",
account, e
))
})?;
match entry.get_password() {
Ok(password) => {
tracing::debug!("Successfully loaded secret '{}'", account);
Ok(Zeroizing::new(password))
}
Err(keyring::Error::NoEntry) => Err(NonoError::SecretNotFound(account.to_string())),
Err(keyring::Error::Ambiguous(creds)) => Err(NonoError::KeystoreAccess(format!(
"Multiple entries ({}) found for '{}' - please resolve manually",
creds.len(),
account
))),
Err(e) => Err(NonoError::KeystoreAccess(format!(
"Cannot access '{}': {}",
account, e
))),
}
}
fn load_from_op(uri: &str) -> Result<Zeroizing<String>> {
validate_op_uri(uri)?;
tracing::debug!("Loading secret from 1Password: {}", redact_op_uri(uri));
let mut child = Command::new("op")
.args(["read", "--", uri])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
NonoError::KeystoreAccess(
"1Password CLI ('op') not found. \
Install it from https://developer.1password.com/docs/cli/"
.to_string(),
)
} else {
NonoError::KeystoreAccess(format!("Could not start the 1Password CLI: {}", e))
}
})?;
let output = wait_with_timeout(&mut child, OP_TIMEOUT).map_err(|e| {
let _ = child.kill();
let _ = child.wait();
e
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(classify_op_error(&stderr, uri));
}
let raw = String::from_utf8(output.stdout).map_err(|_| {
NonoError::KeystoreAccess(format!(
"1Password returned non-UTF-8 data for '{}'",
redact_op_uri(uri)
))
})?;
let trimmed = raw.trim_end_matches(['\n', '\r']).to_string();
Ok(Zeroizing::new(trimmed))
}
fn classify_op_error(stderr: &str, uri: &str) -> NonoError {
let redacted = redact_op_uri(uri);
let stderr_trimmed = stderr.trim();
if stderr.contains("not signed in")
|| stderr.contains("sign in")
|| stderr.contains("authentication required")
|| stderr.contains("session expired")
{
NonoError::KeystoreAccess(format!(
"1Password authentication required for '{}'. \
Run 'op signin' or set OP_SERVICE_ACCOUNT_TOKEN. \
Detail: {}",
redacted, stderr_trimmed
))
} else if stderr.contains("not found")
|| stderr.contains("could not find")
|| stderr.contains("isn't an item")
{
NonoError::SecretNotFound(format!(
"1Password item not found: '{}'. Detail: {}",
redacted, stderr_trimmed
))
} else {
NonoError::KeystoreAccess(format!(
"1Password CLI failed for '{}': {}",
redacted, stderr_trimmed
))
}
}
pub fn redact_op_uri(uri: &str) -> String {
if let Some(path) = uri.strip_prefix(OP_URI_PREFIX) {
let parts: Vec<&str> = path.splitn(3, '/').collect();
if parts.len() >= 3 {
return format!("op://{}/{}/<redacted>", parts[0], parts[1]);
}
}
"op://***".to_string()
}
fn wait_with_timeout(
child: &mut std::process::Child,
timeout: Duration,
) -> Result<std::process::Output> {
let start = std::time::Instant::now();
let poll_interval = Duration::from_millis(100);
loop {
match child.try_wait() {
Ok(Some(status)) => {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
if let Some(mut out) = child.stdout.take() {
std::io::Read::read_to_end(&mut out, &mut stdout).ok();
}
if let Some(mut err) = child.stderr.take() {
std::io::Read::read_to_end(&mut err, &mut stderr).ok();
}
return Ok(std::process::Output {
status,
stdout,
stderr,
});
}
Ok(None) => {
if start.elapsed() >= timeout {
return Err(NonoError::KeystoreAccess(format!(
"1Password CLI timed out after {}s. \
Is 1Password waiting for authentication?",
timeout.as_secs()
)));
}
std::thread::sleep(poll_interval);
}
Err(e) => {
return Err(NonoError::KeystoreAccess(format!(
"Failed to check 1Password CLI status: {}",
e
)));
}
}
}
}
pub fn build_mappings_from_list(accounts: &str) -> Result<HashMap<String, String>> {
let mut mappings = HashMap::new();
for entry in accounts.split(',') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
if entry.starts_with(ENV_URI_PREFIX) {
if let Some(eq_pos) = entry.rfind('=') {
let uri = &entry[..eq_pos];
let var_name = &entry[eq_pos + 1..];
if var_name.is_empty() {
return Err(NonoError::ConfigParse(format!(
"env:// credential '{}' has '=' but no variable name",
uri
)));
}
validate_env_uri(uri)?;
validate_destination_env_var(var_name)?;
mappings.insert(uri.to_string(), var_name.to_string());
} else {
validate_env_uri(entry)?;
let source_var = match entry.strip_prefix(ENV_URI_PREFIX) {
Some(v) => v,
None => {
return Err(NonoError::ConfigParse("invalid env:// URI".to_string()));
}
};
mappings.insert(entry.to_string(), source_var.to_string());
}
} else if entry.starts_with(OP_URI_PREFIX) {
if let Some(eq_pos) = entry.rfind('=') {
let uri = &entry[..eq_pos];
let var_name = &entry[eq_pos + 1..];
if var_name.is_empty() {
return Err(NonoError::ConfigParse(format!(
"1Password credential '{}' has '=' but no variable name. \
Use format: op://vault/item/field=MY_VAR",
redact_op_uri(uri)
)));
}
validate_op_uri(uri)?;
validate_destination_env_var(var_name)?;
mappings.insert(uri.to_string(), var_name.to_string());
} else {
return Err(NonoError::ConfigParse(format!(
"1Password credential requires an explicit variable name. \
Use format: op://vault/item/field=MY_VAR (got '{}')",
redact_op_uri(entry)
)));
}
} else {
let env_var = entry.to_uppercase();
validate_destination_env_var(&env_var)?;
mappings.insert(entry.to_string(), env_var);
}
}
Ok(mappings)
}
pub fn build_secret_mappings(
cli_secrets: Option<&str>,
profile_secrets: &HashMap<String, String>,
) -> Result<HashMap<String, String>> {
let mut combined = profile_secrets.clone();
if let Some(secrets_str) = cli_secrets {
let cli_mappings = build_mappings_from_list(secrets_str)?;
combined.extend(cli_mappings);
}
Ok(combined)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_mappings_from_list() {
let mappings =
build_mappings_from_list("openai_api_key,anthropic_api_key").expect("should parse");
assert_eq!(mappings.len(), 2);
assert_eq!(
mappings.get("openai_api_key"),
Some(&"OPENAI_API_KEY".to_string())
);
assert_eq!(
mappings.get("anthropic_api_key"),
Some(&"ANTHROPIC_API_KEY".to_string())
);
}
#[test]
fn test_build_mappings_handles_whitespace() {
let mappings = build_mappings_from_list(" key1 , key2 , key3 ").expect("should parse");
assert_eq!(mappings.len(), 3);
assert!(mappings.contains_key("key1"));
assert!(mappings.contains_key("key2"));
assert!(mappings.contains_key("key3"));
}
#[test]
fn test_build_mappings_empty() {
let mappings = build_mappings_from_list("").expect("should parse");
assert!(mappings.is_empty());
}
#[test]
fn test_build_mappings_op_uri_with_var_name() {
let mappings =
build_mappings_from_list("op://Development/OpenAI/credential=OPENAI_API_KEY")
.expect("should parse");
assert_eq!(mappings.len(), 1);
assert_eq!(
mappings.get("op://Development/OpenAI/credential"),
Some(&"OPENAI_API_KEY".to_string())
);
}
#[test]
fn test_build_mappings_mixed_keyring_and_op() {
let mappings = build_mappings_from_list("my_api_key,op://vault/item/field=SECRET_VAR")
.expect("should parse");
assert_eq!(mappings.len(), 2);
assert_eq!(mappings.get("my_api_key"), Some(&"MY_API_KEY".to_string()));
assert_eq!(
mappings.get("op://vault/item/field"),
Some(&"SECRET_VAR".to_string())
);
}
#[test]
fn test_build_mappings_op_uri_without_var_rejected() {
let err = build_mappings_from_list("op://vault/item/field")
.expect_err("should reject bare op:// URI");
assert!(
err.to_string().contains("explicit variable name"),
"got: {}",
err
);
}
#[test]
fn test_build_mappings_op_uri_empty_var_rejected() {
let err = build_mappings_from_list("op://vault/item/field=")
.expect_err("should reject empty var name");
assert!(err.to_string().contains("no variable name"), "got: {}", err);
}
#[test]
fn test_build_mappings_op_uri_invalid_uri_rejected() {
let err = build_mappings_from_list("op://vault/item=MY_VAR")
.expect_err("should reject invalid URI");
assert!(
err.to_string().contains("at least vault/item/field"),
"got: {}",
err
);
}
#[test]
fn test_validate_op_uri_valid_3_segments() {
assert!(validate_op_uri("op://vault/item/field").is_ok());
}
#[test]
fn test_validate_op_uri_valid_4_segments() {
assert!(validate_op_uri("op://vault/item/section/field").is_ok());
}
#[test]
fn test_validate_op_uri_valid_with_spaces_and_dashes() {
assert!(validate_op_uri("op://My Vault/My-Item/api-key").is_ok());
}
#[test]
fn test_validate_op_uri_missing_prefix() {
let err = validate_op_uri("vault/item/field").expect_err("should be rejected");
assert!(
err.to_string().contains("does not start with"),
"got: {}",
err
);
}
#[test]
fn test_validate_op_uri_too_few_segments() {
let err = validate_op_uri("op://vault/item").expect_err("should be rejected");
assert!(
err.to_string().contains("at least vault/item/field"),
"got: {}",
err
);
}
#[test]
fn test_validate_op_uri_single_segment() {
let err = validate_op_uri("op://vault").expect_err("should be rejected");
assert!(
err.to_string().contains("at least vault/item/field"),
"got: {}",
err
);
}
#[test]
fn test_validate_op_uri_empty_vault() {
let err = validate_op_uri("op:///item/field").expect_err("should be rejected");
assert!(
err.to_string().contains("empty path segment"),
"got: {}",
err
);
}
#[test]
fn test_validate_op_uri_empty_item() {
let err = validate_op_uri("op://vault//field").expect_err("should be rejected");
assert!(
err.to_string().contains("empty path segment"),
"got: {}",
err
);
}
#[test]
fn test_validate_op_uri_empty_field() {
let err = validate_op_uri("op://vault/item/").expect_err("should be rejected");
assert!(
err.to_string().contains("empty path segment"),
"got: {}",
err
);
}
#[test]
fn test_validate_op_uri_forbidden_semicolon() {
let err = validate_op_uri("op://vault/item;rm -rf/field").expect_err("should be rejected");
assert!(
err.to_string().contains("forbidden character"),
"got: {}",
err
);
}
#[test]
fn test_validate_op_uri_forbidden_pipe() {
let err = validate_op_uri("op://vault/item|evil/field").expect_err("should be rejected");
assert!(
err.to_string().contains("forbidden character"),
"got: {}",
err
);
}
#[test]
fn test_validate_op_uri_forbidden_dollar() {
let err = validate_op_uri("op://vault/$HOME/field").expect_err("should be rejected");
assert!(
err.to_string().contains("forbidden character"),
"got: {}",
err
);
}
#[test]
fn test_validate_op_uri_forbidden_backtick() {
let err = validate_op_uri("op://vault/`whoami`/field").expect_err("should be rejected");
assert!(
err.to_string().contains("forbidden character"),
"got: {}",
err
);
}
#[test]
fn test_validate_op_uri_forbidden_newline() {
let err = validate_op_uri("op://vault/item\n/field").expect_err("should be rejected");
assert!(
err.to_string().contains("forbidden character"),
"got: {}",
err
);
}
#[test]
fn test_validate_op_uri_query_string() {
let err = validate_op_uri("op://vault/item/field?x=y").expect_err("should be rejected");
assert!(
err.to_string().contains("query strings or fragments"),
"got: {}",
err
);
}
#[test]
fn test_validate_op_uri_fragment() {
let err = validate_op_uri("op://vault/item/field#section").expect_err("should be rejected");
assert!(
err.to_string().contains("query strings or fragments"),
"got: {}",
err
);
}
#[test]
fn test_redact_op_uri_3_segments() {
assert_eq!(
redact_op_uri("op://MyVault/MyItem/credential"),
"op://MyVault/MyItem/<redacted>"
);
}
#[test]
fn test_redact_op_uri_4_segments() {
assert_eq!(
redact_op_uri("op://MyVault/MyItem/section/field"),
"op://MyVault/MyItem/<redacted>"
);
}
#[test]
fn test_redact_op_uri_malformed() {
assert_eq!(redact_op_uri("op://only"), "op://***");
}
#[test]
fn test_redact_op_uri_not_op() {
assert_eq!(redact_op_uri("keyring_account"), "op://***");
}
#[test]
fn test_classify_op_error_auth_required() {
let err = classify_op_error(
"[ERROR] not signed in. Run 'op signin' first.\n",
"op://vault/item/field",
);
let msg = err.to_string();
assert!(msg.contains("authentication required"), "got: {}", msg);
assert!(msg.contains("op signin"), "got: {}", msg);
}
#[test]
fn test_classify_op_error_session_expired() {
let err = classify_op_error("[ERROR] session expired\n", "op://vault/item/field");
let msg = err.to_string();
assert!(msg.contains("authentication required"), "got: {}", msg);
}
#[test]
fn test_classify_op_error_not_found() {
let err = classify_op_error(
"[ERROR] \"item\" not found in vault \"vault\"\n",
"op://vault/item/field",
);
let msg = err.to_string();
assert!(msg.contains("not found"), "got: {}", msg);
}
#[test]
fn test_classify_op_error_unknown() {
let err = classify_op_error("[ERROR] network timeout\n", "op://vault/item/field");
let msg = err.to_string();
assert!(msg.contains("1Password CLI failed"), "got: {}", msg);
}
#[test]
fn test_is_op_uri_positive() {
assert!(is_op_uri("op://vault/item/field"));
}
#[test]
fn test_is_op_uri_negative() {
assert!(!is_op_uri("openai_api_key"));
}
#[test]
fn test_load_secret_by_ref_dispatches_op() {
let result = load_secret_by_ref("nono", "op://vault/item/field");
assert!(result.is_err());
let err = result.expect_err("should be rejected").to_string();
assert!(
err.contains("1Password") || err.contains("op"),
"expected 1Password error, got: {}",
err
);
}
#[test]
fn test_is_env_uri_positive() {
assert!(is_env_uri("env://GITHUB_TOKEN"));
assert!(is_env_uri("env://MY_KEY_123"));
}
#[test]
fn test_is_env_uri_negative() {
assert!(!is_env_uri("openai_api_key"));
assert!(!is_env_uri("op://vault/item/field"));
assert!(!is_env_uri("ENV://UPPER_SCHEME"));
}
#[test]
fn test_validate_env_uri_valid() {
assert!(validate_env_uri("env://GITHUB_TOKEN").is_ok());
assert!(validate_env_uri("env://MY_API_KEY_123").is_ok());
assert!(validate_env_uri("env://x").is_ok());
assert!(validate_env_uri("env://A").is_ok());
}
#[test]
fn test_validate_env_uri_empty_name() {
let err = validate_env_uri("env://").expect_err("should reject");
assert!(
err.to_string().contains("empty variable name"),
"got: {}",
err
);
}
#[test]
fn test_validate_env_uri_invalid_chars() {
let err = validate_env_uri("env://MY VAR").expect_err("should reject");
assert!(
err.to_string().contains("invalid character"),
"got: {}",
err
);
let err = validate_env_uri("env://MY-VAR").expect_err("should reject");
assert!(
err.to_string().contains("invalid character"),
"got: {}",
err
);
let err = validate_env_uri("env://MY.VAR").expect_err("should reject");
assert!(
err.to_string().contains("invalid character"),
"got: {}",
err
);
let err = validate_env_uri("env://$(whoami)").expect_err("should reject");
assert!(
err.to_string().contains("invalid character"),
"got: {}",
err
);
}
#[test]
fn test_validate_env_uri_dangerous_ld_preload() {
let err = validate_env_uri("env://LD_PRELOAD").expect_err("should reject");
assert!(err.to_string().contains("dangerous"), "got: {}", err);
}
#[test]
fn test_validate_env_uri_dangerous_dyld() {
let err = validate_env_uri("env://DYLD_INSERT_LIBRARIES").expect_err("should reject");
assert!(err.to_string().contains("dangerous"), "got: {}", err);
}
#[test]
fn test_validate_env_uri_dangerous_node_options() {
let err = validate_env_uri("env://NODE_OPTIONS").expect_err("should reject");
assert!(err.to_string().contains("dangerous"), "got: {}", err);
}
#[test]
fn test_validate_env_uri_dangerous_path() {
let err = validate_env_uri("env://PATH").expect_err("should reject");
assert!(err.to_string().contains("dangerous"), "got: {}", err);
}
#[test]
fn test_validate_env_uri_missing_prefix() {
let err = validate_env_uri("GITHUB_TOKEN").expect_err("should reject");
assert!(
err.to_string().contains("does not start with"),
"got: {}",
err
);
}
#[test]
fn test_load_from_env_set() {
let test_var = "NONO_TEST_ENV_SECRET_12345";
unsafe { std::env::set_var(test_var, "secret_value_42") };
let result = load_from_env(&format!("env://{}", test_var));
assert!(result.is_ok(), "should load: {:?}", result.err());
assert_eq!(*result.expect("should load"), "secret_value_42");
unsafe { std::env::remove_var(test_var) };
}
#[test]
fn test_load_from_env_not_set() {
let result = load_from_env("env://NONO_NONEXISTENT_VAR_XYZZY");
assert!(result.is_err());
let err = result.expect_err("should fail").to_string();
assert!(err.contains("not set"), "got: {}", err);
}
#[test]
fn test_load_from_env_empty() {
let test_var = "NONO_TEST_ENV_EMPTY_12345";
unsafe { std::env::set_var(test_var, "") };
let result = load_from_env(&format!("env://{}", test_var));
assert!(result.is_err());
let err = result.expect_err("should fail").to_string();
assert!(err.contains("empty"), "got: {}", err);
unsafe { std::env::remove_var(test_var) };
}
#[test]
fn test_load_secret_by_ref_dispatches_env() {
let test_var = "NONO_TEST_REF_DISPATCH_12345";
unsafe { std::env::set_var(test_var, "dispatched_ok") };
let result = load_secret_by_ref("nono", &format!("env://{}", test_var));
assert!(
result.is_ok(),
"should dispatch to env backend: {:?}",
result.err()
);
assert_eq!(*result.expect("should load"), "dispatched_ok");
unsafe { std::env::remove_var(test_var) };
}
#[test]
fn test_build_mappings_env_uri_auto_derive() {
let mappings = build_mappings_from_list("env://GITHUB_TOKEN").expect("should parse");
assert_eq!(mappings.len(), 1);
assert_eq!(
mappings.get("env://GITHUB_TOKEN"),
Some(&"GITHUB_TOKEN".to_string())
);
}
#[test]
fn test_build_mappings_env_uri_with_explicit_var() {
let mappings =
build_mappings_from_list("env://GITHUB_TOKEN=GH_TOKEN").expect("should parse");
assert_eq!(mappings.len(), 1);
assert_eq!(
mappings.get("env://GITHUB_TOKEN"),
Some(&"GH_TOKEN".to_string())
);
}
#[test]
fn test_build_mappings_env_uri_empty_var_rejected() {
let err =
build_mappings_from_list("env://GITHUB_TOKEN=").expect_err("should reject empty var");
assert!(err.to_string().contains("no variable name"), "got: {}", err);
}
#[test]
fn test_build_mappings_env_uri_dangerous_rejected() {
let err =
build_mappings_from_list("env://LD_PRELOAD").expect_err("should reject dangerous var");
assert!(err.to_string().contains("dangerous"), "got: {}", err);
}
#[test]
fn test_build_mappings_mixed_keyring_op_env() {
let mappings = build_mappings_from_list(
"my_api_key,op://vault/item/field=SECRET_VAR,env://GITHUB_TOKEN",
)
.expect("should parse");
assert_eq!(mappings.len(), 3);
assert_eq!(mappings.get("my_api_key"), Some(&"MY_API_KEY".to_string()));
assert_eq!(
mappings.get("op://vault/item/field"),
Some(&"SECRET_VAR".to_string())
);
assert_eq!(
mappings.get("env://GITHUB_TOKEN"),
Some(&"GITHUB_TOKEN".to_string())
);
}
#[test]
fn test_validate_env_uri_dangerous_case_insensitive() {
let err = validate_env_uri("env://ld_preload").expect_err("should reject");
assert!(err.to_string().contains("dangerous"), "got: {}", err);
let err = validate_env_uri("env://Ld_Preload").expect_err("should reject");
assert!(err.to_string().contains("dangerous"), "got: {}", err);
let err = validate_env_uri("env://path").expect_err("should reject");
assert!(err.to_string().contains("dangerous"), "got: {}", err);
let err = validate_env_uri("env://Node_Options").expect_err("should reject");
assert!(err.to_string().contains("dangerous"), "got: {}", err);
}
#[test]
fn test_validate_destination_env_var_valid() {
assert!(validate_destination_env_var("GITHUB_TOKEN").is_ok());
assert!(validate_destination_env_var("MY_API_KEY").is_ok());
assert!(validate_destination_env_var("x").is_ok());
}
#[test]
fn test_validate_destination_env_var_empty() {
let err = validate_destination_env_var("").expect_err("should reject");
assert!(err.to_string().contains("empty"), "got: {}", err);
}
#[test]
fn test_validate_destination_env_var_invalid_chars() {
let err = validate_destination_env_var("MY-VAR").expect_err("should reject");
assert!(
err.to_string().contains("invalid character"),
"got: {}",
err
);
}
#[test]
fn test_validate_destination_env_var_dangerous() {
let err = validate_destination_env_var("LD_PRELOAD").expect_err("should reject");
assert!(err.to_string().contains("blocklist"), "got: {}", err);
}
#[test]
fn test_validate_destination_env_var_dangerous_case_insensitive() {
let err = validate_destination_env_var("ld_preload").expect_err("should reject");
assert!(err.to_string().contains("blocklist"), "got: {}", err);
let err = validate_destination_env_var("Path").expect_err("should reject");
assert!(err.to_string().contains("blocklist"), "got: {}", err);
let err = validate_destination_env_var("DYLD_INSERT_LIBRARIES").expect_err("should reject");
assert!(err.to_string().contains("blocklist"), "got: {}", err);
}
#[test]
fn test_build_mappings_env_uri_explicit_dangerous_target_rejected() {
let err = build_mappings_from_list("env://SAFE_VAR=LD_PRELOAD")
.expect_err("should reject dangerous target");
assert!(err.to_string().contains("blocklist"), "got: {}", err);
}
#[test]
fn test_build_mappings_op_uri_dangerous_target_rejected() {
let err = build_mappings_from_list("op://vault/item/field=PATH")
.expect_err("should reject dangerous target");
assert!(err.to_string().contains("blocklist"), "got: {}", err);
}
#[test]
fn test_build_mappings_keyring_dangerous_autoderived_rejected() {
let err =
build_mappings_from_list("ld_preload").expect_err("should reject dangerous target");
assert!(err.to_string().contains("blocklist"), "got: {}", err);
}
}