use std::collections::HashMap;
use async_trait::async_trait;
use crate::{DirEntry, Metadata, VfsResult, VfsStorage};
#[derive(Clone, Debug)]
pub struct SecretConfig {
pub placeholder: String,
}
#[derive(Debug, Clone)]
pub enum FileScrubPolicy {
All,
None,
#[doc(hidden)]
Except(Vec<String>),
#[doc(hidden)]
Only(Vec<String>),
}
impl FileScrubPolicy {
pub fn should_scrub(&self, _path: &str) -> bool {
match self {
Self::All => true,
Self::None => false,
Self::Except(_patterns) => {
true
}
Self::Only(_patterns) => {
true
}
}
}
}
#[derive(Debug)]
pub struct ScrubbingStorage<S> {
inner: S,
secrets: HashMap<String, SecretConfig>,
policy: FileScrubPolicy,
}
impl<S> ScrubbingStorage<S> {
pub fn new(inner: S, secrets: HashMap<String, SecretConfig>, policy: FileScrubPolicy) -> Self {
Self {
inner,
secrets,
policy,
}
}
fn scrub_if_needed(&self, path: &str, data: &[u8]) -> Vec<u8> {
if !self.policy.should_scrub(path) || self.secrets.is_empty() {
return data.to_vec();
}
if let Ok(text) = std::str::from_utf8(data) {
let mut scrubbed = text.to_string();
for secret_config in self.secrets.values() {
scrubbed = scrubbed.replace(&secret_config.placeholder, "[REDACTED]");
}
scrubbed.into_bytes()
} else {
let mut result = data.to_vec();
for secret_config in self.secrets.values() {
result =
replace_bytes(&result, secret_config.placeholder.as_bytes(), b"[REDACTED]");
}
result
}
}
}
#[async_trait]
impl<S: VfsStorage> VfsStorage for ScrubbingStorage<S> {
async fn read(&self, path: &str) -> VfsResult<Vec<u8>> {
self.inner.read(path).await
}
async fn read_at(&self, path: &str, offset: u64, len: u64) -> VfsResult<Vec<u8>> {
self.inner.read_at(path, offset, len).await
}
async fn write(&self, path: &str, data: &[u8]) -> VfsResult<()> {
let scrubbed = self.scrub_if_needed(path, data);
self.inner.write(path, &scrubbed).await
}
async fn write_at(&self, path: &str, offset: u64, data: &[u8]) -> VfsResult<()> {
let scrubbed = self.scrub_if_needed(path, data);
self.inner.write_at(path, offset, &scrubbed).await
}
async fn set_size(&self, path: &str, size: u64) -> VfsResult<()> {
self.inner.set_size(path, size).await
}
async fn delete(&self, path: &str) -> VfsResult<()> {
self.inner.delete(path).await
}
async fn exists(&self, path: &str) -> VfsResult<bool> {
self.inner.exists(path).await
}
async fn list(&self, path: &str) -> VfsResult<Vec<DirEntry>> {
self.inner.list(path).await
}
async fn stat(&self, path: &str) -> VfsResult<Metadata> {
self.inner.stat(path).await
}
async fn mkdir(&self, path: &str) -> VfsResult<()> {
self.inner.mkdir(path).await
}
async fn rmdir(&self, path: &str) -> VfsResult<()> {
self.inner.rmdir(path).await
}
async fn rename(&self, from: &str, to: &str) -> VfsResult<()> {
self.inner.rename(from, to).await
}
fn mkdir_sync(&self, path: &str) -> VfsResult<()> {
self.inner.mkdir_sync(path)
}
}
fn replace_bytes(haystack: &[u8], needle: &[u8], replacement: &[u8]) -> Vec<u8> {
let mut result = Vec::with_capacity(haystack.len());
let mut i = 0;
while i < haystack.len() {
if haystack[i..].starts_with(needle) {
result.extend_from_slice(replacement);
i += needle.len();
} else {
result.push(haystack[i]);
i += 1;
}
}
result
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::InMemoryStorage;
#[tokio::test]
async fn test_scrubbing_text_file() {
let storage = InMemoryStorage::new();
let mut secrets = HashMap::new();
secrets.insert(
"KEY".to_string(),
SecretConfig {
placeholder: "PLACEHOLDER_123".to_string(),
},
);
let scrubbing = ScrubbingStorage::new(storage, secrets, FileScrubPolicy::All);
scrubbing
.write("/test.txt", b"Secret: PLACEHOLDER_123")
.await
.unwrap();
let content = scrubbing.read("/test.txt").await.unwrap();
let text = String::from_utf8(content).unwrap();
assert_eq!(text, "Secret: [REDACTED]");
}
#[tokio::test]
async fn test_scrubbing_disabled() {
let storage = InMemoryStorage::new();
let secrets = HashMap::new();
let scrubbing = ScrubbingStorage::new(storage, secrets, FileScrubPolicy::None);
scrubbing
.write("/test.txt", b"PLACEHOLDER_123")
.await
.unwrap();
let content = scrubbing.read("/test.txt").await.unwrap();
assert_eq!(content, b"PLACEHOLDER_123");
}
#[tokio::test]
async fn test_multiple_placeholders() {
let storage = InMemoryStorage::new();
let mut secrets = HashMap::new();
secrets.insert(
"KEY1".to_string(),
SecretConfig {
placeholder: "PLACEHOLDER_1".to_string(),
},
);
secrets.insert(
"KEY2".to_string(),
SecretConfig {
placeholder: "PLACEHOLDER_2".to_string(),
},
);
let scrubbing = ScrubbingStorage::new(storage, secrets, FileScrubPolicy::All);
scrubbing
.write("/test.txt", b"Keys: PLACEHOLDER_1 and PLACEHOLDER_2")
.await
.unwrap();
let content = scrubbing.read("/test.txt").await.unwrap();
let text = String::from_utf8(content).unwrap();
assert_eq!(text, "Keys: [REDACTED] and [REDACTED]");
}
#[test]
fn test_replace_bytes() {
let data = b"Hello NEEDLE World NEEDLE!";
let result = replace_bytes(data, b"NEEDLE", b"REPLACEMENT");
assert_eq!(result, b"Hello REPLACEMENT World REPLACEMENT!");
}
#[tokio::test]
async fn test_policy_only_fails_closed() {
let storage = InMemoryStorage::new();
let mut secrets = HashMap::new();
secrets.insert(
"KEY".to_string(),
SecretConfig {
placeholder: "PLACEHOLDER_SECRET".to_string(),
},
);
let scrubbing = ScrubbingStorage::new(
storage,
secrets,
FileScrubPolicy::Only(vec!["/allowed/*".to_string()]),
);
scrubbing
.write("/unmatched.txt", b"Secret: PLACEHOLDER_SECRET")
.await
.unwrap();
let content = scrubbing.read("/unmatched.txt").await.unwrap();
let text = String::from_utf8(content).unwrap();
assert_eq!(
text, "Secret: [REDACTED]",
"FileScrubPolicy::Only must fail closed and scrub all files until glob matching is implemented"
);
}
#[tokio::test]
async fn test_policy_except_fails_closed() {
let storage = InMemoryStorage::new();
let mut secrets = HashMap::new();
secrets.insert(
"KEY".to_string(),
SecretConfig {
placeholder: "PLACEHOLDER_SECRET".to_string(),
},
);
let scrubbing = ScrubbingStorage::new(
storage,
secrets,
FileScrubPolicy::Except(vec!["/excluded/*".to_string()]),
);
scrubbing
.write("/excluded.txt", b"Secret: PLACEHOLDER_SECRET")
.await
.unwrap();
let content = scrubbing.read("/excluded.txt").await.unwrap();
let text = String::from_utf8(content).unwrap();
assert_eq!(
text, "Secret: [REDACTED]",
"FileScrubPolicy::Except must fail closed and scrub all files until glob matching is implemented"
);
}
#[test]
fn test_should_scrub_policy_behavior() {
assert!(FileScrubPolicy::All.should_scrub("/any/path"));
assert!(!FileScrubPolicy::None.should_scrub("/any/path"));
assert!(
FileScrubPolicy::Only(vec!["/specific/*".to_string()]).should_scrub("/other/path"),
"Only policy must fail closed"
);
assert!(
FileScrubPolicy::Only(vec!["/specific/*".to_string()]).should_scrub("/specific/file"),
"Only policy must fail closed"
);
assert!(
FileScrubPolicy::Except(vec!["/excluded/*".to_string()]).should_scrub("/other/path"),
"Except policy must fail closed"
);
assert!(
FileScrubPolicy::Except(vec!["/excluded/*".to_string()]).should_scrub("/excluded/file"),
"Except policy must fail closed"
);
}
}