use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use serde::{Deserialize, Serialize};
use crate::error::{CoreError, Result};
pub trait FileProvider: Send + Sync {
fn get(&self, path: &str) -> Result<Vec<u8>>;
fn exists(&self, path: &str) -> bool;
fn glob(&self, pattern: &str) -> Result<Vec<FileEntry>>;
fn lines(&self, path: &str) -> Result<Vec<String>>;
fn get_string(&self, path: &str) -> Result<String> {
let bytes = self.get(path)?;
String::from_utf8(bytes).map_err(|e| CoreError::FileAccess {
path: path.to_string(),
message: format!("file is not valid UTF-8: {}", e),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileEntry {
pub path: String,
pub name: String,
pub content: String,
pub size: usize,
}
#[derive(Debug)]
pub struct SandboxedFileProvider {
root: PathBuf,
canonical_root: PathBuf,
cache: Arc<RwLock<HashMap<PathBuf, Vec<u8>>>>,
}
impl SandboxedFileProvider {
pub fn new(pack_root: impl AsRef<Path>) -> Result<Self> {
let root = pack_root.as_ref().to_path_buf();
if !root.exists() {
return Err(CoreError::FileAccess {
path: root.display().to_string(),
message: "pack root directory does not exist".to_string(),
});
}
let canonical_root = root.canonicalize().map_err(|e| CoreError::FileAccess {
path: root.display().to_string(),
message: format!("failed to canonicalize pack root: {}", e),
})?;
Ok(Self {
root,
canonical_root,
cache: Arc::new(RwLock::new(HashMap::new())),
})
}
fn resolve_path(&self, relative: &str) -> Result<PathBuf> {
let requested = Path::new(relative);
if requested.is_absolute() {
return Err(CoreError::FileAccess {
path: relative.to_string(),
message: "absolute paths are not allowed in templates".to_string(),
});
}
if relative.contains("..") {
}
let full_path = self.root.join(relative);
if !full_path.exists() {
return Err(CoreError::FileAccess {
path: relative.to_string(),
message: "file not found".to_string(),
});
}
let canonical = full_path
.canonicalize()
.map_err(|e| CoreError::FileAccess {
path: relative.to_string(),
message: format!("failed to resolve path: {}", e),
})?;
if !canonical.starts_with(&self.canonical_root) {
return Err(CoreError::FileAccess {
path: relative.to_string(),
message: "path escapes pack directory (sandbox violation)".to_string(),
});
}
Ok(canonical)
}
fn is_valid_path(&self, relative: &str) -> bool {
self.resolve_path(relative).is_ok()
}
}
impl FileProvider for SandboxedFileProvider {
fn get(&self, path: &str) -> Result<Vec<u8>> {
let resolved = self.resolve_path(path)?;
{
let cache = self.cache.read().map_err(|_| CoreError::FileAccess {
path: path.to_string(),
message: "cache lock poisoned".to_string(),
})?;
if let Some(content) = cache.get(&resolved) {
return Ok(content.clone());
}
}
let content = std::fs::read(&resolved).map_err(|e| CoreError::FileAccess {
path: path.to_string(),
message: format!("failed to read file: {}", e),
})?;
{
let mut cache = self.cache.write().map_err(|_| CoreError::FileAccess {
path: path.to_string(),
message: "cache lock poisoned".to_string(),
})?;
cache.insert(resolved, content.clone());
}
Ok(content)
}
fn exists(&self, path: &str) -> bool {
self.is_valid_path(path)
}
fn glob(&self, pattern: &str) -> Result<Vec<FileEntry>> {
let glob_pattern = glob::Pattern::new(pattern).map_err(|e| CoreError::GlobPattern {
message: format!("invalid glob pattern '{}': {}", pattern, e),
})?;
let mut entries = Vec::new();
for entry in walkdir::WalkDir::new(&self.root)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
{
let rel_path = match entry.path().strip_prefix(&self.root) {
Ok(p) => p,
Err(_) => continue,
};
let rel_str = rel_path.to_string_lossy();
if glob_pattern.matches(&rel_str) {
let content = match std::fs::read_to_string(entry.path()) {
Ok(c) => c,
Err(_) => {
match std::fs::read(entry.path()) {
Ok(bytes) => String::from_utf8_lossy(&bytes).to_string(),
Err(_) => continue,
}
}
};
let size = content.len();
entries.push(FileEntry {
path: rel_str.to_string(),
name: entry.file_name().to_string_lossy().to_string(),
content,
size,
});
}
}
entries.sort_by(|a, b| a.path.cmp(&b.path));
Ok(entries)
}
fn lines(&self, path: &str) -> Result<Vec<String>> {
let content = self.get_string(path)?;
Ok(content.lines().map(String::from).collect())
}
}
#[derive(Debug, Default, Clone)]
pub struct MockFileProvider {
files: HashMap<String, Vec<u8>>,
}
impl MockFileProvider {
pub fn new() -> Self {
Self::default()
}
pub fn with_file(mut self, path: &str, content: impl Into<Vec<u8>>) -> Self {
self.files.insert(path.to_string(), content.into());
self
}
pub fn with_text_file(self, path: &str, content: &str) -> Self {
self.with_file(path, content.as_bytes().to_vec())
}
pub fn with_files(
mut self,
files: impl IntoIterator<Item = (&'static str, &'static str)>,
) -> Self {
for (path, content) in files {
self.files
.insert(path.to_string(), content.as_bytes().to_vec());
}
self
}
}
impl FileProvider for MockFileProvider {
fn get(&self, path: &str) -> Result<Vec<u8>> {
self.files
.get(path)
.cloned()
.ok_or_else(|| CoreError::FileAccess {
path: path.to_string(),
message: "file not found".to_string(),
})
}
fn exists(&self, path: &str) -> bool {
self.files.contains_key(path)
}
fn glob(&self, pattern: &str) -> Result<Vec<FileEntry>> {
let glob_pattern = glob::Pattern::new(pattern).map_err(|e| CoreError::GlobPattern {
message: format!("invalid glob pattern '{}': {}", pattern, e),
})?;
let mut entries: Vec<_> = self
.files
.iter()
.filter(|(path, _)| glob_pattern.matches(path))
.map(|(path, content)| {
let name = Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
FileEntry {
path: path.clone(),
name,
content: String::from_utf8_lossy(content).to_string(),
size: content.len(),
}
})
.collect();
entries.sort_by(|a, b| a.path.cmp(&b.path));
Ok(entries)
}
fn lines(&self, path: &str) -> Result<Vec<String>> {
let content = self.get_string(path)?;
Ok(content.lines().map(String::from).collect())
}
}
#[derive(Clone)]
pub struct Files {
provider: Arc<dyn FileProvider>,
}
impl Files {
pub fn new(provider: impl FileProvider + 'static) -> Self {
Self {
provider: Arc::new(provider),
}
}
pub fn from_arc(provider: Arc<dyn FileProvider>) -> Self {
Self { provider }
}
pub fn for_pack(pack_root: impl AsRef<Path>) -> Result<Self> {
let provider = SandboxedFileProvider::new(pack_root)?;
Ok(Self::new(provider))
}
pub fn mock() -> MockFileProvider {
MockFileProvider::new()
}
pub fn get(&self, path: &str) -> Result<String> {
self.provider.get_string(path)
}
pub fn get_bytes(&self, path: &str) -> Result<Vec<u8>> {
self.provider.get(path)
}
pub fn exists(&self, path: &str) -> bool {
self.provider.exists(path)
}
pub fn glob(&self, pattern: &str) -> Result<Vec<FileEntry>> {
self.provider.glob(pattern)
}
pub fn lines(&self, path: &str) -> Result<Vec<String>> {
self.provider.lines(path)
}
}
impl std::fmt::Debug for Files {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Files").finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_pack() -> TempDir {
let temp = TempDir::new().unwrap();
std::fs::create_dir_all(temp.path().join("config")).unwrap();
std::fs::create_dir_all(temp.path().join("scripts")).unwrap();
std::fs::write(temp.path().join("config/app.yaml"), "key: value").unwrap();
std::fs::write(temp.path().join("config/db.yaml"), "host: localhost").unwrap();
std::fs::write(
temp.path().join("scripts/init.sh"),
"#!/bin/bash\necho hello",
)
.unwrap();
std::fs::write(temp.path().join("README.md"), "# Test Pack").unwrap();
temp
}
#[test]
fn test_sandboxed_provider_read_file() {
let temp = create_test_pack();
let provider = SandboxedFileProvider::new(temp.path()).unwrap();
let content = provider.get_string("config/app.yaml").unwrap();
assert_eq!(content, "key: value");
}
#[test]
fn test_sandboxed_provider_exists() {
let temp = create_test_pack();
let provider = SandboxedFileProvider::new(temp.path()).unwrap();
assert!(provider.exists("config/app.yaml"));
assert!(provider.exists("README.md"));
assert!(!provider.exists("nonexistent.txt"));
}
#[test]
fn test_sandboxed_provider_glob() {
let temp = create_test_pack();
let provider = SandboxedFileProvider::new(temp.path()).unwrap();
let entries = provider.glob("config/*.yaml").unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].name, "app.yaml");
assert_eq!(entries[1].name, "db.yaml");
}
#[test]
fn test_sandboxed_provider_lines() {
let temp = create_test_pack();
let provider = SandboxedFileProvider::new(temp.path()).unwrap();
let lines = provider.lines("scripts/init.sh").unwrap();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0], "#!/bin/bash");
assert_eq!(lines[1], "echo hello");
}
#[test]
fn test_sandbox_prevents_absolute_paths() {
let temp = create_test_pack();
let provider = SandboxedFileProvider::new(temp.path()).unwrap();
#[cfg(windows)]
let abs_path = "C:\\Windows\\System32\\config\\SAM";
#[cfg(not(windows))]
let abs_path = "/etc/passwd";
let result = provider.get(abs_path);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("absolute paths"));
}
#[test]
fn test_sandbox_prevents_path_traversal() {
let temp = create_test_pack();
let provider = SandboxedFileProvider::new(temp.path()).unwrap();
let parent = temp.path().parent().unwrap();
std::fs::write(parent.join("secret.txt"), "secret data").unwrap();
let result = provider.get("../secret.txt");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("sandbox") || err.contains("not found"));
}
#[test]
fn test_sandbox_prevents_deep_traversal() {
let temp = create_test_pack();
let provider = SandboxedFileProvider::new(temp.path()).unwrap();
let result = provider.get("config/../../../../../../etc/passwd");
assert!(result.is_err());
}
#[test]
fn test_mock_provider() {
let provider = MockFileProvider::new()
.with_text_file("config/app.yaml", "key: value")
.with_text_file("config/db.yaml", "host: localhost");
assert!(provider.exists("config/app.yaml"));
assert!(!provider.exists("nonexistent.txt"));
let content = provider.get_string("config/app.yaml").unwrap();
assert_eq!(content, "key: value");
}
#[test]
fn test_mock_provider_glob() {
let provider = MockFileProvider::new()
.with_text_file("config/a.yaml", "a")
.with_text_file("config/b.yaml", "b")
.with_text_file("other/c.yaml", "c");
let entries = provider.glob("config/*.yaml").unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].path, "config/a.yaml");
assert_eq!(entries[1].path, "config/b.yaml");
}
#[test]
fn test_files_wrapper() {
let mock = MockFileProvider::new().with_text_file("test.txt", "hello world");
let files = Files::new(mock);
assert!(files.exists("test.txt"));
assert_eq!(files.get("test.txt").unwrap(), "hello world");
}
#[test]
fn test_glob_deterministic_order() {
let provider = MockFileProvider::new()
.with_text_file("z.yaml", "z")
.with_text_file("a.yaml", "a")
.with_text_file("m.yaml", "m");
let entries = provider.glob("*.yaml").unwrap();
let paths: Vec<_> = entries.iter().map(|e| e.path.as_str()).collect();
assert_eq!(paths, vec!["a.yaml", "m.yaml", "z.yaml"]);
}
#[test]
fn test_file_caching() {
let temp = create_test_pack();
let provider = SandboxedFileProvider::new(temp.path()).unwrap();
let content1 = provider.get("config/app.yaml").unwrap();
std::fs::write(temp.path().join("config/app.yaml"), "modified").unwrap();
let content2 = provider.get("config/app.yaml").unwrap();
assert_eq!(content1, content2);
}
#[test]
fn test_binary_file_handling() {
let temp = TempDir::new().unwrap();
let binary_data = vec![0u8, 1, 2, 255, 254, 253];
std::fs::write(temp.path().join("binary.bin"), &binary_data).unwrap();
let provider = SandboxedFileProvider::new(temp.path()).unwrap();
let content = provider.get("binary.bin").unwrap();
assert_eq!(content, binary_data);
}
#[test]
fn test_glob_pattern_validation() {
let provider = MockFileProvider::new();
let result = provider.glob("[invalid");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("invalid glob pattern")
);
}
}