use crate::events::{EventHandler, FsEvent, HandlerError};
use crate::path::{AbsolutePath, CanonicalPath};
use crate::provider::SecretsProvider;
use crate::secrets::config::{InjectFailurePolicy, SecretManagerConfig};
use crate::secrets::registry::SecretFileRegistry;
use crate::secrets::{SecretError, SecretSource, file::SecretFile};
use crate::template::Template;
use async_trait::async_trait;
use secrecy::ExposeSecret;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::{debug, info, warn};
pub struct SecretFileManager {
config: SecretManagerConfig,
registry: SecretFileRegistry,
literals: Vec<SecretFile>,
provider: Arc<dyn SecretsProvider>,
}
impl SecretFileManager {
pub fn new(
mut config: SecretManagerConfig,
provider: Arc<dyn SecretsProvider>,
) -> Result<Self, SecretError> {
config.validate_structure()?;
let mut pinned = Vec::new();
let mut literals = Vec::new();
for s in &config.secrets {
let f = SecretFile::from_secret(s.clone(), &config.out, config.max_file_size)?;
match f.source() {
SecretSource::File(_) => pinned.push(f),
SecretSource::Literal { .. } => literals.push(f),
}
}
let registry = SecretFileRegistry::new(config.map.clone(), pinned, config.max_file_size);
let manager = Self {
config,
registry,
literals,
provider,
};
manager.collisions()?;
Ok(manager)
}
pub fn iter_secrets(&self) -> impl Iterator<Item = &SecretFile> {
self.registry.iter().chain(self.literals.iter())
}
pub fn config(&self) -> &SecretManagerConfig {
&self.config
}
pub fn sources(&self) -> Vec<AbsolutePath> {
let pinned = self
.registry
.iter()
.filter_map(|f| f.source().path().map(|p| AbsolutePath::from(p.clone())));
let mapped = self
.config
.map
.iter()
.map(|m| AbsolutePath::from(m.src().clone()));
pinned.chain(mapped).collect()
}
async fn resolve(&self, file: &SecretFile) -> Result<String, SecretError> {
let f = file.clone();
let content =
tokio::task::spawn_blocking(move || f.content().map(|c| c.into_owned())).await??;
let tpl = Template::parse(&content, &*self.provider);
if tpl.has_secrets() {
let references_to_fetch = tpl.references();
info!(dst=?file.dest(), count=references_to_fetch.len(), "fetching secrets from template");
let secrets_map = self.provider.fetch_map(&references_to_fetch).await?;
let output = tpl.render_with(|k| secrets_map.get(k).map(|s| s.expose_secret()));
Ok(output.into_owned())
} else {
if let Some(reference) = self.provider.parse(content.trim()) {
info!(dst=?file.dest(), "fetching bare secret");
let secrets_map = self
.provider
.fetch_map(std::slice::from_ref(&reference))
.await?;
match secrets_map.get(&reference) {
Some(val) => Ok(val.expose_secret().to_string()),
None => {
warn!(dst=?file.dest(), "provider returned success but secret value was missing");
Ok(content) }
}
} else {
debug!(dst=?file.dest(), "no resolvable secrets found; passing through");
Ok(content)
}
}
}
pub async fn materialize(&self, file: &SecretFile, content: String) -> Result<(), SecretError> {
let writer = self.config.writer.clone();
let dest = file.dest().clone();
let bytes = content.into_bytes();
tokio::task::spawn_blocking(move || writer.atomic_write(&dest, &bytes)).await??;
Ok(())
}
async fn handle_policy(
&self,
file: &SecretFile,
err: SecretError,
policy: InjectFailurePolicy,
) -> Result<(), SecretError> {
if let SecretError::SourceMissing(path) = &err {
debug!("Source doesn't exist: {:?}. Ignoring.", path);
return Ok(());
}
match policy {
InjectFailurePolicy::Error => Err(err),
InjectFailurePolicy::Passthrough => {
warn!(
src = ?file.source().label(),
dst = ?file.dest(),
error = ?err,
"injection failed; policy=passthrough. Reverting to raw copy."
);
let f = file.clone();
let raw = tokio::task::spawn_blocking(move || {
f.content().map(|c| c.into_owned()).unwrap_or_default()
})
.await?;
if !raw.is_empty() {
self.materialize(file, raw).await?;
}
Ok(())
}
InjectFailurePolicy::Ignore => {
warn!(src = ?file.source().label(), dst = ?file.dest(), error = ?err, "injection failed; ignoring");
Ok(())
}
}
}
pub async fn process(&self, file: &SecretFile) -> Result<(), SecretError> {
match self.resolve(file).await {
Ok(content) => {
if let Err(e) = self.materialize(file, content).await {
return self
.handle_policy(file, e, self.config.inject_failure_policy)
.await;
}
Ok(())
}
Err(e) => {
self.handle_policy(file, e, self.config.inject_failure_policy)
.await
}
}
}
pub async fn inject_all(&self) -> Result<(), SecretError> {
for file in self.iter_secrets() {
self.process(file).await?;
}
Ok(())
}
fn collisions(&self) -> Result<(), SecretError> {
let mut entries: Vec<(&AbsolutePath, String)> = Vec::new();
for file in self.iter_secrets() {
entries.push((file.dest(), format!("File({})", file.source().label())));
}
entries.sort_by_key(|(path, _)| *path);
for i in 0..entries.len().saturating_sub(1) {
let (curr_path, curr_src) = &entries[i];
let (next_path, next_src) = &entries[i + 1];
if curr_path == next_path {
return Err(SecretError::Collision {
first: curr_src.clone(),
second: next_src.clone(),
dst: curr_path.to_path_buf(),
});
}
if next_path.starts_with(curr_path) {
return Err(SecretError::StructureConflict {
blocker: curr_src.clone(),
blocked: next_src.clone(),
});
}
}
Ok(())
}
fn handle_remove(&mut self, src: AbsolutePath) -> Result<(), SecretError> {
let removed = self.registry.remove(&src);
if removed.is_empty() {
debug!(
?src,
"event: path removed but no secrets were tracked there"
);
return Ok(());
}
for file in &removed {
let dst = file.dest();
if dst.exists() {
std::fs::remove_file(dst)?;
}
debug!("event: removed secret file: {:?}", file.dest());
}
if let Some(ceiling) = self.registry.resolve(src.clone()) {
self.cleanup_parents(removed, &ceiling);
}
Ok(())
}
fn cleanup_parents(&self, removed_files: Vec<SecretFile>, ceiling: &AbsolutePath) {
let mut candidates = std::collections::HashSet::new();
for file in removed_files {
if let Some(parent) = file.dest().parent() {
candidates.insert(parent.to_path_buf());
}
}
for dir in candidates {
if let Ok(candidate) = CanonicalPath::try_new(dir)
&& candidate.starts_with(ceiling)
{
self.bubble_delete(candidate.into(), ceiling);
}
}
}
fn bubble_delete(&self, start_dir: AbsolutePath, ceiling: &AbsolutePath) {
let mut current = start_dir;
loop {
if !current.starts_with(ceiling) {
break;
}
match std::fs::remove_dir(¤t) {
Ok(_) => {
if current == *ceiling {
break;
}
match current.parent() {
Some(p) => current = p,
None => break,
}
}
Err(_) => break,
}
}
}
async fn handle_move(
&mut self,
old: AbsolutePath,
new: CanonicalPath,
) -> Result<(), SecretError> {
if let Some((from_dst, to_dst)) = self.registry.try_rebase(&old, &new.clone().into()) {
debug!(?from_dst, ?to_dst, "attempting optimistic rename");
if let Some(p) = to_dst.parent() {
tokio::fs::create_dir_all(p).await?;
}
match tokio::fs::rename(&from_dst, &to_dst).await {
Ok(_) => {
debug!(?old, ?new, "moved");
if let Some(parent) = from_dst.parent()
&& let Some(ceiling) = self.registry.resolve(old.clone())
&& let Some(ceil_parent) = ceiling.parent()
{
self.bubble_delete(parent, &ceil_parent);
}
return Ok(());
}
Err(e) => {
warn!(error=?e, "move failed; falling back to reinjection");
self.registry.remove(&new.clone().into());
if from_dst.exists() {
let _ = tokio::fs::remove_file(&from_dst).await;
}
}
}
}
debug!(?old, ?new, "fallback move via remove + write");
self.handle_remove(old)?;
self.handle_write(new).await?;
Ok(())
}
async fn handle_write(&mut self, src: CanonicalPath) -> Result<(), SecretError> {
if src.is_dir() {
debug!(?src, "directory write event; scanning for children");
let entries: Vec<PathBuf> = walkdir::WalkDir::new(&src)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.map(|e| e.path().to_path_buf())
.collect();
for entry in entries {
if let Ok(canon_entry) = CanonicalPath::try_new(entry) {
Box::pin(self.handle_write(canon_entry)).await?;
}
}
return Ok(());
}
match self.registry.upsert(src.into())? {
Some(file) => {
self.process(&file).await?;
}
None => {
}
}
Ok(())
}
}
#[async_trait]
impl EventHandler for SecretFileManager {
fn paths(&self) -> Vec<AbsolutePath> {
self.sources()
}
async fn handle(&mut self, events: Vec<FsEvent>) -> Result<(), HandlerError> {
for event in events {
let result = match event {
FsEvent::Write(src) => match src.canonicalize() {
Ok(canon) => self.handle_write(canon).await,
Err(e) => {
debug!(?src, "write/create event for missing file; ignoring: {}", e);
Ok(())
}
},
FsEvent::Remove(src) => self.handle_remove(src),
FsEvent::Move { from, to } => {
match to.canonicalize() {
Ok(new_canon) => self.handle_move(from, new_canon).await,
Err(e) => {
debug!(
?to,
"move destination missing; downgrading to remove: {}", e
);
self.handle_remove(from)
}
}
}
};
if let Err(e) = result {
warn!(error = ?e, "failed to process fs event");
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::secrets::MemSize;
use std::path::Path;
use std::str::FromStr;
#[test]
fn secret_value_sanitization() {
let root = AbsolutePath::new("/");
let v = SecretFile::from_template("Db_Password".to_string(), "".to_string(), &root);
assert_eq!(v.dest(), Path::new("/Db_Password"));
let v = SecretFile::from_template("A/B/C".to_string(), "".to_string(), &root);
assert_eq!(v.dest(), Path::new("/ABC"));
let v = SecretFile::from_template("weird name".to_string(), "".to_string(), &root);
assert_eq!(v.dest(), Path::new("/weird name"));
let v = SecretFile::from_template("..//--__".to_string(), "".to_string(), &root);
assert_eq!(v.dest(), Path::new("/..--__"));
}
#[test]
fn test_size_parsing() {
assert_eq!(MemSize::from_str("100").unwrap().bytes, 100);
assert_eq!(MemSize::from_str("1k").unwrap().bytes, 1024);
assert_eq!(MemSize::from_str("10M").unwrap().bytes, 10 * 1024 * 1024);
}
}