use super::errors::ArchiveError;
use super::vfs_adapter::{DirEntry, VfsFileSystem};
use std::io::{Cursor, Read, Seek};
#[cfg(not(target_arch = "wasm32"))]
use std::path::Path;
use std::sync::Mutex;
use zip::ZipArchive;
#[derive(Debug)]
pub(super) struct ArchiveVfs<T> {
archive: Mutex<ZipArchive<T>>,
}
impl<T> ArchiveVfs<T>
where
T: Read + Seek,
{
pub(super) fn from_reader(reader: T) -> Result<Self, ArchiveError> {
let mut archive = ZipArchive::new(reader).map_err(|e| ArchiveError::InvalidZipFormat {
details: e.to_string(),
})?;
for i in 0..archive.len() {
let file = archive
.by_index(i)
.map_err(|e| ArchiveError::CorruptedEntry {
index: i,
details: e.to_string(),
})?;
let file_name = file.name();
if file_name.starts_with('/') || file_name.starts_with('\\') {
return Err(ArchiveError::PathTraversal {
path: file_name.to_string(),
});
}
if file_name.len() >= 2 {
let mut chars = file_name.chars();
if let (Some(first), Some(second)) = (chars.next(), chars.next())
&& first.is_ascii_alphabetic()
&& second == ':'
{
return Err(ArchiveError::PathTraversal {
path: file_name.to_string(),
});
}
}
let normalized = file.enclosed_name();
if let Some(normalized_path) = normalized {
let path_str = normalized_path.to_string_lossy();
if path_str.contains("..") {
return Err(ArchiveError::PathTraversal {
path: file_name.to_string(),
});
}
} else {
return Err(ArchiveError::PathTraversal {
path: file_name.to_string(),
});
}
}
Ok(Self {
archive: Mutex::new(archive),
})
}
}
impl ArchiveVfs<std::fs::File> {
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, ArchiveError> {
let path = path.as_ref();
if path.extension().and_then(|s| s.to_str()) != Some("cjar") {
return Err(ArchiveError::InvalidExtension {
expected: "cjar".to_string(),
found: path
.extension()
.and_then(|s| s.to_str())
.unwrap_or("(none)")
.to_string(),
});
}
let file = std::fs::File::open(path).map_err(|e| ArchiveError::CannotReadFile {
path: path.display().to_string(),
source: e,
})?;
Self::from_reader(file)
}
}
impl ArchiveVfs<Cursor<Vec<u8>>> {
pub(super) fn from_buffer(buffer: Vec<u8>) -> Result<Self, ArchiveError> {
let cursor = Cursor::new(buffer);
Self::from_reader(cursor)
}
}
impl<T> ArchiveVfs<T>
where
T: Read + Seek,
{
fn normalize_path(path: &str) -> String {
let path = path.trim_start_matches('/');
let path = path.strip_prefix("./").unwrap_or(path);
if path == "." || path.is_empty() {
String::new()
} else {
path.to_string()
}
}
fn path_exists(&self, path: &str) -> Result<bool, std::io::Error> {
let normalized = Self::normalize_path(path);
let mut archive = self
.archive
.lock()
.map_err(|e| std::io::Error::other(format!("archive mutex poisoned: {e}")))?;
if archive.by_name(&normalized).is_ok() {
return Ok(true);
}
let dir_prefix = if normalized.is_empty() {
String::new()
} else {
format!("{normalized}/")
};
for i in 0..archive.len() {
if let Ok(file) = archive.by_index(i) {
let file_name = file.name();
if file_name == normalized || file_name.starts_with(&dir_prefix) {
return Ok(true);
}
}
}
Ok(false)
}
#[cfg(test)]
pub(super) fn is_file(&self, path: &str) -> bool {
let normalized = Self::normalize_path(path);
let Ok(mut archive) = self.archive.lock() else {
return false;
};
if let Ok(file) = archive.by_name(&normalized) {
return file.is_file();
}
false
}
fn is_directory(&self, path: &str) -> Result<bool, std::io::Error> {
let normalized = Self::normalize_path(path);
let mut archive = self
.archive
.lock()
.map_err(|e| std::io::Error::other(format!("archive mutex poisoned: {e}")))?;
Ok(Self::is_directory_locked(&mut archive, &normalized))
}
fn is_directory_locked(archive: &mut ZipArchive<T>, normalized: &str) -> bool {
if normalized.is_empty() {
return true;
}
let dir_path_with_slash = format!("{normalized}/");
if let Ok(file) = archive.by_name(&dir_path_with_slash) {
return file.is_dir();
}
for i in 0..archive.len() {
if let Ok(file) = archive.by_index(i) {
let file_name = file.name();
if file_name.starts_with(&format!("{normalized}/")) {
return true;
}
}
}
false
}
}
impl<T> VfsFileSystem for ArchiveVfs<T>
where
T: Read + Seek + Send + Sync + 'static,
{
fn read_file(&self, path: &str) -> Result<Vec<u8>, std::io::Error> {
let normalized = Self::normalize_path(path);
let mut archive = self
.archive
.lock()
.map_err(|e| std::io::Error::other(format!("archive mutex poisoned: {e}")))?;
let mut file = archive.by_name(&normalized).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("File not found in archive: {path}: {e}"),
)
})?;
let mut contents = Vec::new();
file.read_to_end(&mut contents)?;
Ok(contents)
}
fn exists(&self, path: &str) -> bool {
self.path_exists(path).unwrap_or(false)
}
fn is_dir(&self, path: &str) -> bool {
self.is_directory(path).unwrap_or(false)
}
fn read_dir(&self, path: &str) -> Result<Vec<DirEntry>, std::io::Error> {
let normalized = Self::normalize_path(path);
let prefix = if normalized.is_empty() {
String::new()
} else {
format!("{normalized}/")
};
let mut archive = self
.archive
.lock()
.map_err(|e| std::io::Error::other(format!("archive mutex poisoned: {e}")))?;
let mut seen = std::collections::HashSet::new();
let mut entry_paths = Vec::new();
for i in 0..archive.len() {
let file = archive.by_index(i).map_err(|e| {
std::io::Error::other(format!("Failed to read archive entry {i}: {e}"))
})?;
let file_name = file.name();
if file_name.starts_with(&prefix) || (prefix.is_empty() && !file_name.contains('/')) {
let relative = if prefix.is_empty() {
file_name
} else {
&file_name[prefix.len()..]
};
let child_name = if let Some(slash_pos) = relative.find('/') {
&relative[..slash_pos]
} else {
relative
};
if child_name.is_empty() || !seen.insert(child_name.to_string()) {
continue;
}
let entry_path = if prefix.is_empty() {
child_name.to_string()
} else {
format!("{prefix}{child_name}")
};
entry_paths.push((child_name.to_string(), entry_path));
}
}
let mut entries = Vec::new();
for (name, entry_path) in entry_paths {
let entry_path_normalized = Self::normalize_path(&entry_path);
let is_directory = Self::is_directory_locked(&mut archive, &entry_path_normalized);
entries.push(DirEntry {
name,
path: entry_path,
is_dir: is_directory,
});
}
Ok(entries)
}
fn open_file(&self, path: &str) -> Result<Box<dyn Read + Send>, std::io::Error> {
let bytes = self.read_file(path)?;
Ok(Box::new(Cursor::new(bytes)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use zip::CompressionMethod;
use zip::write::{ExtendedFileOptions, FileOptions};
fn create_test_archive(files: Vec<(&str, &str)>) -> Vec<u8> {
let mut buffer = Vec::new();
{
let cursor = Cursor::new(&mut buffer);
let mut zip = zip::ZipWriter::new(cursor);
for (name, content) in files {
let options = FileOptions::<ExtendedFileOptions>::default()
.compression_method(CompressionMethod::Deflated);
zip.start_file(name, options).unwrap();
zip.write_all(content.as_bytes()).unwrap();
}
zip.finish().unwrap();
}
buffer
}
#[test]
fn test_from_buffer_valid_archive() {
let bytes = create_test_archive(vec![("metadata.json", "{}")]);
let _result = ArchiveVfs::from_buffer(bytes)
.expect("expect ArchiveVfs initialized correctly from buffer");
}
#[test]
fn test_from_buffer_invalid_zip() {
let bytes = b"This is not a ZIP file".to_vec();
let result = ArchiveVfs::from_buffer(bytes);
let err = result.expect_err("Expected InvalidZipFormat error for non-ZIP data");
assert!(
matches!(err, ArchiveError::InvalidZipFormat { .. }),
"Expected InvalidZipFormat error, got: {err:?}"
);
}
#[test]
fn test_from_buffer_path_traversal() {
let bytes = create_test_archive(vec![("../../../etc/passwd", "malicious")]);
let result = ArchiveVfs::from_buffer(bytes);
let err = result.expect_err("Expected PathTraversal error for malicious path");
assert!(
matches!(err, ArchiveError::PathTraversal { .. }),
"Expected PathTraversal error, got: {err:?}"
);
}
#[test]
fn test_read_file_success() {
let bytes = create_test_archive(vec![
("metadata.json", r#"{"version":"1.0"}"#),
("schema.cedarschema", "namespace Test;"),
]);
let vfs = ArchiveVfs::from_buffer(bytes).unwrap();
let content = vfs.read_file("metadata.json").unwrap();
assert_eq!(String::from_utf8(content).unwrap(), r#"{"version":"1.0"}"#);
let content = vfs.read_file("schema.cedarschema").unwrap();
assert_eq!(String::from_utf8(content).unwrap(), "namespace Test;");
}
#[test]
fn test_read_file_not_found() {
let bytes = create_test_archive(vec![("metadata.json", "{}")]);
let vfs = ArchiveVfs::from_buffer(bytes).unwrap();
let result = vfs.read_file("nonexistent.json");
let err = result.expect_err("Expected error for nonexistent file");
assert!(
matches!(err, std::io::Error { .. }),
"Expected IO error for file not found"
);
}
#[test]
fn test_exists() {
let bytes = create_test_archive(vec![
("metadata.json", "{}"),
("policies/policy1.cedar", "permit();"),
]);
let vfs = ArchiveVfs::from_buffer(bytes).unwrap();
assert!(vfs.exists("metadata.json"));
assert!(vfs.exists("policies/policy1.cedar"));
assert!(vfs.exists("policies")); assert!(!vfs.exists("nonexistent.json"));
}
#[test]
fn test_is_file() {
let bytes = create_test_archive(vec![
("metadata.json", "{}"),
("policies/policy1.cedar", "permit();"),
]);
let vfs = ArchiveVfs::from_buffer(bytes).unwrap();
assert!(vfs.is_file("metadata.json"));
assert!(vfs.is_file("policies/policy1.cedar"));
assert!(!vfs.is_file("policies"));
assert!(!vfs.is_file("nonexistent.json"));
}
#[test]
fn test_is_dir() {
let bytes = create_test_archive(vec![
("metadata.json", "{}"),
("policies/policy1.cedar", "permit();"),
("policies/policy2.cedar", "forbid();"),
]);
let vfs = ArchiveVfs::from_buffer(bytes).unwrap();
assert!(vfs.is_dir("."));
assert!(vfs.is_dir("policies"));
assert!(!vfs.is_dir("metadata.json"));
assert!(!vfs.is_dir("nonexistent"));
}
#[test]
fn test_read_dir_root() {
let bytes = create_test_archive(vec![
("metadata.json", "{}"),
("schema.cedarschema", "namespace Test;"),
("policies/policy1.cedar", "permit();"),
]);
let vfs = ArchiveVfs::from_buffer(bytes).unwrap();
let entries = vfs.read_dir(".").unwrap();
assert_eq!(entries.len(), 3);
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
assert!(names.contains(&"metadata.json"));
assert!(names.contains(&"schema.cedarschema"));
assert!(names.contains(&"policies"));
}
#[test]
fn test_read_dir_subdirectory() {
let bytes = create_test_archive(vec![
("policies/policy1.cedar", "permit();"),
("policies/policy2.cedar", "forbid();"),
("policies/nested/policy3.cedar", "deny();"),
]);
let vfs = ArchiveVfs::from_buffer(bytes).unwrap();
let entries = vfs.read_dir("policies").unwrap();
assert_eq!(entries.len(), 3);
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
assert!(names.contains(&"policy1.cedar"));
assert!(names.contains(&"policy2.cedar"));
assert!(names.contains(&"nested"));
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_from_file_path_invalid_extension() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let archive_path = temp_dir.path().join("test.zip");
let bytes = create_test_archive(vec![("metadata.json", "{}")]);
std::fs::write(&archive_path, bytes).unwrap();
let result = ArchiveVfs::from_file(&archive_path);
assert!(matches!(
result.expect_err("should fail"),
ArchiveError::InvalidExtension { .. }
));
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_from_file_path_success() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let archive_path = temp_dir.path().join("test.cjar");
let bytes = create_test_archive(vec![("metadata.json", "{}")]);
std::fs::write(&archive_path, bytes).unwrap();
ArchiveVfs::from_file(&archive_path).expect("should load valid .cjar file");
}
#[test]
fn test_complex_directory_structure() {
let bytes = create_test_archive(vec![
("metadata.json", "{}"),
("policies/allow/policy1.cedar", "permit();"),
("policies/allow/policy2.cedar", "permit();"),
("policies/deny/policy3.cedar", "forbid();"),
("entities/users/admin.json", "{}"),
("entities/users/regular.json", "{}"),
("entities/groups/admins.json", "{}"),
]);
let vfs = ArchiveVfs::from_buffer(bytes).unwrap();
let root_entries = vfs.read_dir(".").unwrap();
assert_eq!(root_entries.len(), 3);
let policies_entries = vfs.read_dir("policies").unwrap();
assert_eq!(policies_entries.len(), 2);
let allow_entries = vfs.read_dir("policies/allow").unwrap();
assert_eq!(allow_entries.len(), 2); }
}