use std::path::{Path, PathBuf};
use zeroize::Zeroize;
use crate::audit::extract_json_field;
use crate::error::Error;
use crate::vault::Vault;
pub fn vault_root() -> Result<PathBuf, Error> {
let config_dir = if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
PathBuf::from(xdg)
} else {
let home = std::env::var("HOME").map_err(|_| {
Error::StorageIo(std::io::Error::new(
std::io::ErrorKind::NotFound,
"HOME not set",
))
})?;
PathBuf::from(home).join(".config")
};
Ok(config_dir.join("envseal"))
}
#[derive(Debug, Clone)]
pub struct SecretInfo {
pub name: String,
pub modified: Option<std::time::SystemTime>,
}
pub fn list_secrets(root: &Path) -> Result<Vec<SecretInfo>, Error> {
let vault_dir = root.join("vault");
crate::guard::verify_not_symlink(&vault_dir)?;
let mut entries = Vec::new();
if vault_dir.exists() {
for entry in std::fs::read_dir(&vault_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("seal") {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
crate::guard::verify_not_symlink(&path)?;
let modified = std::fs::symlink_metadata(&path)
.ok()
.and_then(|m| m.modified().ok());
entries.push(SecretInfo {
name: stem.to_string(),
modified,
});
}
}
}
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(entries)
}
pub fn list_secret_names(root: &Path) -> Result<Vec<String>, Error> {
Ok(list_secrets(root)?.into_iter().map(|s| s.name).collect())
}
pub fn secret_exists(root: &Path, name: &str) -> Result<bool, Error> {
let path = root.join("vault").join(format!("{name}.seal"));
crate::guard::verify_not_symlink(&path)?;
Ok(path.exists())
}
#[must_use]
pub fn format_age(modified: std::time::SystemTime) -> String {
std::time::SystemTime::now()
.duration_since(modified)
.ok()
.map_or_else(
|| "—".to_string(),
|d| {
let secs = d.as_secs();
if secs < 60 {
"just now".to_string()
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86400 {
format!("{}h ago", secs / 3600)
} else {
format!("{}d ago", secs / 86400)
}
},
)
}
#[must_use]
pub fn detect_secret_type(value: &str) -> Option<&'static str> {
static DETECTIONS: &[(&str, &str)] = &[
("sk-proj-", "OpenAI Project Key"),
("sk-ant-", "Anthropic API Key"),
("sk-", "OpenAI API Key"),
("ghp_", "GitHub PAT"),
("gho_", "GitHub OAuth"),
("github_pat_", "GitHub Fine-Grained PAT"),
("glpat-", "GitLab PAT"),
("AKIA", "AWS Access Key"),
("xoxb-", "Slack Bot Token"),
("xoxp-", "Slack User Token"),
("sk_live_", "Stripe Secret Key"),
("sk_test_", "Stripe Test Key"),
("pk_live_", "Stripe Publishable Key"),
("pk_test_", "Stripe Test Publishable Key"),
("rk_live_", "Stripe Restricted Key"),
("SG.", "SendGrid API Key"),
("key-", "Mailgun API Key"),
("AIza", "Google API Key"),
("npm_", "npm Token"),
("pypi-", "PyPI Token"),
("eyJ", "JWT Token"),
("https://", "URL"),
("http://", "URL"),
("postgresql://", "PostgreSQL URL"),
("postgres://", "PostgreSQL URL"),
("mysql://", "MySQL URL"),
("redis://", "Redis URL"),
("mongodb://", "MongoDB URL"),
("mongodb+srv://", "MongoDB Atlas URL"),
];
DETECTIONS
.iter()
.find(|(prefix, _)| value.starts_with(prefix))
.map(|(_, label)| *label)
}
#[derive(Debug, Clone)]
pub struct PeekResult {
pub name: String,
pub redacted: String,
pub length: usize,
pub secret_type: Option<&'static str>,
}
pub fn peek_secret(vault: &Vault, name: &str) -> Result<PeekResult, Error> {
let mut plaintext = vault.decrypt(name)?;
let vec = std::mem::take(&mut *plaintext);
let s = match String::from_utf8(vec) {
Ok(s) => s,
Err(e) => {
let mut vec = e.into_bytes();
vec.zeroize();
return Err(Error::CryptoFailure(
"secret is not valid UTF-8".to_string(),
));
}
};
let mut value = zeroize::Zeroizing::new(s);
let redacted = if value.len() <= 10 {
let mut prefix = value.chars().take(2).collect::<String>();
let res = format!("{prefix}***");
prefix.zeroize();
res
} else {
let mut prefix = value.chars().take(4).collect::<String>();
let mut suffix_rev = value.chars().rev().take(4).collect::<String>();
let mut suffix = suffix_rev.chars().rev().collect::<String>();
let res = format!("{prefix}...{suffix}");
prefix.zeroize();
suffix_rev.zeroize();
suffix.zeroize();
res
};
let length = value.len();
let secret_type = detect_secret_type(&value);
value.shrink_to_fit();
value.zeroize();
Ok(PeekResult {
name: name.to_string(),
redacted,
length,
secret_type,
})
}
pub fn peek_secret_default(name: &str) -> Result<PeekResult, Error> {
let vault = Vault::open_default()?;
peek_secret(&vault, name)
}
pub fn peek_secret_full_default(name: &str) -> Result<String, Error> {
let vault = Vault::open_default()?;
let config = crate::security_config::load_config(vault.root(), vault.master_key_bytes())?;
let binary_path = std::env::current_exe()
.map_err(|e| Error::BinaryResolution(format!("cannot determine current executable: {e}")))?
.to_string_lossy()
.to_string();
let approval = crate::gui::request_approval(
&binary_path,
&[
String::from("envseal"),
String::from("peek"),
name.to_string(),
String::from("--full"),
],
name,
"",
&config,
)?;
match approval {
crate::gui::Approval::AllowOnce | crate::gui::Approval::AllowAlways => {
let mut plaintext = vault.decrypt(name)?;
match String::from_utf8(std::mem::take(&mut *plaintext)) {
Ok(s) => Ok(s),
Err(e) => {
let mut vec = e.into_bytes();
vec.zeroize();
Err(Error::CryptoFailure(
"secret is not valid UTF-8".to_string(),
))
}
}
}
crate::gui::Approval::Deny => Err(Error::UserDenied),
}
}
#[derive(Debug, Clone)]
pub struct CheckResult {
pub satisfied: Vec<EnvMapping>,
pub missing: Vec<EnvMapping>,
}
#[derive(Debug, Clone)]
pub struct EnvMapping {
pub env_var: String,
pub secret_name: String,
pub present: bool,
}
pub fn check_envseal_file(root: &Path, envseal_path: &Path) -> Result<CheckResult, Error> {
let mappings = crate::envseal_file::parse_envseal_file(envseal_path)?;
let names = list_secret_names(root)?;
let mut satisfied = Vec::new();
let mut missing = Vec::new();
for m in mappings {
let present = names.contains(&m.secret_name);
let mapping = EnvMapping {
env_var: m.env_var.clone(),
secret_name: m.secret_name.clone(),
present,
};
if present {
satisfied.push(mapping);
} else {
missing.push(mapping);
}
}
Ok(CheckResult { satisfied, missing })
}
pub fn env_dry_run(root: &Path, cwd: &Path) -> Result<CheckResult, Error> {
let mappings = if let Some(m) = crate::envseal_file::discover_and_load(cwd)? {
m
} else {
let names = list_secret_names(root)?;
crate::envseal_file::auto_map_from_names(&names)
};
let names = list_secret_names(root)?;
let mut satisfied = Vec::new();
let mut missing = Vec::new();
for m in mappings {
let present = names.contains(&m.secret_name);
let mapping = EnvMapping {
env_var: m.env_var.clone(),
secret_name: m.secret_name.clone(),
present,
};
if present {
satisfied.push(mapping);
} else {
missing.push(mapping);
}
}
Ok(CheckResult { satisfied, missing })
}
#[derive(Debug, Clone)]
pub struct VaultStatus {
pub root: PathBuf,
pub secret_count: usize,
pub initialized: bool,
pub envseal_found: bool,
pub security_tier: String,
pub last_audit: Option<String>,
}
pub fn vault_status(root: &Path) -> Result<VaultStatus, Error> {
let secrets = list_secrets(root)?;
let initialized = root.join("master.key").exists();
let cwd = std::env::current_dir().unwrap_or_default();
let envseal_found = crate::envseal_file::discover_and_load(&cwd)
.ok()
.flatten()
.is_some();
let mut corrupted = false;
if initialized {
let mk_path = root.join("master.key");
if let Ok(raw) = std::fs::read(&mk_path) {
if crate::vault::hardware::parse_v2(&raw).is_err() {
let min_v1_len = 16 + 12; if raw.len() < min_v1_len {
corrupted = true;
}
}
} else {
corrupted = true;
}
}
let security_tier = if corrupted {
"Corrupted".to_string()
} else if !initialized {
"Standard (default)".to_string()
} else if root.join("security.sealed").exists() {
"Locked (passphrase required)".to_string()
} else if root.join("security.toml").exists() {
std::fs::read_to_string(root.join("security.toml"))
.ok()
.and_then(|content| {
content
.lines()
.find(|l| l.trim_start().starts_with("tier"))
.map(|l| {
l.split('=').nth(1).map_or_else(
|| "Standard".to_string(),
|v| v.trim().trim_matches('"').to_string(),
)
})
})
.unwrap_or_else(|| "Standard (default)".to_string())
} else {
"Standard (default)".to_string()
};
let last_audit = crate::audit::read_last(1).into_iter().next().map(|raw| {
let ts = extract_json_field(&raw, "ts").unwrap_or_default();
let event = extract_json_field(&raw, "event").unwrap_or_default();
let binary = extract_json_field(&raw, "binary").unwrap_or_default();
let secret = extract_json_field(&raw, "secret").unwrap_or_default();
let ts_short = ts.get(..16).unwrap_or(&ts).replace('T', " ");
if !binary.is_empty() && !secret.is_empty() {
format!(" {ts_short} {event:<22} {binary} → {secret}")
} else if !secret.is_empty() {
format!(" {ts_short} {event:<22} {secret}")
} else {
format!(" {ts_short} {event}")
}
});
Ok(VaultStatus {
root: root.to_path_buf(),
secret_count: secrets.len(),
initialized: initialized && !corrupted,
envseal_found,
security_tier,
last_audit,
})
}
#[must_use]
pub fn audit_log(n: usize) -> Vec<String> {
crate::audit::read_last(n)
}
#[must_use]
pub fn audit_log_parsed(n: usize) -> crate::audit::ParsedReadResult {
let Ok(dir) = crate::audit::log::default_audit_dir() else {
return crate::audit::ParsedReadResult::default();
};
crate::audit::read_last_parsed_at(&dir, n)
}
#[must_use]
pub fn audit_log_filtered(n: usize, filter: &crate::audit::AuditFilter) -> Vec<String> {
crate::audit::read_last_filtered(n, filter)
}
#[must_use]
pub fn audit_log_parsed_filtered(
n: usize,
filter: &crate::audit::AuditFilter,
) -> crate::audit::ParsedReadResult {
let Ok(dir) = crate::audit::log::default_audit_dir() else {
return crate::audit::ParsedReadResult::default();
};
crate::audit::read_last_parsed_filtered_at(&dir, n, filter)
}