/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ use crate::std::mock::{mock_key, MockKey}; use std::collections::HashMap; use std::ffi::OsString; use std::io::{ErrorKind, Read, Result, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::SystemTime; /// Mock filesystem file content. #[derive(Debug, Default, Clone)] pub struct MockFileContent(Arc>>); impl MockFileContent { pub fn empty() -> Self { Self::default() } pub fn new(data: String) -> Self { Self::new_bytes(data.into()) } pub fn new_bytes(data: Vec) -> Self { MockFileContent(Arc::new(Mutex::new(data))) } } impl From<()> for MockFileContent { fn from(_: ()) -> Self { Self::empty() } } impl From for MockFileContent { fn from(s: String) -> Self { Self::new(s) } } impl From<&str> for MockFileContent { fn from(s: &str) -> Self { Self::new(s.to_owned()) } } impl From> for MockFileContent { fn from(bytes: Vec) -> Self { Self::new_bytes(bytes) } } impl From<&[u8]> for MockFileContent { fn from(bytes: &[u8]) -> Self { Self::new_bytes(bytes.to_owned()) } } /// Mocked filesystem directory entries. pub type MockDirEntries = HashMap; /// The content of a mock filesystem item. pub enum MockFSContent { /// File content. File(Result), /// A directory with the given entries. Dir(MockDirEntries), } impl std::fmt::Debug for MockFSContent { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { Self::File(_) => f.debug_tuple("File").finish(), Self::Dir(e) => f.debug_tuple("Dir").field(e).finish(), } } } /// A mock filesystem item. #[derive(Debug)] pub struct MockFSItem { /// The content of the item (file/dir). pub content: MockFSContent, /// The modification time of the item. pub modified: SystemTime, } impl From for MockFSItem { fn from(content: MockFSContent) -> Self { MockFSItem { content, modified: SystemTime::UNIX_EPOCH, } } } /// A mock filesystem. #[derive(Debug, Clone)] pub struct MockFiles { root: Arc>, } impl Default for MockFiles { fn default() -> Self { MockFiles { root: Arc::new(Mutex::new(MockFSContent::Dir(Default::default()).into())), } } } impl MockFiles { /// Create a new, empty filesystem. pub fn new() -> Self { Self::default() } /// Add a mocked file with the given content. The modification time will be the unix epoch. /// /// Pancis if the parent directory is not already mocked. pub fn add_file, C: Into>(&self, path: P, content: C) -> &Self { self.add_file_result(path, Ok(content.into()), SystemTime::UNIX_EPOCH) } /// Add a mocked directory. pub fn add_dir>(&self, path: P) -> &Self { self.path(path, true, |_| ()).unwrap(); self } /// Add a mocked file that returns the given result and has the given modification time. /// /// Pancis if the parent directory is not already mocked. pub fn add_file_result>( &self, path: P, result: Result, modified: SystemTime, ) -> &Self { let name = path.as_ref().file_name().expect("invalid path"); self.parent_dir(path.as_ref(), move |dir| { if dir.contains_key(name) { Err(ErrorKind::AlreadyExists.into()) } else { dir.insert( name.to_owned(), MockFSItem { content: MockFSContent::File(result), modified, }, ); Ok(()) } }) .and_then(|r| r) .unwrap(); self } /// If create_dirs is true, all missing path components (_including the final component_) are /// created as directories. In this case `Err` is only returned if a file conflicts with /// a directory component. pub fn path, F, R>(&self, path: P, create_dirs: bool, f: F) -> Result where F: FnOnce(&mut MockFSItem) -> R, { let mut guard = self.root.lock().unwrap(); let mut cur_entry = &mut *guard; for component in path.as_ref().components() { use std::path::Component::*; match component { CurDir | RootDir | Prefix(_) => continue, ParentDir => panic!("unsupported path: {}", path.as_ref().display()), Normal(name) => { let cur_dir = match &mut cur_entry.content { MockFSContent::File(_) => return Err(ErrorKind::NotFound.into()), MockFSContent::Dir(d) => d, }; cur_entry = if create_dirs { cur_dir .entry(name.to_owned()) .or_insert_with(|| MockFSContent::Dir(Default::default()).into()) } else { cur_dir.get_mut(name).ok_or(ErrorKind::NotFound)? }; } } } Ok(f(cur_entry)) } /// Get the mocked parent directory of the given path and call a callback on the mocked /// directory's entries. pub fn parent_dir, F, R>(&self, path: P, f: F) -> Result where F: FnOnce(&mut MockDirEntries) -> R, { self.path( path.as_ref().parent().unwrap_or(&Path::new("")), false, move |item| match &mut item.content { MockFSContent::File(_) => Err(ErrorKind::NotFound.into()), MockFSContent::Dir(d) => Ok(f(d)), }, ) .and_then(|r| r) } /// Return a file assertion helper for the mocked filesystem. pub fn assert_files(&self) -> AssertFiles { let mut files = HashMap::new(); let root = self.root.lock().unwrap(); fn dir(files: &mut HashMap, path: &Path, item: &MockFSItem) { match &item.content { MockFSContent::File(Ok(c)) => { files.insert(path.to_owned(), c.clone()); } MockFSContent::Dir(d) => { for (component, item) in d { dir(files, &path.join(component), item); } } _ => (), } } dir(&mut files, Path::new(""), &*root); AssertFiles { files } } } /// A utility for asserting the state of the mocked filesystem. /// /// All files must be accounted for; when dropped, a panic will occur if some files remain which /// weren't checked. #[derive(Debug)] pub struct AssertFiles { files: HashMap, } // On windows we ignore drive prefixes. This is only relevant for real paths, which are only // present for edge case situations in tests (where AssertFiles is used). fn remove_prefix(p: &Path) -> &Path { let mut iter = p.components(); if let Some(std::path::Component::Prefix(_)) = iter.next() { iter.next(); // Prefix is followed by RootDir iter.as_path() } else { p } } impl AssertFiles { /// Assert that the given path contains the given content (as a utf8 string). pub fn check, S: AsRef>(&mut self, path: P, content: S) -> &mut Self { let p = remove_prefix(path.as_ref()); let Some(mfc) = self.files.remove(p) else { panic!("missing file: {}", p.display()); }; let guard = mfc.0.lock().unwrap(); assert_eq!( std::str::from_utf8(&*guard).unwrap(), content.as_ref(), "file content mismatch: {}", p.display() ); self } /// Assert that the given path contains the given byte content. pub fn check_bytes, B: AsRef<[u8]>>( &mut self, path: P, content: B, ) -> &mut Self { let p = remove_prefix(path.as_ref()); let Some(mfc) = self.files.remove(p) else { panic!("missing file: {}", p.display()); }; let guard = mfc.0.lock().unwrap(); assert_eq!( &*guard, content.as_ref(), "file content mismatch: {}", p.display() ); self } /// Ignore the given file (whether it exists or not). pub fn ignore>(&mut self, path: P) -> &mut Self { self.files.remove(remove_prefix(path.as_ref())); self } /// Assert that the given path exists without checking its content. pub fn check_exists>(&mut self, path: P) -> &mut Self { let p = remove_prefix(path.as_ref()); if self.files.remove(p).is_none() { panic!("missing file: {}", p.display()); } self } /// Finish checking files. /// /// This panics if all files were not checked. /// /// This is also called when the value is dropped. pub fn finish(&mut self) { let files = std::mem::take(&mut self.files); if !files.is_empty() { panic!("additional files not expected: {:?}", files.keys()); } } } impl Drop for AssertFiles { fn drop(&mut self) { if !std::thread::panicking() { self.finish(); } } } mock_key! { pub struct MockFS => MockFiles } pub struct File { content: MockFileContent, pos: usize, } impl File { pub fn open>(path: P) -> Result { MockFS.get(move |files| { files .path(path, false, |item| match &item.content { MockFSContent::File(result) => result .as_ref() .map(|b| File { content: b.clone(), pos: 0, }) .map_err(|e| e.kind().into()), MockFSContent::Dir(_) => Err(ErrorKind::NotFound.into()), }) .and_then(|r| r) }) } pub fn create>(path: P) -> Result { let path = path.as_ref(); MockFS.get(|files| { let name = path.file_name().expect("invalid path"); files.parent_dir(path, move |d| { if !d.contains_key(name) { d.insert( name.to_owned(), MockFSItem { content: MockFSContent::File(Ok(Default::default())), modified: super::time::SystemTime::now().0, }, ); } }) })?; Self::open(path) } } impl Read for File { fn read(&mut self, buf: &mut [u8]) -> Result { let guard = self.content.0.lock().unwrap(); if self.pos >= guard.len() { return Ok(0); } let to_read = std::cmp::min(buf.len(), guard.len() - self.pos); buf[..to_read].copy_from_slice(&guard[self.pos..self.pos + to_read]); self.pos += to_read; Ok(to_read) } } impl Seek for File { fn seek(&mut self, pos: SeekFrom) -> Result { let len = self.content.0.lock().unwrap().len(); match pos { SeekFrom::Start(n) => self.pos = n as usize, SeekFrom::End(n) => { if n < 0 { let offset = -n as usize; if offset > len { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "out of bounds", )); } self.pos = len - offset; } else { self.pos = len + n as usize } } SeekFrom::Current(n) => { if n < 0 { let offset = -n as usize; if offset > self.pos { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "out of bounds", )); } self.pos -= offset; } else { self.pos += n as usize; } } } Ok(self.pos as u64) } } impl Write for File { fn write(&mut self, buf: &[u8]) -> Result { let mut guard = self.content.0.lock().unwrap(); let end = self.pos + buf.len(); if end > guard.len() { guard.resize(end, 0); } (&mut guard[self.pos..end]).copy_from_slice(buf); self.pos = end; Ok(buf.len()) } fn flush(&mut self) -> Result<()> { Ok(()) } } pub fn create_dir_all>(path: P) -> Result<()> { MockFS.get(move |files| files.path(path, true, |_| ())) } pub fn rename, Q: AsRef>(from: P, to: Q) -> Result<()> { MockFS.get(move |files| { let from_name = from.as_ref().file_name().expect("invalid path"); let item = files .parent_dir(from.as_ref(), move |d| { d.remove(from_name).ok_or(ErrorKind::NotFound.into()) }) .and_then(|r| r)?; let to_name = to.as_ref().file_name().expect("invalid path"); files .parent_dir(to.as_ref(), move |d| { // Just error if `to` exists, which doesn't quite follow `std::fs::rename` behavior. if d.contains_key(to_name) { Err(ErrorKind::AlreadyExists.into()) } else { d.insert(to_name.to_owned(), item); Ok(()) } }) .and_then(|r| r) }) } pub fn remove_file>(path: P) -> Result<()> { MockFS.get(move |files| { let name = path.as_ref().file_name().expect("invalid path"); files .parent_dir(path.as_ref(), |d| { if let Some(MockFSItem { content: MockFSContent::Dir(_), .. }) = d.get(name) { Err(ErrorKind::NotFound.into()) } else { d.remove(name).ok_or(ErrorKind::NotFound.into()).map(|_| ()) } }) .and_then(|r| r) }) } pub fn write, C: AsRef<[u8]>>(path: P, contents: C) -> Result<()> { File::create(path.as_ref())?.write_all(contents.as_ref()) } pub struct ReadDir { base: PathBuf, children: Vec, } impl ReadDir { pub fn new(path: &Path) -> Result { MockFS.get(move |files| { files .path(path, false, |item| match &item.content { MockFSContent::Dir(d) => Ok(ReadDir { base: path.to_owned(), children: d.keys().cloned().collect(), }), MockFSContent::File(_) => Err(ErrorKind::NotFound.into()), }) .and_then(|r| r) }) } } impl Iterator for ReadDir { type Item = Result; fn next(&mut self) -> Option { let child = self.children.pop()?; Some(Ok(DirEntry(self.base.join(child)))) } } pub struct DirEntry(PathBuf); impl DirEntry { pub fn path(&self) -> super::path::PathBuf { super::path::PathBuf(self.0.clone()) } pub fn metadata(&self) -> Result { MockFS.get(|files| { files.path(&self.0, false, |item| { let is_dir = matches!(&item.content, MockFSContent::Dir(_)); Metadata { is_dir, modified: item.modified, } }) }) } } pub struct Metadata { is_dir: bool, modified: SystemTime, } impl Metadata { pub fn is_file(&self) -> bool { !self.is_dir } pub fn is_dir(&self) -> bool { self.is_dir } pub fn modified(&self) -> Result { Ok(super::time::SystemTime(self.modified)) } }