use super::error::WasmError;
const MAX_PATH_LEN: usize = 4096;
#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
pub struct VirtualFile {
path: String,
content: String,
}
impl<'de> serde::Deserialize<'de> for VirtualFile {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(serde::Deserialize)]
struct Raw {
path: String,
content: String,
}
let raw = Raw::deserialize(deserializer)?;
validate_path(&raw.path).map_err(serde::de::Error::custom)?;
Ok(Self {
path: raw.path,
content: raw.content,
})
}
}
impl VirtualFile {
pub fn new(path: impl Into<String>, content: impl Into<String>) -> super::Result<Self> {
let path = path.into();
validate_path(&path)?;
Ok(Self {
path,
content: content.into(),
})
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "reserved for benchmarks and future internal use")
)]
#[must_use]
pub(crate) fn new_unchecked(path: impl Into<String>, content: impl Into<String>) -> Self {
Self {
path: path.into(),
content: content.into(),
}
}
#[must_use]
pub fn path(&self) -> &str {
&self.path
}
#[must_use]
pub fn content(&self) -> &str {
&self.content
}
#[must_use]
pub fn file_name(&self) -> &str {
self.path
.rsplit_once('/')
.map_or(self.path.as_str(), |(_, name)| name)
}
#[must_use]
pub fn extension(&self) -> Option<&str> {
let (stem, ext) = self.file_name().rsplit_once('.')?;
if stem.is_empty() || ext.is_empty() {
None
} else {
Some(ext)
}
}
#[must_use]
pub fn is_dotfile(&self) -> bool {
self.normalized_path()
.split('/')
.any(|component| component.starts_with('.'))
}
#[must_use]
pub fn normalized_path(&self) -> &str {
self.path.strip_prefix("./").unwrap_or(&self.path)
}
}
fn validate_path(path: &str) -> super::Result<()> {
if path.is_empty() {
return Err(WasmError::InvalidPath("path is empty".into()));
}
if path.len() > MAX_PATH_LEN {
return Err(WasmError::InvalidPath(format!(
"path exceeds {MAX_PATH_LEN} bytes: {}",
path.len()
)));
}
if path.starts_with('/') {
return Err(WasmError::InvalidPath(
"path must be relative (no leading '/')".into(),
));
}
if path.bytes().any(|b| b.is_ascii_control() && b != b'\t') {
return Err(WasmError::InvalidPath(
"path contains null byte or control character".into(),
));
}
if path.contains('\\') {
return Err(WasmError::InvalidPath(
"path contains backslash — use '/' as separator".into(),
));
}
for component in path.split('/') {
if component == ".." {
return Err(WasmError::InvalidPath(
"path contains '..' traversal component".into(),
));
}
if component.is_empty() {
return Err(WasmError::InvalidPath(
"path contains empty component (consecutive or trailing '/')".into(),
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_path() {
let f = VirtualFile::new("src/main.rs", "fn main() {}").unwrap();
assert_eq!(f.path(), "src/main.rs");
assert_eq!(f.content(), "fn main() {}");
assert_eq!(f.file_name(), "main.rs");
assert_eq!(f.extension(), Some("rs"));
assert!(!f.is_dotfile());
}
#[test]
fn rejects_empty_path() {
assert!(VirtualFile::new("", "content").is_err());
}
#[test]
fn rejects_null_byte() {
assert!(VirtualFile::new("src/\0bad.rs", "").is_err());
}
#[test]
fn rejects_control_chars() {
assert!(VirtualFile::new("src/\x01bad.rs", "").is_err());
}
#[test]
fn rejects_newline_in_path() {
assert!(VirtualFile::new("src/\nbad.rs", "").is_err());
}
#[test]
fn allows_tab_in_path() {
assert!(VirtualFile::new("src/\tok.rs", "").is_ok());
}
#[test]
fn rejects_overly_long_path() {
let long = "a/".repeat(MAX_PATH_LEN);
assert!(VirtualFile::new(long, "").is_err());
}
#[test]
fn rejects_path_traversal() {
assert!(VirtualFile::new("../etc/passwd", "").is_err());
assert!(VirtualFile::new("src/../../etc/passwd", "").is_err());
assert!(VirtualFile::new("src/..", "").is_err());
}
#[test]
fn allows_double_dot_in_filename() {
assert!(VirtualFile::new("src/..foo", "").is_ok());
assert!(VirtualFile::new("src/foo..bar", "").is_ok());
}
#[test]
fn rejects_backslash_path() {
assert!(VirtualFile::new("src\\main.rs", "").is_err());
assert!(VirtualFile::new("src\\lib\\main.rs", "").is_err());
}
#[test]
fn rejects_absolute_path() {
assert!(VirtualFile::new("/etc/passwd", "").is_err());
assert!(VirtualFile::new("/", "").is_err());
assert!(VirtualFile::new("/src/main.rs", "").is_err());
}
#[test]
fn rejects_consecutive_slashes() {
assert!(VirtualFile::new("src//main.rs", "").is_err());
assert!(VirtualFile::new("a///b", "").is_err());
}
#[test]
fn rejects_trailing_slash() {
assert!(VirtualFile::new("src/", "").is_err());
assert!(VirtualFile::new("src/lib/", "").is_err());
}
#[test]
fn extension_basic() {
assert_eq!(
VirtualFile::new_unchecked("main.rs", "").extension(),
Some("rs")
);
}
#[test]
fn extension_multiple_dots() {
assert_eq!(
VirtualFile::new_unchecked("archive.tar.gz", "").extension(),
Some("gz")
);
}
#[test]
fn extension_dotfile_returns_none() {
assert_eq!(
VirtualFile::new_unchecked(".gitignore", "").extension(),
None
);
}
#[test]
fn extension_trailing_dot_returns_none() {
assert_eq!(
VirtualFile::new_unchecked("Makefile.", "").extension(),
None
);
}
#[test]
fn extension_no_dot() {
assert_eq!(VirtualFile::new_unchecked("Makefile", "").extension(), None);
}
#[test]
fn extension_ignores_dots_in_directory_components() {
assert_eq!(
VirtualFile::new_unchecked("src/my.module/Makefile", "").extension(),
None
);
assert_eq!(
VirtualFile::new_unchecked("src/my.module/lib.rs", "").extension(),
Some("rs")
);
}
#[test]
fn dotfile_root() {
assert!(VirtualFile::new_unchecked(".gitignore", "").is_dotfile());
}
#[test]
fn dotfile_nested() {
assert!(VirtualFile::new_unchecked("src/.hidden/file.rs", "").is_dotfile());
}
#[test]
fn dotfile_dot_slash_prefix_is_not_dotfile() {
assert!(!VirtualFile::new_unchecked("./src/main.rs", "").is_dotfile());
}
#[test]
fn dotfile_dot_slash_with_actual_dotfile() {
assert!(VirtualFile::new_unchecked("./.gitignore", "").is_dotfile());
}
#[test]
fn not_dotfile() {
assert!(!VirtualFile::new_unchecked("src/main.rs", "").is_dotfile());
}
#[test]
fn normalized_path_strips_dot_slash() {
let f = VirtualFile::new_unchecked("./src/main.rs", "");
assert_eq!(f.normalized_path(), "src/main.rs");
}
#[test]
fn normalized_path_noop_without_prefix() {
let f = VirtualFile::new_unchecked("src/main.rs", "");
assert_eq!(f.normalized_path(), "src/main.rs");
}
#[test]
fn ordering_is_lexicographic_by_path() {
let a = VirtualFile::new_unchecked("a.rs", "");
let b = VirtualFile::new_unchecked("b.rs", "");
let c = VirtualFile::new_unchecked("src/c.rs", "");
let mut files = vec![c.clone(), a.clone(), b.clone()];
files.sort();
assert_eq!(files, vec![a, b, c]);
}
#[test]
fn ord_consistent_with_eq() {
let a = VirtualFile::new_unchecked("same.rs", "content a");
let b = VirtualFile::new_unchecked("same.rs", "content b");
if a.cmp(&b) == std::cmp::Ordering::Equal {
assert_eq!(a, b);
} else {
assert_ne!(a, b);
}
}
#[test]
fn hash_consistent_with_eq() {
use std::hash::{DefaultHasher, Hash, Hasher};
let a = VirtualFile::new_unchecked("src/main.rs", "fn main() {}");
let b = VirtualFile::new_unchecked("src/main.rs", "fn main() {}");
assert_eq!(a, b);
let hash = |v: &VirtualFile| {
let mut h = DefaultHasher::new();
v.hash(&mut h);
h.finish()
};
assert_eq!(hash(&a), hash(&b));
}
#[test]
fn serde_round_trip() {
let original = VirtualFile::new("src/main.rs", "fn main() {}").unwrap();
let json = serde_json::to_string(&original).unwrap();
let recovered: VirtualFile = serde_json::from_str(&json).unwrap();
assert_eq!(original, recovered);
}
#[test]
fn deserialize_validates_empty_path() {
let json = r#"{"path": "", "content": "bad"}"#;
let result: Result<VirtualFile, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn deserialize_rejects_null_byte() {
let json = r#"{"path": "src/\u0000bad.rs", "content": ""}"#;
let result: Result<VirtualFile, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn deserialize_rejects_path_traversal() {
let json = r#"{"path": "../etc/passwd", "content": "root:x:0:0"}"#;
let result: Result<VirtualFile, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn deserialize_rejects_backslash() {
let json = r#"{"path": "src\\main.rs", "content": ""}"#;
let result: Result<VirtualFile, _> = serde_json::from_str(json);
assert!(result.is_err());
}
}