1use std::collections::{BTreeMap, BTreeSet};
33use std::io::{BufRead, ErrorKind, Read, Write};
34use std::path::{Component, Path, PathBuf};
35use std::sync::atomic::{AtomicU64, Ordering};
36use std::sync::{Arc, Mutex, MutexGuard};
37
38use crate::error::RuntimeError;
39use crate::host::{HostApi, Reentry, ResourceHandle};
40use crate::schema::ModuleSchema;
41use crate::value::{Repr, Value};
42
43const MAX_LINE_BYTES: usize = 1024 * 1024;
53
54pub struct Files {
56 source: FileSource,
57 readers: Mutex<BTreeMap<u64, ReaderState>>,
59 writers: Mutex<BTreeMap<u64, WriterState>>,
61 next_id: AtomicU64,
71}
72
73struct ReaderState {
75 path: String,
78 form: ReaderForm,
79}
80
81enum ReaderForm {
84 Rooted(std::io::BufReader<std::fs::File>),
87 InMemory { contents: String, position: usize },
91}
92
93struct WriterState {
95 path: String,
98 form: WriterForm,
99}
100
101enum WriterForm {
103 Rooted(std::io::BufWriter<std::fs::File>),
105 InMemory { key: String },
109}
110
111enum FileSource {
112 Rooted(PathBuf),
114 InMemory(Arc<Mutex<BTreeMap<String, String>>>),
121}
122
123const SCHEMA: ModuleSchema = cove_schema::hosts::FILES;
129
130impl Files {
131 pub fn rooted(root: PathBuf) -> Self {
142 Files::with_source(FileSource::Rooted(root))
143 }
144
145 pub fn in_memory(files: BTreeMap<String, String>) -> Self {
151 Files::with_source(FileSource::InMemory(Arc::new(Mutex::new(files))))
152 }
153
154 pub fn tree(&self) -> Tree {
162 match &self.source {
163 FileSource::InMemory(files) => Tree(Arc::clone(files)),
164 FileSource::Rooted(_) => Tree(Arc::new(Mutex::new(BTreeMap::new()))),
165 }
166 }
167
168 fn with_source(source: FileSource) -> Self {
169 Files {
170 source,
171 readers: Mutex::new(BTreeMap::new()),
172 writers: Mutex::new(BTreeMap::new()),
173 next_id: AtomicU64::new(1),
174 }
175 }
176
177 fn read(&self, path: &str) -> Result<String, String> {
178 match &self.source {
179 FileSource::Rooted(root) => {
180 let full = rooted_path(root, path)?;
181 std::fs::read_to_string(&full).map_err(|e| read_error(path, &e))
182 }
183 FileSource::InMemory(files) => {
184 let key = relative_key(path)?;
185 stored(files)
186 .get(&key)
187 .cloned()
188 .ok_or_else(|| missing(path))
189 }
190 }
191 }
192
193 fn write(&self, path: &str, contents: &str) -> Result<(), String> {
194 match &self.source {
195 FileSource::Rooted(root) => {
196 relative_parts(path)?;
200 std::fs::create_dir_all(root)
205 .map_err(|e| format!("files: cannot create the root directory: {e}"))?;
206 let full = rooted_path(root, path)?;
207 if let Some(parent) = full.parent() {
211 std::fs::create_dir_all(parent)
212 .map_err(|e| format!("files: cannot write `{path}`: {e}"))?;
213 }
214 std::fs::write(&full, contents)
215 .map_err(|e| format!("files: cannot write `{path}`: {e}"))
216 }
217 FileSource::InMemory(files) => {
218 let key = relative_key(path)?;
219 if key.is_empty() {
220 return Err(format!("files: `{path}` is a directory"));
221 }
222 stored(files).insert(key, contents.to_string());
223 Ok(())
224 }
225 }
226 }
227
228 fn exists(&self, path: &str) -> bool {
235 match &self.source {
236 FileSource::Rooted(root) => match rooted_path(root, path) {
237 Ok(full) => full.exists(),
238 Err(_) => false,
239 },
240 FileSource::InMemory(files) => match relative_key(path) {
241 Ok(key) if key.is_empty() => true,
242 Ok(key) => {
243 let prefix = format!("{key}/");
244 let files = stored(files);
245 files.contains_key(&key) || files.keys().any(|k| k.starts_with(&prefix))
246 }
247 Err(_) => false,
248 },
249 }
250 }
251
252 fn list(&self, path: &str) -> Result<Vec<String>, String> {
258 match &self.source {
259 FileSource::Rooted(root) => {
260 let full = rooted_path(root, path)?;
261 let entries = std::fs::read_dir(&full).map_err(|e| read_error(path, &e))?;
262 let mut names = BTreeSet::new();
263 for entry in entries {
264 let entry = entry.map_err(|e| format!("files: cannot list `{path}`: {e}"))?;
265 names.insert(entry.file_name().to_string_lossy().into_owned());
266 }
267 Ok(names.into_iter().collect())
268 }
269 FileSource::InMemory(files) => {
270 let key = relative_key(path)?;
271 let depth = if key.is_empty() {
272 0
273 } else {
274 key.split('/').count()
275 };
276 let mut names = BTreeSet::new();
277 for path in stored(files).keys() {
278 let parts: Vec<&str> = path.split('/').collect();
279 if parts.len() <= depth {
280 continue;
281 }
282 if !key.is_empty() && parts[..depth].join("/") != key {
283 continue;
284 }
285 names.insert(parts[depth].to_string());
286 }
287 if names.is_empty() && !key.is_empty() {
288 return Err(missing(path));
289 }
290 Ok(names.into_iter().collect())
291 }
292 }
293 }
294
295 fn open(&self, path: &str) -> Result<Value, String> {
301 let form = match &self.source {
302 FileSource::Rooted(root) => {
303 let full = rooted_path(root, path)?;
304 let file = std::fs::File::open(&full).map_err(|e| read_error(path, &e))?;
305 ReaderForm::Rooted(std::io::BufReader::new(file))
306 }
307 FileSource::InMemory(files) => {
308 let key = relative_key(path)?;
309 let contents = stored(files)
310 .get(&key)
311 .cloned()
312 .ok_or_else(|| missing(path))?;
313 ReaderForm::InMemory {
314 contents,
315 position: 0,
316 }
317 }
318 };
319 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
320 self.readers().insert(
321 id,
322 ReaderState {
323 path: path.to_string(),
324 form,
325 },
326 );
327 Ok(Value(Repr::Resource(ResourceHandle::new(
328 "files",
329 &SCHEMA.resources[0],
330 id,
331 ))))
332 }
333
334 fn create(&self, path: &str) -> Result<Value, String> {
337 let form = match &self.source {
338 FileSource::Rooted(root) => {
339 relative_parts(path)?;
345 std::fs::create_dir_all(root)
346 .map_err(|e| format!("files: cannot create the root directory: {e}"))?;
347 let full = rooted_path(root, path)?;
348 if let Some(parent) = full.parent() {
349 std::fs::create_dir_all(parent)
350 .map_err(|e| format!("files: cannot write `{path}`: {e}"))?;
351 }
352 let file = std::fs::File::create(&full)
353 .map_err(|e| format!("files: cannot write `{path}`: {e}"))?;
354 WriterForm::Rooted(std::io::BufWriter::new(file))
355 }
356 FileSource::InMemory(files) => {
357 let key = relative_key(path)?;
358 if key.is_empty() {
359 return Err(format!("files: `{path}` is a directory"));
360 }
361 stored(files).insert(key.clone(), String::new());
365 WriterForm::InMemory { key }
366 }
367 };
368 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
369 self.writers().insert(
370 id,
371 WriterState {
372 path: path.to_string(),
373 form,
374 },
375 );
376 Ok(Value(Repr::Resource(ResourceHandle::new(
377 "files",
378 &SCHEMA.resources[1],
379 id,
380 ))))
381 }
382
383 fn write_through(&self, state: &mut WriterState, text: &str) -> Result<(), String> {
390 let WriterState { path, form } = state;
391 match (&self.source, form) {
392 (_, WriterForm::Rooted(file)) => file
393 .write_all(text.as_bytes())
394 .map_err(|e| format!("files: cannot write `{path}`: {e}")),
395 (FileSource::InMemory(files), WriterForm::InMemory { key }) => {
396 let mut tree = stored(files);
405 match tree.get_mut(key) {
406 Some(file) => file.push_str(text),
407 None => {
412 tree.insert(key.clone(), text.to_string());
413 }
414 }
415 Ok(())
416 }
417 (FileSource::Rooted(_), WriterForm::InMemory { .. }) => {
418 unreachable!("a writer takes the form of the source that issued it")
419 }
420 }
421 }
422
423 fn readers(&self) -> MutexGuard<'_, BTreeMap<u64, ReaderState>> {
426 self.readers
427 .lock()
428 .unwrap_or_else(|poisoned| poisoned.into_inner())
429 }
430
431 fn writers(&self) -> MutexGuard<'_, BTreeMap<u64, WriterState>> {
434 self.writers
435 .lock()
436 .unwrap_or_else(|poisoned| poisoned.into_inner())
437 }
438
439 fn delete(&self, path: &str) -> Result<(), String> {
440 match &self.source {
441 FileSource::Rooted(root) => {
442 let full = rooted_path(root, path)?;
443 std::fs::remove_file(&full).map_err(|e| read_error(path, &e))
444 }
445 FileSource::InMemory(files) => {
446 let key = relative_key(path)?;
447 match stored(files).remove(&key) {
448 Some(_) => Ok(()),
449 None => Err(missing(path)),
450 }
451 }
452 }
453 }
454}
455
456#[derive(Clone)]
463pub struct Tree(Arc<Mutex<BTreeMap<String, String>>>);
464
465impl Tree {
466 pub fn files(&self) -> BTreeMap<String, String> {
468 stored(&self.0).clone()
469 }
470}
471
472fn stored(files: &Mutex<BTreeMap<String, String>>) -> MutexGuard<'_, BTreeMap<String, String>> {
476 files
477 .lock()
478 .unwrap_or_else(|poisoned| poisoned.into_inner())
479}
480
481fn missing(path: &str) -> String {
483 format!("files: `{path}` does not exist")
484}
485
486fn read_error(path: &str, error: &std::io::Error) -> String {
492 match error.kind() {
493 ErrorKind::NotFound => missing(path),
494 _ => format!("files: cannot read `{path}`: {error}"),
495 }
496}
497
498fn relative_parts(path: &str) -> Result<Vec<String>, String> {
514 if path.is_empty() {
515 return Err("files: a path must not be empty".to_string());
516 }
517 if path.contains('\0') {
518 return Err(format!("files: `{path}` contains a NUL byte"));
519 }
520 if path.contains('\\') {
521 return Err(format!(
522 "files: `{path}` contains a backslash, and a path is `/`-separated and relative to the root this host grants"
523 ));
524 }
525 let candidate = Path::new(path);
526 if candidate.is_absolute() {
527 return Err(format!(
528 "files: `{path}` is absolute, and a path is relative to the root this host grants"
529 ));
530 }
531 let mut parts = Vec::new();
532 for component in candidate.components() {
533 match component {
534 Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()),
535 Component::CurDir => {}
536 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
537 return Err(format!("files: `{path}` leaves the root this host grants"))
538 }
539 }
540 }
541 Ok(parts)
542}
543
544fn relative_key(path: &str) -> Result<String, String> {
547 Ok(relative_parts(path)?.join("/"))
548}
549
550fn rooted_path(root: &Path, path: &str) -> Result<PathBuf, String> {
559 let parts = relative_parts(path)?;
560 let mut full = root.to_path_buf();
561 for part in &parts {
562 full.push(part);
563 }
564 let Ok(canonical_root) = root.canonicalize() else {
567 return Err(missing(path));
568 };
569 if !within(&canonical_root, &full) {
570 return Err(format!(
571 "files: `{path}` resolves outside the root this host grants"
572 ));
573 }
574 Ok(full)
575}
576
577fn within(root: &Path, candidate: &Path) -> bool {
580 let mut existing = candidate.to_path_buf();
581 loop {
582 if let Ok(real) = existing.canonicalize() {
583 return real.starts_with(root);
584 }
585 if !existing.pop() {
586 return false;
587 }
588 }
589}
590
591fn result(outcome: Result<Value, String>) -> Value {
594 match outcome {
595 Ok(value) => Value::ok(value),
596 Err(message) => Value::err(Value::error(message)),
597 }
598}
599
600fn read_line(state: &mut ReaderState) -> Result<Option<String>, String> {
609 let ReaderState { path, form } = state;
610 match form {
611 ReaderForm::Rooted(reader) => {
612 let mut bytes = Vec::new();
616 reader
617 .by_ref()
618 .take(MAX_LINE_BYTES as u64 + 1)
619 .read_until(b'\n', &mut bytes)
620 .map_err(|e| format!("files: cannot read `{path}`: {e}"))?;
621 if bytes.is_empty() {
622 return Ok(None);
623 }
624 if bytes.len() > MAX_LINE_BYTES && bytes.last() != Some(&b'\n') {
625 return Err(too_long(path));
626 }
627 let line = String::from_utf8(strip_terminator(bytes))
628 .map_err(|_| format!("files: `{path}` is not UTF-8"))?;
629 Ok(Some(line))
630 }
631 ReaderForm::InMemory { contents, position } => {
632 if *position >= contents.len() {
633 return Ok(None);
634 }
635 let rest = &contents[*position..];
639 let (line, advance) = match rest.find('\n') {
640 Some(at) => (&rest[..at], at + 1),
641 None => (rest, rest.len()),
642 };
643 if line.len() > MAX_LINE_BYTES {
644 return Err(too_long(path));
645 }
646 let terminated = advance > line.len();
647 *position += advance;
648 let line = if terminated {
649 line.strip_suffix('\r').unwrap_or(line)
650 } else {
651 line
652 };
653 Ok(Some(line.to_string()))
654 }
655 }
656}
657
658fn strip_terminator(mut bytes: Vec<u8>) -> Vec<u8> {
660 if bytes.last() == Some(&b'\n') {
661 bytes.pop();
662 if bytes.last() == Some(&b'\r') {
663 bytes.pop();
664 }
665 }
666 bytes
667}
668
669fn too_long(path: &str) -> String {
672 format!("files: `{path}` has a line longer than the {MAX_LINE_BYTES} bytes this host reads")
673}
674
675fn flush(state: &mut WriterState) -> Result<(), String> {
682 let WriterState { path, form } = state;
683 match form {
684 WriterForm::Rooted(file) => file
685 .flush()
686 .map_err(|e| format!("files: cannot write `{path}`: {e}")),
687 WriterForm::InMemory { .. } => Ok(()),
688 }
689}
690
691fn closed(handle: &ResourceHandle, op: &str) -> RuntimeError {
698 RuntimeError::new(format!(
699 "`{handle}` is closed, so `{op}` has nothing to act on"
700 ))
701 .with_rule(
702 "A host resource handle names a resource the host owns. Closing the resource ends the handle; the name outlives it and addresses nothing.",
703 )
704 .with_help("open a new one, or move the `close` after the last use")
705}
706
707impl HostApi for Files {
708 fn module_schema(&self) -> ModuleSchema {
709 SCHEMA
710 }
711
712 fn call(&self, op: &str, args: Vec<Value>) -> Result<Value, RuntimeError> {
713 match op {
714 "read" => {
715 let path = one_path(op, &args)?;
716 Ok(result(
717 self.read(&path).map(|text| Value(Repr::Str(text.into()))),
718 ))
719 }
720 "write" => {
721 let [Value(Repr::Str(path)), Value(Repr::Str(contents))] = args.as_slice() else {
722 unreachable!("checked by HostRegistry::call")
723 };
724 let (path, contents) = (path.to_string(), contents.to_string());
725 Ok(result(
726 self.write(&path, &contents).map(|()| Value(Repr::Unit)),
727 ))
728 }
729 "exists" => {
730 let path = one_path(op, &args)?;
731 Ok(Value(Repr::Bool(self.exists(&path))))
732 }
733 "list" => {
734 let path = one_path(op, &args)?;
735 Ok(result(self.list(&path).map(|names| {
736 Value(Repr::Array(
737 names
738 .into_iter()
739 .map(|n| Value(Repr::Str(n.into())))
740 .collect(),
741 ))
742 })))
743 }
744 "delete" => {
745 let path = one_path(op, &args)?;
746 Ok(result(self.delete(&path).map(|()| Value(Repr::Unit))))
747 }
748 "open" => {
749 let path = one_path(op, &args)?;
750 Ok(result(self.open(&path)))
751 }
752 "create" => {
753 let path = one_path(op, &args)?;
754 Ok(result(self.create(&path)))
755 }
756 _ => unreachable!("checked by HostRegistry::call"),
757 }
758 }
759
760 fn call_resource(
761 &self,
762 handle: &ResourceHandle,
763 op: &str,
764 args: Vec<Value>,
765 _back: &mut dyn Reentry,
766 ) -> Result<Value, RuntimeError> {
767 match handle.type_name.as_str() {
768 "Reader" => match op {
769 "readLine" => {
770 let mut readers = self.readers();
776 let Some(state) = readers.get_mut(&handle.id) else {
777 return Err(closed(handle, op));
778 };
779 Ok(result(read_line(state).map(|line| match line {
780 Some(text) => Value::some(Value(Repr::Str(text.into()))),
781 None => Value::none(),
782 })))
783 }
784 "close" => match self.readers().remove(&handle.id) {
785 Some(_) => Ok(Value::ok(Value(Repr::Unit))),
786 None => Err(closed(handle, op)),
787 },
788 _ => unreachable!("checked by HostRegistry::call_resource"),
789 },
790 "Writer" => match op {
791 "write" | "writeLine" => {
792 let [Value(Repr::Str(text))] = args.as_slice() else {
793 unreachable!("checked by HostRegistry::call_resource")
794 };
795 let mut written = text.to_string();
796 if op == "writeLine" {
797 written.push('\n');
798 }
799 let mut writers = self.writers();
800 let Some(state) = writers.get_mut(&handle.id) else {
801 return Err(closed(handle, op));
802 };
803 Ok(result(
804 self.write_through(state, &written)
805 .map(|()| Value(Repr::Unit)),
806 ))
807 }
808 "close" => {
813 let Some(mut state) = self.writers().remove(&handle.id) else {
814 return Err(closed(handle, op));
815 };
816 Ok(result(flush(&mut state).map(|()| Value(Repr::Unit))))
817 }
818 _ => unreachable!("checked by HostRegistry::call_resource"),
819 },
820 _ => unreachable!("checked by HostRegistry::call_resource"),
821 }
822 }
823}
824
825fn one_path(op: &str, args: &[Value]) -> Result<String, RuntimeError> {
827 match args {
828 [Value(Repr::Str(path))] => Ok(path.to_string()),
829 _ => Err(RuntimeError::new(format!(
830 "`files.{op}` takes one `String` argument"
831 ))),
832 }
833}
834
835#[cfg(test)]
836mod tests {
837 use super::*;
838 use crate::host::{Grants, HostRegistry, NoReentry};
839 use crate::schema::Effect;
840 use std::path::Path;
841 use std::sync::Arc;
842
843 struct TempDir(PathBuf);
845
846 impl TempDir {
847 fn new(name: &str) -> Self {
848 let dir = std::env::temp_dir().join(format!(
849 "cove-files-test-{name}-{}-{}",
850 std::process::id(),
851 std::time::SystemTime::now()
852 .duration_since(std::time::UNIX_EPOCH)
853 .unwrap()
854 .as_nanos()
855 ));
856 std::fs::create_dir_all(&dir).unwrap();
857 TempDir(dir)
858 }
859
860 fn path(&self) -> &Path {
861 &self.0
862 }
863 }
864
865 impl Drop for TempDir {
866 fn drop(&mut self) {
867 let _ = std::fs::remove_dir_all(&self.0);
868 }
869 }
870
871 fn ok_value(value: Value) -> Value {
872 match value.ok_payload() {
873 Some(payload) => payload.first().cloned().unwrap_or(Value(Repr::Unit)),
874 None => panic!("expected `Ok(...)`, found {value}"),
875 }
876 }
877
878 fn err_message(value: Value) -> String {
879 match value.err_payload() {
880 Some(payload) => payload.first().map(ToString::to_string).unwrap_or_default(),
881 None => panic!("expected `Err(...)`, found {value}"),
882 }
883 }
884
885 fn strings(value: Value) -> Vec<String> {
886 match value {
887 Value(Repr::Array(items)) => items.iter().map(ToString::to_string).collect(),
888 other => panic!("expected an `Array`, found {other}"),
889 }
890 }
891
892 fn is_true(value: Value) -> bool {
893 match value {
894 Value(Repr::Bool(b)) => b,
895 other => panic!("expected a `Bool`, found {other}"),
896 }
897 }
898
899 fn str_arg(text: &str) -> Value {
900 Value(Repr::Str(text.into()))
901 }
902
903 fn handle(value: Value) -> Arc<ResourceHandle> {
905 match ok_value(value) {
906 Value(Repr::Resource(handle)) => handle,
907 other => panic!("expected a resource handle, found {other}"),
908 }
909 }
910
911 fn opened(files: &Files, path: &str) -> Arc<ResourceHandle> {
912 handle(files.call("open", vec![str_arg(path)]).unwrap())
913 }
914
915 fn created(files: &Files, path: &str) -> Arc<ResourceHandle> {
916 handle(files.call("create", vec![str_arg(path)]).unwrap())
917 }
918
919 fn on(files: &Files, handle: &ResourceHandle, op: &str, args: Vec<Value>) -> Value {
920 files
921 .call_resource(handle, op, args, &mut NoReentry)
922 .unwrap_or_else(|error| panic!("`{op}` on `{handle}`: {}", error.message))
923 }
924
925 fn next_line(files: &Files, handle: &ResourceHandle) -> Value {
926 on(files, handle, "readLine", Vec::new())
927 }
928
929 fn line(value: Value) -> Option<String> {
931 let option = ok_value(value);
932 match option.some_payload() {
933 Some(payload) => Some(payload.first().map(ToString::to_string).unwrap_or_default()),
934 None => {
935 assert_eq!(option.to_string(), "None", "expected `Some(..)` or `None`");
936 None
937 }
938 }
939 }
940
941 fn lines(files: &Files, path: &str) -> Vec<String> {
943 let reader = opened(files, path);
944 let mut read = Vec::new();
945 while let Some(text) = line(next_line(files, &reader)) {
946 read.push(text);
947 }
948 on(files, &reader, "close", Vec::new());
949 read
950 }
951
952 fn both(dir: &TempDir) -> Vec<Files> {
955 vec![
956 Files::rooted(dir.path().to_path_buf()),
957 Files::in_memory(BTreeMap::new()),
958 ]
959 }
960
961 #[test]
962 fn writing_then_reading_answers_what_was_written() {
963 let dir = TempDir::new("round-trip");
964 for files in both(&dir) {
965 let written = files
966 .call("write", vec![str_arg("notes.txt"), str_arg("five words")])
967 .unwrap();
968 assert_eq!(ok_value(written).to_string(), "()");
969
970 let read = files.call("read", vec![str_arg("notes.txt")]).unwrap();
971 assert_eq!(ok_value(read).to_string(), "five words");
972 }
973 }
974
975 #[test]
976 fn writing_twice_keeps_only_the_second_contents() {
977 let dir = TempDir::new("overwrite");
978 for files in both(&dir) {
979 files
980 .call("write", vec![str_arg("notes.txt"), str_arg("first")])
981 .unwrap();
982 files
983 .call("write", vec![str_arg("notes.txt"), str_arg("second")])
984 .unwrap();
985
986 let read = files.call("read", vec![str_arg("notes.txt")]).unwrap();
987 assert_eq!(ok_value(read).to_string(), "second");
988 }
989 }
990
991 #[test]
992 fn a_nested_path_is_created_along_with_its_directories() {
993 let dir = TempDir::new("nested");
994 for files in both(&dir) {
995 files
996 .call("write", vec![str_arg("a/b/c.txt"), str_arg("deep")])
997 .unwrap();
998
999 let read = files.call("read", vec![str_arg("a/b/c.txt")]).unwrap();
1000 assert_eq!(ok_value(read).to_string(), "deep");
1001 assert!(is_true(files.call("exists", vec![str_arg("a/b")]).unwrap()));
1002 assert_eq!(
1003 strings(ok_value(files.call("list", vec![str_arg("a")]).unwrap())),
1004 ["b"]
1005 );
1006 }
1007 }
1008
1009 #[test]
1010 fn reading_a_path_that_is_not_there_reports_it() {
1011 let dir = TempDir::new("missing");
1012 for files in both(&dir) {
1013 let read = files.call("read", vec![str_arg("absent.txt")]).unwrap();
1014 assert_eq!(err_message(read), "files: `absent.txt` does not exist");
1015 }
1016 }
1017
1018 #[test]
1019 fn exists_answers_before_and_after_a_write() {
1020 let dir = TempDir::new("exists");
1021 for files in both(&dir) {
1022 assert!(!is_true(
1023 files.call("exists", vec![str_arg("notes.txt")]).unwrap()
1024 ));
1025 files
1026 .call("write", vec![str_arg("notes.txt"), str_arg("here")])
1027 .unwrap();
1028 assert!(is_true(
1029 files.call("exists", vec![str_arg("notes.txt")]).unwrap()
1030 ));
1031 }
1032 }
1033
1034 #[test]
1035 fn listing_the_root_answers_its_entries_in_order() {
1036 let dir = TempDir::new("list-root");
1037 for files in both(&dir) {
1038 for name in ["b.txt", "a.txt", "c.txt"] {
1039 files
1040 .call("write", vec![str_arg(name), str_arg("x")])
1041 .unwrap();
1042 }
1043
1044 let listed = files.call("list", vec![str_arg(".")]).unwrap();
1045 assert_eq!(strings(ok_value(listed)), ["a.txt", "b.txt", "c.txt"]);
1046 }
1047 }
1048
1049 #[test]
1050 fn listing_a_directory_that_is_not_there_reports_it() {
1051 let dir = TempDir::new("list-missing");
1052 for files in both(&dir) {
1053 let listed = files.call("list", vec![str_arg("nowhere")]).unwrap();
1054 assert_eq!(err_message(listed), "files: `nowhere` does not exist");
1055 }
1056 }
1057
1058 #[test]
1059 fn deleting_removes_the_file_and_then_reports_it_gone() {
1060 let dir = TempDir::new("delete");
1061 for files in both(&dir) {
1062 files
1063 .call("write", vec![str_arg("notes.txt"), str_arg("x")])
1064 .unwrap();
1065
1066 let deleted = files.call("delete", vec![str_arg("notes.txt")]).unwrap();
1067 assert_eq!(ok_value(deleted).to_string(), "()");
1068 assert!(!is_true(
1069 files.call("exists", vec![str_arg("notes.txt")]).unwrap()
1070 ));
1071
1072 let again = files.call("delete", vec![str_arg("notes.txt")]).unwrap();
1073 assert_eq!(err_message(again), "files: `notes.txt` does not exist");
1074 }
1075 }
1076
1077 #[test]
1081 fn every_path_that_could_escape_the_root_is_refused() {
1082 let cases = [
1083 ("", "files: a path must not be empty"),
1084 ("..", "files: `..` leaves the root this host grants"),
1085 (
1086 "../cove.toml",
1087 "files: `../cove.toml` leaves the root this host grants",
1088 ),
1089 (
1090 "a/../../b.txt",
1091 "files: `a/../../b.txt` leaves the root this host grants",
1092 ),
1093 (
1094 "/etc/passwd",
1095 "files: `/etc/passwd` is absolute, and a path is relative to the root this host grants",
1096 ),
1097 (
1098 "a\\b.txt",
1099 "files: `a\\b.txt` contains a backslash, and a path is `/`-separated and relative to the root this host grants",
1100 ),
1101 ("a\0b", "files: `a\0b` contains a NUL byte"),
1102 ];
1103
1104 let dir = TempDir::new("escape");
1105 for (path, expected) in cases {
1106 for files in both(&dir) {
1107 for op in ["read", "list", "delete"] {
1108 let refused = files.call(op, vec![str_arg(path)]).unwrap();
1109 assert_eq!(err_message(refused), expected, "`{op}` of `{path}`");
1110 }
1111 let refused = files
1112 .call("write", vec![str_arg(path), str_arg("payload")])
1113 .unwrap();
1114 assert_eq!(err_message(refused), expected, "`write` of `{path}`");
1115 assert!(
1116 !is_true(files.call("exists", vec![str_arg(path)]).unwrap()),
1117 "`exists` of `{path}`"
1118 );
1119 }
1120 }
1121 }
1122
1123 #[test]
1126 fn a_refused_write_leaves_nothing_behind() {
1127 let dir = TempDir::new("refused-write");
1128 let outside = dir.path().join("outside.txt");
1129 let root = dir.path().join("root");
1130 std::fs::create_dir_all(&root).unwrap();
1131 let files = Files::rooted(root);
1132
1133 let refused = files
1134 .call("write", vec![str_arg("../outside.txt"), str_arg("payload")])
1135 .unwrap();
1136 assert_eq!(
1137 err_message(refused),
1138 "files: `../outside.txt` leaves the root this host grants"
1139 );
1140 assert!(!outside.exists());
1141
1142 let refused = files
1145 .call("create", vec![str_arg("../outside.txt")])
1146 .unwrap();
1147 assert_eq!(
1148 err_message(refused),
1149 "files: `../outside.txt` leaves the root this host grants"
1150 );
1151 assert!(!outside.exists());
1152 }
1153
1154 #[cfg(unix)]
1157 #[test]
1158 fn a_symbolic_link_out_of_the_root_is_refused() {
1159 let dir = TempDir::new("symlink");
1160 let root = dir.path().join("root");
1161 std::fs::create_dir_all(&root).unwrap();
1162 let secret = dir.path().join("secret.txt");
1163 std::fs::write(&secret, "not yours").unwrap();
1164 std::os::unix::fs::symlink(&secret, root.join("link.txt")).unwrap();
1165 std::os::unix::fs::symlink(dir.path(), root.join("up")).unwrap();
1166
1167 let files = Files::rooted(root);
1168 for path in ["link.txt", "up/secret.txt"] {
1169 let refused = files.call("read", vec![str_arg(path)]).unwrap();
1170 assert_eq!(
1171 err_message(refused),
1172 format!("files: `{path}` resolves outside the root this host grants")
1173 );
1174 }
1175
1176 let refused = files
1177 .call("write", vec![str_arg("link.txt"), str_arg("payload")])
1178 .unwrap();
1179 assert_eq!(
1180 err_message(refused),
1181 "files: `link.txt` resolves outside the root this host grants"
1182 );
1183 assert_eq!(std::fs::read_to_string(&secret).unwrap(), "not yours");
1184 }
1185
1186 #[cfg(unix)]
1188 #[test]
1189 fn a_symbolic_link_inside_the_root_is_allowed() {
1190 let dir = TempDir::new("symlink-inside");
1191 std::fs::write(dir.path().join("real.txt"), "inside").unwrap();
1192 std::os::unix::fs::symlink(dir.path().join("real.txt"), dir.path().join("link.txt"))
1193 .unwrap();
1194
1195 let files = Files::rooted(dir.path().to_path_buf());
1196 let read = files.call("read", vec![str_arg("link.txt")]).unwrap();
1197 assert_eq!(ok_value(read).to_string(), "inside");
1198 }
1199
1200 #[test]
1203 fn a_root_that_does_not_exist_yet_is_empty_until_the_first_write() {
1204 let dir = TempDir::new("absent-root");
1205 let root = dir.path().join("not-created-yet");
1206 let files = Files::rooted(root.clone());
1207
1208 let read = files.call("read", vec![str_arg("notes.txt")]).unwrap();
1209 assert_eq!(err_message(read), "files: `notes.txt` does not exist");
1210 assert!(!is_true(
1211 files.call("exists", vec![str_arg("notes.txt")]).unwrap()
1212 ));
1213 assert!(!root.exists());
1214
1215 files
1216 .call("write", vec![str_arg("notes.txt"), str_arg("now")])
1217 .unwrap();
1218 assert_eq!(
1219 std::fs::read_to_string(root.join("notes.txt")).unwrap(),
1220 "now"
1221 );
1222 }
1223
1224 #[test]
1225 fn a_run_without_the_files_grant_cannot_read() {
1226 let mut hosts = HostRegistry::new(Grants::new(["console"]));
1227 hosts.register(Box::new(Files::in_memory(BTreeMap::new())));
1228
1229 let error = hosts
1230 .call("files", "read", vec![str_arg("notes.txt")])
1231 .expect_err("the call should be rejected");
1232 assert_eq!(
1233 error.message,
1234 "`files.read` requires the `files` capability, which this run was not granted"
1235 );
1236 }
1237
1238 #[test]
1239 fn a_granted_files_host_is_reachable_through_the_registry() {
1240 let mut hosts = HostRegistry::new(Grants::new(["files"]));
1241 hosts.register(Box::new(Files::in_memory(BTreeMap::from([(
1242 "notes.txt".to_string(),
1243 "hello".to_string(),
1244 )]))));
1245
1246 let read = hosts
1247 .call("files", "read", vec![str_arg("notes.txt")])
1248 .expect("the call should be allowed");
1249 assert_eq!(ok_value(read).to_string(), "hello");
1250 }
1251
1252 #[test]
1253 fn signatures_read_like_source() {
1254 let files = Files::in_memory(BTreeMap::new());
1255 let rendered: Vec<String> = files
1256 .module_schema()
1257 .operations
1258 .iter()
1259 .map(|op| op.signature())
1260 .collect();
1261 assert_eq!(
1262 rendered,
1263 [
1264 "read(String) -> Result<String, Error>",
1265 "write(String, String) -> Result<Unit, Error>",
1266 "exists(String) -> Bool",
1267 "list(String) -> Result<Array<String>, Error>",
1268 "delete(String) -> Result<Unit, Error>",
1269 "open(String) -> Result<files.Reader, Error>",
1270 "create(String) -> Result<files.Writer, Error>",
1271 ]
1272 );
1273 let rendered: Vec<String> = SCHEMA.resources[0]
1274 .operations
1275 .iter()
1276 .map(|op| op.signature())
1277 .collect();
1278 assert_eq!(
1279 rendered,
1280 [
1281 "readLine() -> Result<Option<String>, Error>",
1282 "close() -> Result<Unit, Error>",
1283 ]
1284 );
1285 let rendered: Vec<String> = SCHEMA.resources[1]
1286 .operations
1287 .iter()
1288 .map(|op| op.signature())
1289 .collect();
1290 assert_eq!(
1291 rendered,
1292 [
1293 "write(String) -> Result<Unit, Error>",
1294 "writeLine(String) -> Result<Unit, Error>",
1295 "close() -> Result<Unit, Error>",
1296 ]
1297 );
1298 }
1299
1300 #[test]
1303 fn reads_and_writes_declare_different_effects() {
1304 let files = Files::in_memory(BTreeMap::new());
1305 for op in files.module_schema().operations {
1306 let expected = match op.name {
1307 "read" | "exists" | "list" | "open" => Effect::Read,
1308 "write" | "delete" | "create" => Effect::IrreversibleWrite,
1309 other => panic!("unexpected operation `{other}`"),
1310 };
1311 assert_eq!(op.effect, expected, "`files.{}`", op.name);
1312 assert_eq!(
1313 op.cancellable,
1314 expected == Effect::Read,
1315 "`files.{}`",
1316 op.name
1317 );
1318 }
1319 }
1320
1321 #[test]
1324 fn a_writer_and_a_reader_round_trip_the_lines_that_were_written() {
1325 let dir = TempDir::new("stream-round-trip");
1326 for files in both(&dir) {
1327 let writer = created(&files, "log.txt");
1328 for text in ["first", "second", "third"] {
1329 let written = on(&files, &writer, "writeLine", vec![str_arg(text)]);
1330 assert_eq!(ok_value(written).to_string(), "()");
1331 }
1332 on(&files, &writer, "close", Vec::new());
1333
1334 let reader = opened(&files, "log.txt");
1335 assert_eq!(line(next_line(&files, &reader)).as_deref(), Some("first"));
1336 assert_eq!(line(next_line(&files, &reader)).as_deref(), Some("second"));
1337 assert_eq!(line(next_line(&files, &reader)).as_deref(), Some("third"));
1338 assert_eq!(line(next_line(&files, &reader)), None);
1339 on(&files, &reader, "close", Vec::new());
1340 }
1341 }
1342
1343 #[test]
1347 fn a_last_line_with_no_terminator_is_still_a_line() {
1348 let dir = TempDir::new("stream-unterminated");
1349 for files in both(&dir) {
1350 let writer = created(&files, "log.txt");
1351 on(&files, &writer, "writeLine", vec![str_arg("first")]);
1352 on(&files, &writer, "write", vec![str_arg("second")]);
1353 on(&files, &writer, "close", Vec::new());
1354
1355 assert_eq!(lines(&files, "log.txt"), ["first", "second"]);
1356 }
1357 }
1358
1359 #[test]
1362 fn a_carriage_return_before_a_newline_is_part_of_the_terminator() {
1363 let dir = TempDir::new("stream-crlf");
1364 for files in both(&dir) {
1365 files
1366 .call(
1367 "write",
1368 vec![str_arg("log.txt"), str_arg("first\r\nsecond\r\nthird")],
1369 )
1370 .unwrap();
1371
1372 assert_eq!(lines(&files, "log.txt"), ["first", "second", "third"]);
1373 }
1374 }
1375
1376 #[test]
1377 fn an_empty_file_answers_no_lines_at_all() {
1378 let dir = TempDir::new("stream-empty");
1379 for files in both(&dir) {
1380 let writer = created(&files, "log.txt");
1381 on(&files, &writer, "close", Vec::new());
1382
1383 let reader = opened(&files, "log.txt");
1384 assert_eq!(line(next_line(&files, &reader)), None);
1385 on(&files, &reader, "close", Vec::new());
1386 }
1387 }
1388
1389 #[test]
1392 fn a_line_at_the_bound_is_read_and_one_past_it_is_refused() {
1393 let dir = TempDir::new("stream-bound");
1394 for files in both(&dir) {
1395 for (path, length) in [("at.txt", MAX_LINE_BYTES), ("past.txt", MAX_LINE_BYTES + 1)] {
1396 let contents = "a".repeat(length) + "\n";
1397 files
1398 .call("write", vec![str_arg(path), str_arg(&contents)])
1399 .unwrap();
1400 }
1401
1402 let reader = opened(&files, "at.txt");
1403 assert_eq!(
1404 line(next_line(&files, &reader)).map(|text| text.len()),
1405 Some(MAX_LINE_BYTES)
1406 );
1407
1408 let reader = opened(&files, "past.txt");
1409 let refused = next_line(&files, &reader);
1410 assert_eq!(
1411 err_message(refused),
1412 format!(
1413 "files: `past.txt` has a line longer than the {MAX_LINE_BYTES} bytes this host reads"
1414 )
1415 );
1416 }
1417 }
1418
1419 #[test]
1420 fn opening_a_path_that_is_not_there_reports_it() {
1421 let dir = TempDir::new("stream-missing");
1422 for files in both(&dir) {
1423 let refused = files.call("open", vec![str_arg("absent.txt")]).unwrap();
1424 assert_eq!(err_message(refused), "files: `absent.txt` does not exist");
1425 }
1426 }
1427
1428 #[test]
1432 fn a_path_that_leaves_the_root_is_refused_for_a_handle_as_it_is_for_a_read() {
1433 let dir = TempDir::new("stream-escape");
1434 for path in ["../escape", "/etc/passwd"] {
1435 for files in both(&dir) {
1436 let expected = err_message(files.call("read", vec![str_arg(path)]).unwrap());
1437 for op in ["open", "create"] {
1438 let refused = files.call(op, vec![str_arg(path)]).unwrap();
1439 assert_eq!(err_message(refused), expected, "`{op}` of `{path}`");
1440 }
1441 }
1442 }
1443 }
1444
1445 #[test]
1446 fn a_reader_that_was_closed_reports_that_its_handle_addresses_nothing() {
1447 let dir = TempDir::new("stream-closed-reader");
1448 for files in both(&dir) {
1449 files
1450 .call("write", vec![str_arg("log.txt"), str_arg("only\n")])
1451 .unwrap();
1452 let reader = opened(&files, "log.txt");
1453 on(&files, &reader, "close", Vec::new());
1454
1455 let error = files
1456 .call_resource(&reader, "readLine", Vec::new(), &mut NoReentry)
1457 .expect_err("a read from a closed reader is refused");
1458 assert_eq!(
1459 error.message,
1460 "`files.Reader#1` is closed, so `readLine` has nothing to act on"
1461 );
1462 }
1463 }
1464
1465 #[test]
1466 fn closing_twice_reports_that_the_handle_addresses_nothing() {
1467 let dir = TempDir::new("stream-closed-twice");
1468 for files in both(&dir) {
1469 let writer = created(&files, "log.txt");
1470 on(&files, &writer, "close", Vec::new());
1471
1472 let error = files
1473 .call_resource(&writer, "close", Vec::new(), &mut NoReentry)
1474 .expect_err("closing a closed writer is refused");
1475 assert_eq!(
1476 error.message,
1477 "`files.Writer#1` is closed, so `close` has nothing to act on"
1478 );
1479 }
1480 }
1481
1482 #[test]
1485 fn a_reader_and_a_writer_of_one_host_never_share_an_identity() {
1486 let dir = TempDir::new("stream-identity");
1487 for files in both(&dir) {
1488 let writer = created(&files, "log.txt");
1489 let reader = opened(&files, "log.txt");
1490
1491 assert_eq!(writer.qualified_type(), "files.Writer");
1492 assert_eq!(reader.qualified_type(), "files.Reader");
1493 assert_ne!(writer.id, reader.id);
1494 assert!(!writer.task_safe);
1495 assert!(!reader.task_safe);
1496 }
1497 }
1498
1499 #[test]
1502 fn creating_a_nested_path_creates_the_directories_above_it() {
1503 let dir = TempDir::new("stream-nested");
1504 for files in both(&dir) {
1505 let writer = created(&files, "a/b/log.txt");
1506 on(&files, &writer, "writeLine", vec![str_arg("deep")]);
1507 on(&files, &writer, "close", Vec::new());
1508
1509 assert_eq!(lines(&files, "a/b/log.txt"), ["deep"]);
1510 }
1511 }
1512
1513 #[test]
1516 fn a_line_that_is_not_utf8_is_reported() {
1517 let dir = TempDir::new("stream-not-utf8");
1518 std::fs::write(dir.path().join("bytes.bin"), [0xff, 0xfe, b'\n']).unwrap();
1519 let files = Files::rooted(dir.path().to_path_buf());
1520
1521 let reader = opened(&files, "bytes.bin");
1522 assert_eq!(
1523 err_message(next_line(&files, &reader)),
1524 "files: `bytes.bin` is not UTF-8"
1525 );
1526 }
1527
1528 #[test]
1531 fn the_fake_publishes_what_a_writer_has_written_before_it_is_closed() {
1532 let files = Files::in_memory(BTreeMap::new());
1533 let writer = created(&files, "log.txt");
1534 on(&files, &writer, "writeLine", vec![str_arg("first")]);
1535
1536 let read = files.call("read", vec![str_arg("log.txt")]).unwrap();
1537 assert_eq!(ok_value(read).to_string(), "first\n");
1538 }
1539
1540 #[test]
1541 fn a_run_without_the_files_grant_cannot_open_or_use_a_reader() {
1542 let mut hosts = HostRegistry::new(Grants::new(["console"]));
1543 hosts.register(Box::new(Files::in_memory(BTreeMap::new())));
1544
1545 let error = hosts
1546 .call("files", "open", vec![str_arg("notes.txt")])
1547 .expect_err("the call should be rejected");
1548 assert_eq!(
1549 error.message,
1550 "`files.open` requires the `files` capability, which this run was not granted"
1551 );
1552
1553 let handle = ResourceHandle::new("files", &SCHEMA.resources[0], 1);
1554 let error = hosts
1555 .call_resource(&handle, "readLine", Vec::new(), &mut NoReentry)
1556 .expect_err("the call should be rejected");
1557 assert_eq!(
1558 error.message,
1559 "`files.Reader.readLine` requires the `files` capability, which this run was not granted"
1560 );
1561 }
1562
1563 #[test]
1567 fn a_reader_and_a_writer_declare_the_effects_their_calls_have() {
1568 for resource in SCHEMA.resources {
1569 assert!(!resource.task_safe, "`files.{}`", resource.name);
1570 for op in resource.operations {
1571 let expected = match op.name {
1572 "readLine" => Effect::Read,
1573 "write" | "writeLine" => Effect::IrreversibleWrite,
1574 "close" => Effect::ReversibleWrite,
1575 other => panic!("unexpected operation `{other}`"),
1576 };
1577 assert_eq!(op.effect, expected, "`files.{}.{}`", resource.name, op.name);
1578 assert_eq!(
1579 op.cancellable,
1580 expected == Effect::Read,
1581 "`files.{}.{}`",
1582 resource.name,
1583 op.name
1584 );
1585 }
1586 }
1587 }
1588}