Skip to main content

cove_runtime/
files.rs

1//! `files`: the real filesystem, confined to a directory the host chose.
2//!
3//! The Language Card names files first among the operations that are typed
4//! Host APIs rather than ambient authority. [`crate::host::Documents`] is a
5//! narrower thing: a read-only view over a fixed set of `.txt` documents, so
6//! a program that only reads its inputs never has to be handed a filesystem.
7//! This module is the filesystem itself — reading, writing, listing, and
8//! removing — and it is a separate capability precisely because it is the
9//! wider one.
10//!
11//! Granting `files` must not hand over the machine, so the real
12//! implementation is rooted: [`Files::rooted`] takes the one directory a run
13//! may reach, and every path is checked against it twice. The lexical check
14//! refuses an absolute path, a `..` component, and a backslash, none of which
15//! can name a place inside the root. The second check follows symbolic links,
16//! because a path made only of ordinary components can still leave the root
17//! through one. [`Files::in_memory`] is the fake: the same paths are refused
18//! for the same reasons, so a test written against it exercises the rules the
19//! real filesystem enforces.
20//!
21//! Paths are always relative to the root and always `/`-separated. `.` names
22//! the root itself, which is how `list(".")` asks what a run can see.
23//!
24//! `read` and `write` move a whole file. `open` and `create` move one a line
25//! at a time instead, through the two resource kinds ADR 0018 added: a
26//! `files.Reader` answers lines until there are none left, a `files.Writer`
27//! takes them, and each is a position in a file, which is why neither may
28//! cross a task boundary. Both are reached through the same `files`
29//! capability and both go through the same path checks, so a handle cannot
30//! name a place the root does not contain.
31
32use 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
43/// The most of one line this host will read.
44///
45/// One mebibyte, and the bound exists for the reason `http`'s do: a host
46/// reads what it decided to read rather than what the input asked it to, and
47/// what sits under a granted root is not the run's to trust. The bound is the
48/// host's and not the program's because `readLine` takes no argument — giving
49/// it one would make every caller answer a question about a file it has not
50/// seen. `read` stays unbounded, since it is the operation whose name says it
51/// wants the whole thing.
52const MAX_LINE_BYTES: usize = 1024 * 1024;
53
54/// `files`: reading, writing, listing, and removing files under one root.
55pub struct Files {
56    source: FileSource,
57    /// The readers this host still has open, by the identity it issued.
58    readers: Mutex<BTreeMap<u64, ReaderState>>,
59    /// The writers this host still has open, by the identity it issued.
60    writers: Mutex<BTreeMap<u64, WriterState>>,
61    /// The identity the next reader or writer gets.
62    ///
63    /// One counter serves both kinds, so an identity is unique among
64    /// everything this host issued rather than merely among the readers or
65    /// merely among the writers. ADR 0013 makes a handle a name and requires
66    /// that a name never be reused; a counter per kind would hand out
67    /// `files.Reader#1` and `files.Writer#1` from the same host, and then the
68    /// number a trace or a diagnostic prints would no longer say on its own
69    /// which of this host's resources was meant.
70    next_id: AtomicU64,
71}
72
73/// One reader this host has open.
74struct ReaderState {
75    /// The path it was opened on, so a failure names what the program wrote
76    /// rather than the handle it was handed back.
77    path: String,
78    form: ReaderForm,
79}
80
81/// Where an open reader reads from, which is whichever form the
82/// [`FileSource`] that issued it keeps its files in.
83enum ReaderForm {
84    /// A buffered handle on the real file, which holds one buffer however
85    /// long the file is.
86    Rooted(std::io::BufReader<std::fs::File>),
87    /// The contents as they stood when the reader was opened, and how far
88    /// into them it has read. The fake tree holds a `String` rather than
89    /// something to seek in, so a position is a byte offset into that.
90    InMemory { contents: String, position: usize },
91}
92
93/// One writer this host has open.
94struct WriterState {
95    /// The path it was created on, for the reason [`ReaderState::path`] is
96    /// kept.
97    path: String,
98    form: WriterForm,
99}
100
101/// Where an open writer writes to.
102enum WriterForm {
103    /// A buffered handle on the real file, which `close` flushes.
104    Rooted(std::io::BufWriter<std::fs::File>),
105    /// The key the fake tree stores this file under. The text is the tree's
106    /// own entry and is appended to in place; a second copy here would have
107    /// to be written back on every line, which is quadratic in the file.
108    InMemory { key: String },
109}
110
111enum FileSource {
112    /// The real filesystem, reachable only inside this directory.
113    Rooted(PathBuf),
114    /// A tree that lives only in this process, keyed by `/`-separated
115    /// relative path.
116    ///
117    /// The real filesystem is the operating system's to synchronize; this
118    /// tree is the host's own state, so the host locks it. Two tasks writing
119    /// at once therefore take turns here exactly as they do on disk.
120    InMemory(Arc<Mutex<BTreeMap<String, String>>>),
121}
122
123/// What `files` declares about itself.
124///
125/// The table is [`cove_schema::hosts::FILES`], so the description the
126/// compiler checks a call against and the one the boundary dispatches through
127/// are the same bytes.
128const SCHEMA: ModuleSchema = cove_schema::hosts::FILES;
129
130impl Files {
131    /// The real filesystem, reachable only inside `root`.
132    ///
133    /// The root is the host's choice, never the program's: no path a program
134    /// writes can name a place outside it, so granting `files` grants exactly
135    /// this directory.
136    ///
137    /// `root` need not exist yet. Until it does, every read answers that the
138    /// path is not there; the first `write` creates it, along with any
139    /// directories the written path names below it, so a program does not
140    /// have to know whether the host prepared the tree.
141    pub fn rooted(root: PathBuf) -> Self {
142        Files::with_source(FileSource::Rooted(root))
143    }
144
145    /// A fake filesystem that lives only in this process, for tests.
146    ///
147    /// Each key is a `/`-separated relative path, exactly as a program would
148    /// write it, and its value is the file's contents. A path with no key of
149    /// its own but with keys below it is a directory.
150    pub fn in_memory(files: BTreeMap<String, String>) -> Self {
151        Files::with_source(FileSource::InMemory(Arc::new(Mutex::new(files))))
152    }
153
154    /// The fake tree as this host holds it, for a test to read back.
155    ///
156    /// A test drives the program and then asks this what the run left behind,
157    /// which is the only way to assert on a file the program wrote rather
158    /// than on the console line that says it did. A rooted host answers an
159    /// empty tree: what a run wrote is on the filesystem, under the root the
160    /// test chose, where the test can go and read it.
161    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                // A path this host refuses must not reach the filesystem at
197                // all, so the lexical rules are applied before anything is
198                // created.
199                relative_parts(path)?;
200                // The root is the host's own directory, so creating it is
201                // never an escape. It has to exist before the containment
202                // check below, which resolves symbolic links and therefore
203                // needs a real directory to resolve against.
204                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                // `rooted_path` refused every component that could climb out,
208                // so the directories still missing below the deepest existing
209                // one can only be created inside the root.
210                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    /// Whether `path` names something this host can reach.
229    ///
230    /// A path this host refuses answers `false` rather than reporting the
231    /// refusal: outside the root there is nothing for a run to observe, and
232    /// an `exists` that distinguished "refused" from "absent" would disclose
233    /// what lies outside the capability.
234    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    /// The names directly inside the directory `path`, in ascending order.
253    ///
254    /// The names are the entries themselves, not paths: a program joins them
255    /// onto the directory it asked about. Ordering is defined so that a
256    /// listing is the same on every run and every platform.
257    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    /// Opens `path` for reading and issues the handle that names the reader.
296    ///
297    /// The path goes through the checks `read` applies and nothing else, so a
298    /// path this host refuses for a whole-file read is refused here for the
299    /// same stated reason.
300    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    /// Creates or truncates `path` and issues the handle that names the
335    /// writer.
336    fn create(&self, path: &str) -> Result<Value, String> {
337        let form = match &self.source {
338            FileSource::Rooted(root) => {
339                // The order `write` uses, and for its reasons: the lexical
340                // rules refuse a path before anything is created, the root is
341                // the host's own directory and so always safe to create, and
342                // the containment check needs a directory that exists to
343                // resolve against.
344                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                // Creating truncates, and the fake truncates when the writer
362                // is issued rather than when it is first written to, so the
363                // tree says what a freshly created real file says.
364                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    /// Writes `text` through the writer `state` names.
384    ///
385    /// The in-memory form publishes everything written so far under its key on
386    /// every call rather than only on `close`, so a `read` of the same path
387    /// answers what has been written, the way a read of a real file that has
388    /// been flushed does.
389    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                // Appended to the tree's own entry rather than rewritten
397                // from a second copy the writer kept. Rewriting it was
398                // quadratic in what a program writes, and the fake is what
399                // every test and every embedding without a real filesystem
400                // writes through: `examples/cq`'s sample entry writes a
401                // hundred thousand lines into sixteen megabytes, so the
402                // copies came to some eight hundred gigabytes and the
403                // differential harness spent four minutes on that one case.
404                let mut tree = stored(files);
405                match tree.get_mut(key) {
406                    Some(file) => file.push_str(text),
407                    // The entry is gone, so something emptied the tree while
408                    // this writer was open. Writing it back is what the
409                    // rewrite did, and a writer that silently wrote nowhere
410                    // would be worse.
411                    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    /// The readers this host has open, taken back from a poisoned lock for
424    /// the reason [`stored`] gives.
425    fn readers(&self) -> MutexGuard<'_, BTreeMap<u64, ReaderState>> {
426        self.readers
427            .lock()
428            .unwrap_or_else(|poisoned| poisoned.into_inner())
429    }
430
431    /// The writers this host has open, taken back from a poisoned lock for
432    /// the reason [`stored`] gives.
433    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/// A fake host's tree, for a test to read back.
457///
458/// This is [`crate::http::Served`]'s counterpart for `files`: a handle on the
459/// state the fake kept, so a test asks it what happened rather than reaching
460/// into the host, which is the program's boundary and not the thing under
461/// test.
462#[derive(Clone)]
463pub struct Tree(Arc<Mutex<BTreeMap<String, String>>>);
464
465impl Tree {
466    /// Every file this host holds, by `/`-separated relative path.
467    pub fn files(&self) -> BTreeMap<String, String> {
468        stored(&self.0).clone()
469    }
470}
471
472/// The in-memory tree, taken back from a lock a panicking run may have
473/// poisoned: a broken invariant in one task must not turn every later
474/// `files` call in another into a second, unrelated failure.
475fn stored(files: &Mutex<BTreeMap<String, String>>) -> MutexGuard<'_, BTreeMap<String, String>> {
476    files
477        .lock()
478        .unwrap_or_else(|poisoned| poisoned.into_inner())
479}
480
481/// The message for a path that names nothing this host can reach.
482fn missing(path: &str) -> String {
483    format!("files: `{path}` does not exist")
484}
485
486/// Reports an error from an operation that only observes the filesystem.
487///
488/// A missing path is reported the same way whether the filesystem or this
489/// host's own bookkeeping noticed it, so the fake and the real
490/// implementation answer a missing path identically.
491fn 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
498/// The components of `path`, refusing anything that could name a place
499/// outside a root.
500///
501/// The rules are lexical, so they hold before the filesystem is touched and
502/// hold identically for the in-memory fake:
503///
504/// - an empty path names nothing;
505/// - a NUL byte cannot appear in a path the operating system will accept;
506/// - an absolute path names a place chosen by the program rather than the
507///   host;
508/// - a `..` component climbs out of the root;
509/// - a backslash separates components on some platforms and not on others,
510///   so a path containing one does not mean the same thing everywhere.
511///
512/// A `.` component is dropped, so `.` alone names the root.
513fn 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
544/// `path` as the `/`-separated key the in-memory fake stores it under. The
545/// root itself is the empty key.
546fn relative_key(path: &str) -> Result<String, String> {
547    Ok(relative_parts(path)?.join("/"))
548}
549
550/// `path` resolved against `root`, or the reason this host refuses it.
551///
552/// The lexical rules in [`relative_parts`] are not enough on their own: every
553/// component of `a/b.txt` is ordinary, and `a` may still be a symbolic link
554/// to somewhere else entirely. So the deepest ancestor of the resolved path
555/// that exists is canonicalized and checked against the canonical root. The
556/// components below it cannot climb back out, because `..` was already
557/// refused.
558fn 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    // A root that does not exist holds nothing, so there is nothing to refuse
565    // and nothing to find. `write` creates the root before it asks.
566    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
577/// Whether `candidate` really lives under the canonical `root`, following
578/// symbolic links as far as `candidate` exists.
579fn 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
591/// `Ok(())` or `Err(Error(message))`, the shape every fallible `files`
592/// operation answers with.
593fn 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
600/// The next line of `state`, with its terminator removed, or `None` when
601/// there is no next line.
602///
603/// A final line with no terminator is a line: the terminator ends a line
604/// rather than making one. A `\r\n` ending is a `\n` ending whose terminator
605/// is two bytes, so the `\r` goes with the terminator and is not part of what
606/// is answered, while a `\r` at the very end of a file with no `\n` after it
607/// is an ordinary byte of the last line.
608fn read_line(state: &mut ReaderState) -> Result<Option<String>, String> {
609    let ReaderState { path, form } = state;
610    match form {
611        ReaderForm::Rooted(reader) => {
612            // `take` bounds the read rather than what the read kept, so a
613            // line past the bound stops the reader at the bound instead of
614            // being gathered whole and then measured.
615            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            // Every position this advances to is just past a `\n`, which is
636            // one byte and never part of another character, so the slice is
637            // always taken at a character boundary.
638            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
658/// `bytes` without the line terminator it ended with, if it ended with one.
659fn 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
669/// The message for a line longer than this host reads, which names the bound
670/// so a run learns the number rather than only that there was one.
671fn too_long(path: &str) -> String {
672    format!("files: `{path}` has a line longer than the {MAX_LINE_BYTES} bytes this host reads")
673}
674
675/// Flushes whatever `state` is still holding.
676///
677/// Dropping a [`std::io::BufWriter`] flushes it and throws the error away, so
678/// a `close` that did not flush here would report success for bytes that
679/// never reached the disk. The in-memory form has nothing to flush, because
680/// every write already published.
681fn 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
691/// A call on a handle whose reader or writer this host no longer has.
692///
693/// This is a [`RuntimeError`] rather than a Cove `Err`, and deliberately: a
694/// line read from a reader that was closed is not an expected failure the
695/// program should handle, it is the program having kept a name past the thing
696/// it named.
697fn 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                    // The lock is held across the read. Nothing a reader does
771                    // reenters the interpreter, so holding it cannot deadlock,
772                    // and taking the state out to read outside the lock would
773                    // let a `close` elsewhere find the entry missing while its
774                    // reader was still reading.
775                    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                // The entry goes whether or not the flush succeeds: the file
809                // is closed either way, so a handle that reported a failed
810                // flush and stayed open would name a writer nothing can
811                // recover.
812                "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
825/// The single `String` path argument of `op`.
826fn 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    /// A temporary directory, removed on drop.
844    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    /// The handle `open` or `create` answered with.
904    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    /// The line a `readLine` answered, or `None` at the end of the file.
930    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    /// The lines `path` holds, read through a reader until it answers `None`.
942    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    /// A fake and a real host rooted at a fresh directory, so every rule can
953    /// be asserted against both implementations of the same Host API.
954    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    /// Every path that names a place outside the root, refused by both
1078    /// implementations for the same stated reason, before either one touches
1079    /// storage.
1080    #[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    /// A refused write must not reach the filesystem, not merely report that
1124    /// it did not.
1125    #[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        // `create` truncates whatever it opens, so a refused one must not
1143        // reach the filesystem either.
1144        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    /// A path of ordinary components can still leave the root through a
1155    /// symbolic link, which no lexical rule can see.
1156    #[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    /// A symbolic link that stays inside the root is an ordinary path.
1187    #[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    /// The root is the host's to create, so a run against a root that is not
1201    /// there yet reads nothing and writes normally.
1202    #[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    /// The effect distinction is the point of this host, so it is asserted
1301    /// rather than left to a reader of the table.
1302    #[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    // ------------------------------------------ streaming: readers and writers
1322
1323    #[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    /// `write` puts down exactly what it was given, so a file whose last
1344    /// piece had no newline ends without one — and that piece is still a
1345    /// line.
1346    #[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    /// A `\r\n` ending is a `\n` ending whose terminator is two bytes, so
1360    /// neither byte reaches the program.
1361    #[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    /// The bound is the host's, so it is the same number on both
1390    /// implementations and the refusal names it.
1391    #[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    /// A handle is another way to name a path, so it is refused wherever a
1429    /// path is — with the reason `read` gives, since the checks are the ones
1430    /// `read` runs.
1431    #[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    /// One counter serves both kinds, so no two of this host's handles carry
1483    /// the same number.
1484    #[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    /// `create` prepares the tree below the root the way `write` does, so a
1500    /// program does not have to make the directories it is about to write in.
1501    #[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    /// A file the host was pointed at is whatever was in the directory, so a
1514    /// line that is not text is reported rather than being made into one.
1515    #[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    /// The fake publishes on every call rather than on `close`, so what a
1529    /// writer has written is readable while it is still open.
1530    #[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    /// ADR 0018's effects, asserted rather than left to a reader of the
1564    /// table: reading a line reads, writing one cannot be undone, and closing
1565    /// gives back what opening took.
1566    #[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}