Skip to main content

forge_agent/env/
documents.rs

1//! The document read: one file from a session's tree, bounded, as bytes
2//! with a kind.
3//!
4//! Resolution is against the session's own working directory, and nothing
5//! outside that tree reads unless it sits under one of the project's
6//! configured document roots - the check is on the canonicalized path, so a
7//! `..` walk, an absolute path and a symlink pointing out are all refused
8//! the same way, and a spell the filesystem will not resolve is refused
9//! rather than read through its lexical collapse. The read is bounded by
10//! a ceiling and detects the kind by extension. Every refusal is a
11//! sentence naming its reason, because the reason is what a viewer draws
12//! and what a hand-off returns.
13
14use std::fs::File;
15use std::io::Read as _;
16use std::path::{Path, PathBuf};
17
18use forge_primitives::document::{DocumentKind, DocumentRead};
19
20/// Why a document could not be read. Every arm is a sentence a viewer draws
21/// and a hand-off returns, so the reason travels with the refusal.
22#[derive(Debug, thiserror::Error)]
23pub enum DocumentRefusal {
24    #[error("the path resolves outside the session's tree: {path}")]
25    OutsideTheTree { path: String },
26    #[error("no document at {path}")]
27    Missing { path: String },
28    #[error("the document could not be read: {path} ({why})")]
29    Unreadable { path: String, why: String },
30    #[error("the document is {}, over the {} ceiling", mib(*size_bytes), mib(*ceiling_bytes))]
31    OverCeiling { size_bytes: u64, ceiling_bytes: u64 },
32    #[error("a PDF opens read-only; it cannot be handed reviewable")]
33    PdfNotReviewable,
34    #[error("the document is not UTF-8 text: {path}")]
35    NotUtf8 { path: String },
36}
37
38/// Read one document out of `cwd`'s tree, or under one of `document_roots`.
39///
40/// `path` resolves against `cwd`. The read answers whether the DOCUMENT is
41/// reviewable - the commentable set the hand-off has always offered, by
42/// extension, while a PDF and a binary open read-only - so a viewer's door
43/// does not depend on how the page was reached. The Files reader's wider
44/// UTF-8 drawing classification is the CLIENT's policy, not this read's:
45/// the wire keeps the hand-off's extension kind, so the hand-off viewer and
46/// its documented behaviour are unchanged. The request's
47/// `reviewable` flag asks the hand-off's own question and is refused for a
48/// PDF, whose kind can never carry a review. The read is bounded by
49/// `ceiling_bytes`, and a refusal names its reason.
50///
51/// `document_roots` are the extra roots the project allows - the
52/// `document_roots` entries of its `forge.toml` project. Each is
53/// canonicalized before the comparison, like the requested path, so a
54/// root behind a symlink is judged by where it really points; a root
55/// that cannot be canonicalized is compared lexically, which refuses
56/// nothing a resolvable path would have been allowed under.
57pub fn read(
58    cwd: &Path,
59    document_roots: &[PathBuf],
60    path: &str,
61    reviewable: bool,
62    ceiling_bytes: u64,
63) -> Result<DocumentRead, DocumentRefusal> {
64    let root = cwd.canonicalize().map_err(|err| DocumentRefusal::Unreadable {
65        path: cwd.display().to_string(),
66        why: err.to_string(),
67    })?;
68    let (resolved, walked) = match resolve(&root, path) {
69        Landed::Walked(resolved) => (resolved, true),
70        Landed::Collapsed(resolved) => (resolved, false),
71    };
72    if !resolved.starts_with(&root) && !under_any_root(document_roots, &resolved) {
73        return Err(DocumentRefusal::OutsideTheTree { path: path.to_owned() });
74    }
75    if !walked {
76        // The spell does not resolve and the collapse is a different
77        // path, so nothing is opened through it: the spell names
78        // nothing, which is the miss it reports.
79        return Err(DocumentRefusal::Missing { path: path.to_owned() });
80    }
81    let metadata = std::fs::metadata(&resolved).map_err(|err| {
82        if err.kind() == std::io::ErrorKind::NotFound {
83            DocumentRefusal::Missing { path: path.to_owned() }
84        } else {
85            DocumentRefusal::Unreadable { path: path.to_owned(), why: err.to_string() }
86        }
87    })?;
88    if !metadata.is_file() {
89        return Err(DocumentRefusal::Unreadable {
90            path: path.to_owned(),
91            why: "not a file".to_owned(),
92        });
93    }
94    if metadata.len() > ceiling_bytes {
95        return Err(DocumentRefusal::OverCeiling { size_bytes: metadata.len(), ceiling_bytes });
96    }
97    let kind = kind_of(&resolved);
98    if matches!(kind, DocumentKind::Pdf) && reviewable {
99        return Err(DocumentRefusal::PdfNotReviewable);
100    }
101    let file = File::open(&resolved).map_err(|err| DocumentRefusal::Unreadable {
102        path: path.to_owned(),
103        why: err.to_string(),
104    })?;
105    // Taken rather than read whole: the ceiling bounds the allocation even
106    // for a file that grew past the metadata check.
107    let mut bytes = Vec::new();
108    let mut bounded = file.take(ceiling_bytes + 1);
109    bounded.read_to_end(&mut bytes).map_err(|err| DocumentRefusal::Unreadable {
110        path: path.to_owned(),
111        why: err.to_string(),
112    })?;
113    if bytes.len() as u64 > ceiling_bytes {
114        return Err(DocumentRefusal::OverCeiling { size_bytes: bytes.len() as u64, ceiling_bytes });
115    }
116    // A file the hand-off has always treated as text must still decode: a
117    // corrupt markdown or code file is refused, not handed on.
118    if reviewable_kind(&kind) && std::str::from_utf8(&bytes).is_err() {
119        return Err(DocumentRefusal::NotUtf8 { path: path.to_owned() });
120    }
121    let can_review = reviewable_kind(&kind);
122    let content_hash = crate::env::git_diff::resolver::content_hash_bytes(&bytes);
123    Ok(DocumentRead { path: resolved, kind, bytes, reviewable: can_review, content_hash })
124}
125
126/// The extensions whose files the hand-off can carry a review on.
127///
128/// **This is the hand-off's set, not the drawing policy.** The Files reader
129/// draws by the client's per-format policy - any UTF-8 file is text, an SVG
130/// is a picture - while a hand-off keeps its commentable set exactly as it
131/// was, so a `.rs` document still draws as it did and an extensionless
132/// Justfile opens read-only. A language added here is one the hand-off
133/// should comment, which is a different question from whether the reader can
134/// draw it.
135const TEXT_EXTENSIONS: &[&str] = &[
136    "rs", "ts", "mts", "cts", "tsx", "js", "mjs", "cjs", "jsx", "svelte", "py", "go", "json",
137    "toml", "yaml", "yml", "sh", "bash", "zsh", "css", "html", "htm", "xml", "svg", "sql", "kt",
138    "kts", "md", "markdown", "ini", "txt",
139];
140
141/// Whether a kind can carry a review at all: markdown, text and the code
142/// kinds draw as comment targets (the hand-off's comments ride a document
143/// read), and a PDF or an unrecognised kind opens read-only.
144fn reviewable_kind(kind: &DocumentKind) -> bool {
145    match kind {
146        DocumentKind::Markdown | DocumentKind::Text => true,
147        DocumentKind::Other(ext) => TEXT_EXTENSIONS.contains(&ext.as_str()),
148        DocumentKind::Pdf => false,
149    }
150}
151
152/// Whether `resolved` sits under one of the document roots. The comparison
153/// is by path component - `Path::starts_with` - so `MadnessX` is not
154/// under a `Madness` root.
155///
156/// The root is compared both as it resolves and as it was written: a
157/// MISSING path falls back to a lexical walk built from the request's
158/// own spelling, so a root reached through a symlink would otherwise
159/// refuse a missing document as an escape that its in-tree twin reports
160/// missing. The spelled arm can only ever match a path that is not on
161/// disk, which is what keeps it from admitting an escape.
162fn under_any_root(document_roots: &[PathBuf], resolved: &Path) -> bool {
163    document_roots.iter().any(|root| {
164        let canonical = root.canonicalize().unwrap_or_else(|_| lexical(root));
165        resolved.starts_with(&canonical) || resolved.starts_with(lexical(root))
166    })
167}
168
169/// What a requested path resolved to under `root`.
170enum Landed {
171    /// The path the read may open: canonical (every symlink in it
172    /// resolved), or the spelled path itself when only its tail is
173    /// missing - a `..` over a component that does not exist, say - so
174    /// metadata on it can only report the miss.
175    Walked(PathBuf),
176    /// A spell the filesystem would not resolve, collapsed LEXICALLY
177    /// into a DIFFERENT path. The rewrite can pop a `..` over a
178    /// component that does not exist and expose a later symlink, so this
179    /// path may exist, and may leave every root once its symlinks
180    /// resolve. It is judged for the refusal's classification only, and
181    /// never opened.
182    Collapsed(PathBuf),
183}
184
185/// Where `path` lands under `root`. A missing file has no canonical
186/// path, so its PARENT is canonicalized instead - what keeps the prefix
187/// check honest for a path whose directory is a symlink out. A path that
188/// canonicalizes neither way and needs no rewriting is `Walked` as the
189/// spelled path itself; one the collapse rewrites is `Collapsed`, so a
190/// `..` climbing past the root is still an escape when the chain it
191/// climbs through is not on disk, and a spell that collapses onto a
192/// symlink out is never read through the rewrite.
193fn resolve(root: &Path, path: &str) -> Landed {
194    let joined = root.join(path);
195    if let Ok(full) = joined.canonicalize() {
196        return Landed::Walked(full);
197    }
198    if let (Some(parent), Some(name)) = (joined.parent(), joined.file_name())
199        && let Ok(parent) = parent.canonicalize()
200    {
201        return Landed::Walked(parent.join(name));
202    }
203    let collapsed = lexical(&joined);
204    if collapsed == joined { Landed::Walked(joined) } else { Landed::Collapsed(collapsed) }
205}
206
207/// `path` with `.` dropped and `..` popped, filesystem untouched. Popping
208/// past the root is a no-op, as it is for the OS.
209fn lexical(path: &Path) -> PathBuf {
210    let mut out = PathBuf::new();
211    for part in path.components() {
212        match part {
213            std::path::Component::CurDir => {}
214            std::path::Component::ParentDir => {
215                out.pop();
216            }
217            other => out.push(other),
218        }
219    }
220    out
221}
222
223/// The kind by extension, lowercased; an extension the list does not know
224/// is kept as itself.
225fn kind_of(path: &Path) -> DocumentKind {
226    match path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_lowercase) {
227        None => DocumentKind::Other(String::new()),
228        Some(ext) if ext == "md" => DocumentKind::Markdown,
229        Some(ext) if ext == "pdf" => DocumentKind::Pdf,
230        Some(ext) if ext == "txt" => DocumentKind::Text,
231        Some(ext) => DocumentKind::Other(ext),
232    }
233}
234
235/// A byte count as a refusal writes it: whole MiB bare, otherwise one
236/// decimal. Tenths in integer space, so no float rounding enters a
237/// sentence a reader acts on.
238fn mib(bytes: u64) -> String {
239    const MIB: u128 = 1024 * 1024;
240    let tenths = (u128::from(bytes) * 10) / MIB;
241    if tenths.is_multiple_of(10) {
242        format!("{} MiB", tenths / 10)
243    } else {
244        format!("{}.{} MiB", tenths / 10, tenths % 10)
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use std::path::Path;
251
252    use forge_primitives::document::DocumentKind;
253    use tempfile::TempDir;
254
255    use super::{DocumentRefusal, read};
256
257    /// 1 MiB, so the over-ceiling case is small rather than a 10 MiB fixture.
258    const CEILING: u64 = 1024 * 1024;
259
260    fn fixture() -> TempDir {
261        tempfile::tempdir().expect("a tempdir")
262    }
263
264    /// Write `rel` under `root`, creating parents.
265    fn write(root: &Path, rel: &str, bytes: &[u8]) {
266        let path = root.join(rel);
267        if let Some(parent) = path.parent() {
268            std::fs::create_dir_all(parent).expect("mkdirs");
269        }
270        std::fs::write(path, bytes).expect("write fixture");
271    }
272
273    /// The control: a document inside the tree reads, resolved to its
274    /// canonical absolute path - and the cwd is handed in UNCANONICALIZED,
275    /// which on this machine is the `/var` -> `/private/var` case: a prefix
276    /// check that skipped canonicalization would refuse every file here.
277    #[test]
278    fn a_document_inside_the_tree_reads_resolved_and_with_its_kind() {
279        let dir = fixture();
280        let root = dir.path().canonicalize().expect("the fixture canonicalizes");
281        write(&root, "docs/readme.md", b"# hi\n");
282
283        let document =
284            read(dir.path(), &[], "docs/readme.md", false, CEILING).expect("the read lands");
285
286        assert_eq!(document.kind, DocumentKind::Markdown);
287        assert_eq!(document.bytes, b"# hi\n");
288        assert_eq!(document.path, root.join("docs/readme.md"), "the resolved absolute path");
289        assert!(document.reviewable, "a markdown document is reviewable whoever opened it");
290        assert_eq!(
291            document.content_hash,
292            crate::env::git_diff::resolver::content_hash_bytes(b"# hi\n"),
293            "and the document's own hash, which a thread's anchor compares against",
294        );
295    }
296
297    /// The kinds the list starts with, by extension, case-insensitive; an
298    /// extension the list does not know is kept as itself, and no extension
299    /// is kept as none. **The kind is the hand-off's**, not the Files
300    /// reader's drawing: the reader classifies UTF-8 by the client policy.
301    #[test]
302    fn each_extension_maps_to_its_kind_and_other_is_kept_as_itself() {
303        let dir = fixture();
304        write(dir.path(), "notes.md", b"# hi\n");
305        write(dir.path(), "paper.PDF", b"%PDF-1.4 fake");
306        write(dir.path(), "notes.txt", b"x");
307        write(dir.path(), "sketch.rs", b"x");
308        write(dir.path(), "LICENSE", b"x");
309
310        let kind = |rel: &str| read(dir.path(), &[], rel, false, CEILING).expect("reads").kind;
311        assert_eq!(kind("notes.md"), DocumentKind::Markdown);
312        assert_eq!(kind("paper.PDF"), DocumentKind::Pdf, "the extension's case does not matter");
313        assert_eq!(kind("notes.txt"), DocumentKind::Text);
314        assert_eq!(kind("sketch.rs"), DocumentKind::Other("rs".to_owned()));
315        assert_eq!(kind("LICENSE"), DocumentKind::Other(String::new()), "no extension, none kept");
316    }
317
318    /// **The read answers whether the DOCUMENT is reviewable, not what the
319    /// caller asked.** A markdown, text or code document a viewer opened
320    /// cold - the request flag false - is reviewable all the same, so the
321    /// hand-off draws its comments however the reader reached it; an
322    /// extensionless file, a PDF and a binary are not, whatever the caller
323    /// asked for. The Files reader still OPENS the extensionless text file:
324    /// its drawing policy does not consult this flag.
325    #[test]
326    fn the_read_answers_the_documents_own_reviewability_not_the_callers_flag() {
327        let dir = fixture();
328        write(dir.path(), "plan.md", b"# hi\n");
329        write(dir.path(), "notes.txt", b"x");
330        write(dir.path(), "paper.pdf", b"%PDF-1.4 fake");
331        write(dir.path(), "sketch.rs", b"fn main() {}");
332        write(dir.path(), "Justfile", b"check:\n");
333        write(dir.path(), "notes.weird", b"plain words\n");
334        write(dir.path(), "mark.svg", b"<svg/>");
335        write(dir.path(), "blob.png", b"\x89PNG fake");
336
337        // The hand-off's commentable set, by extension, as it always was.
338        for probe in ["plan.md", "notes.txt", "sketch.rs", "mark.svg"] {
339            let cold = read(dir.path(), &[], probe, false, CEILING).expect("reads");
340            assert!(cold.reviewable, "{probe}: reviewable however the reader got there");
341        }
342        // An extensionless or unknown extension is not in the hand-off's
343        // commentable set - the Files reader draws it, a hand-off does not.
344        for probe in ["Justfile", "notes.weird"] {
345            let text = read(dir.path(), &[], probe, false, CEILING).expect("reads");
346            assert!(!text.reviewable, "{probe}: not in the hand-off's commentable set");
347        }
348        for probe in ["paper.pdf", "blob.png"] {
349            let read_only = read(dir.path(), &[], probe, false, CEILING).expect("reads");
350            assert!(!read_only.reviewable, "{probe}: read-only whatever the caller asked");
351        }
352    }
353
354    #[test]
355    fn a_missing_document_is_refused_by_name() {
356        let dir = fixture();
357
358        let refusal =
359            read(dir.path(), &[], "docs/gone.md", false, CEILING).expect_err("nothing to read");
360
361        assert!(matches!(refusal, DocumentRefusal::Missing { .. }), "{refusal:?}");
362        assert!(
363            refusal.to_string().contains("docs/gone.md"),
364            "the refusal names the path: {refusal}"
365        );
366    }
367
368    /// Over the ceiling is refused naming both sizes, and the bytes are
369    /// never read: the bound holds before anything is allocated.
370    #[test]
371    fn a_document_over_the_ceiling_is_refused_naming_its_size() {
372        let dir = fixture();
373        write(dir.path(), "big.txt", &vec![b'x'; 2 * 1024 * 1024]);
374
375        let refusal =
376            read(dir.path(), &[], "big.txt", false, CEILING).expect_err("over the ceiling");
377
378        assert!(matches!(refusal, DocumentRefusal::OverCeiling { .. }), "{refusal:?}");
379        let why = refusal.to_string();
380        assert!(why.contains("2 MiB"), "names the document's size: {why}");
381        assert!(why.contains("1 MiB"), "and the ceiling it is over: {why}");
382    }
383
384    /// Nothing outside the session's tree reads, and every shape of escape
385    /// is refused BY NAME: a `..` walk, an absolute path, and a symlink that
386    /// points out from inside. The targets all EXIST, so `missing` cannot be
387    /// what refuses them.
388    #[test]
389    fn a_path_outside_the_session_tree_is_refused_by_name() {
390        let dir = fixture();
391        let base = dir.path().canonicalize().expect("the fixture canonicalizes");
392        let cwd = base.join("tree").join("inner");
393        std::fs::create_dir_all(&cwd).expect("the session tree");
394        write(&base, "outside.md", b"secret");
395        std::os::unix::fs::symlink(base.join("outside.md"), cwd.join("link.md"))
396            .expect("the escaping symlink");
397        let absolute = base.join("outside.md").to_str().expect("utf8").to_owned();
398
399        for probe in ["../../outside.md".to_owned(), absolute, "link.md".to_owned()] {
400            let refusal = read(&cwd, &[], &probe, false, CEILING).expect_err("escapes never read");
401            assert!(
402                matches!(refusal, DocumentRefusal::OutsideTheTree { .. }),
403                "{probe}: {refusal:?}"
404            );
405            assert!(refusal.to_string().contains(&probe), "the refusal names {probe}: {refusal}");
406        }
407    }
408
409    /// A `..` walk whose target does not exist is still an ESCAPE, not a
410    /// missing document: with no parent chain to canonicalize, the climb is
411    /// decided lexically, so the refusal names the boundary rather than a
412    /// file that was never there. A missing document INSIDE the tree keeps
413    /// reading missing, parents absent or not - the control.
414    #[test]
415    fn an_escape_through_a_missing_parent_chain_refuses_as_outside_the_tree() {
416        let dir = fixture();
417        let root = dir.path().canonicalize().expect("canonical");
418        let cwd = root.join("tree");
419        std::fs::create_dir_all(&cwd).expect("the session tree");
420
421        let refusal = read(&cwd, &[], "../../etc/hosts", false, CEILING)
422            .expect_err("climbing out never reads, existing or not");
423        assert!(
424            matches!(refusal, DocumentRefusal::OutsideTheTree { .. }),
425            "the boundary is the refusal: {refusal:?}",
426        );
427
428        let refusal = read(&cwd, &[], "gone/deeper/missing.md", false, CEILING)
429            .expect_err("a missing document inside the tree still reads missing");
430        assert!(matches!(refusal, DocumentRefusal::Missing { .. }), "{refusal:?}");
431    }
432
433    /// A file under a configured document root reads exactly as an in-tree
434    /// one does, and the root is handed in UNCANONICALIZED - on this
435    /// machine `dir.path()` is the `/var` -> `/private/var` case - so
436    /// the check canonicalizes both sides. An in-tree read with roots
437    /// configured is the control: roots widen, they never narrow.
438    #[test]
439    fn a_document_under_a_document_root_reads_as_an_in_tree_one() {
440        let dir = fixture();
441        let base = dir.path().canonicalize().expect("canonical");
442        let cwd = base.join("tree");
443        std::fs::create_dir_all(&cwd).expect("the session tree");
444        write(&base, "vault/notes.md", b"# vault\n");
445        write(&base, "tree/inside.md", b"# inside\n");
446        let roots = vec![dir.path().join("vault")];
447
448        let vaulted = base.join("vault/notes.md").to_str().expect("utf8").to_owned();
449        let document = read(&cwd, &roots, &vaulted, false, CEILING).expect("a vault file reads");
450        assert_eq!(document.kind, DocumentKind::Markdown, "the kind, as in-tree");
451        assert_eq!(document.bytes, b"# vault\n");
452        assert_eq!(document.path, base.join("vault/notes.md"), "resolved to its canonical path");
453
454        let inside = read(&cwd, &roots, "inside.md", false, CEILING).expect("the tree reads");
455        assert_eq!(inside.path, cwd.join("inside.md"), "the tree is not narrowed by the roots");
456    }
457
458    /// Outside the tree AND outside every root is the refusal it always
459    /// was, in the same sentence: a `..` walk, an absolute path and a
460    /// symlink pointing out are all refused by name with roots in play.
461    #[test]
462    fn a_path_outside_the_tree_and_every_root_still_refuses_by_name() {
463        let dir = fixture();
464        let base = dir.path().canonicalize().expect("canonical");
465        let cwd = base.join("tree");
466        std::fs::create_dir_all(&cwd).expect("the session tree");
467        std::fs::create_dir_all(base.join("vault")).expect("a root that holds nothing for it");
468        write(&base, "outside.md", b"secret");
469        std::os::unix::fs::symlink(base.join("outside.md"), cwd.join("link.md"))
470            .expect("the escaping symlink");
471        let absolute = base.join("outside.md").to_str().expect("utf8").to_owned();
472        let roots = vec![dir.path().join("vault")];
473
474        for probe in ["../outside.md".to_owned(), absolute, "link.md".to_owned()] {
475            let refusal =
476                read(&cwd, &roots, &probe, false, CEILING).expect_err("escapes never read");
477            assert!(
478                matches!(refusal, DocumentRefusal::OutsideTheTree { .. }),
479                "{probe}: {refusal:?}"
480            );
481            assert!(refusal.to_string().contains(&probe), "the refusal names {probe}: {refusal}");
482        }
483    }
484
485    /// A symlink under a root that points out of every root refuses:
486    /// the canonicalized target is what the check sees, not the link's
487    /// place. The root's own real file is the control.
488    #[test]
489    fn a_symlink_out_of_a_root_refuses() {
490        let dir = fixture();
491        let base = dir.path().canonicalize().expect("canonical");
492        let cwd = base.join("tree");
493        std::fs::create_dir_all(&cwd).expect("the session tree");
494        write(&base, "vault/real.md", b"# real\n");
495        write(&base, "elsewhere/secret.md", b"secret");
496        std::os::unix::fs::symlink(base.join("elsewhere/secret.md"), base.join("vault/link.md"))
497            .expect("the escaping symlink");
498        let roots = vec![base.join("vault")];
499
500        let link = base.join("vault/link.md").to_str().expect("utf8").to_owned();
501        let refusal = read(&cwd, &roots, &link, false, CEILING).expect_err("out of every root");
502        assert!(matches!(refusal, DocumentRefusal::OutsideTheTree { .. }), "{refusal:?}");
503
504        let real = base.join("vault/real.md").to_str().expect("utf8").to_owned();
505        read(&cwd, &roots, &real, false, CEILING).expect("the root's own file still reads");
506
507        // A DANGLING link out of every root reports missing rather than
508        // an escape: its target does not exist, and the in-tree twin of
509        // the same shape reports missing too. configuration.md states
510        // this classification, so this pins it.
511        std::os::unix::fs::symlink(base.join("elsewhere/gone.md"), base.join("vault/dangling.md"))
512            .expect("the dangling symlink");
513        let dangling = base.join("vault/dangling.md").to_str().expect("utf8").to_owned();
514        let refusal = read(&cwd, &roots, &dangling, false, CEILING).expect_err("nothing there");
515        assert!(matches!(refusal, DocumentRefusal::Missing { .. }), "{refusal:?}");
516    }
517
518    /// A missing document under a document root reports MISSING, exactly as
519    /// it does in-tree. The root here is reached through a symlink and
520    /// the request is spelled the same way, with no chain below it on
521    /// disk - the case where a canonical root compared against a
522    /// lexically-collapsed path would refuse as an escape instead.
523    #[test]
524    fn a_missing_document_under_a_symlinked_document_root_reports_missing() {
525        let dir = fixture();
526        let base = dir.path().canonicalize().expect("canonical");
527        let cwd = base.join("tree");
528        std::fs::create_dir_all(&cwd).expect("the session tree");
529        std::fs::create_dir_all(base.join("vault")).expect("the vault");
530        std::os::unix::fs::symlink(base.join("vault"), base.join("spelled")).expect("the symlink");
531        let roots = vec![base.join("spelled")];
532
533        let gone = base.join("spelled/gone/plan.md").to_str().expect("utf8").to_owned();
534        let refusal = read(&cwd, &roots, &gone, false, CEILING).expect_err("nothing to read");
535        assert!(matches!(refusal, DocumentRefusal::Missing { .. }), "{refusal:?}");
536        assert!(refusal.to_string().contains("gone/plan.md"), "names the path: {refusal}");
537    }
538
539    /// A sibling whose name starts with a root's is NOT inside it: the
540    /// comparison is by path component, so `MadnessX` is not under a
541    /// `Madness` root however their strings start.
542    #[test]
543    fn a_sibling_of_a_root_is_not_inside_it() {
544        let dir = fixture();
545        let base = dir.path().canonicalize().expect("canonical");
546        let cwd = base.join("tree");
547        std::fs::create_dir_all(&cwd).expect("the session tree");
548        write(&base, "Madness/plan.md", b"# plan\n");
549        write(&base, "MadnessX/plan.md", b"# plan\n");
550        let roots = vec![base.join("Madness")];
551
552        let root_file = base.join("Madness/plan.md").to_str().expect("utf8").to_owned();
553        read(&cwd, &roots, &root_file, false, CEILING).expect("the root itself reads");
554
555        let sibling = base.join("MadnessX/plan.md").to_str().expect("utf8").to_owned();
556        let refusal =
557            read(&cwd, &roots, &sibling, false, CEILING).expect_err("a sibling is not inside");
558        assert!(matches!(refusal, DocumentRefusal::OutsideTheTree { .. }), "{refusal:?}");
559    }
560
561    /// A spell whose `..` pops over a component that does not exist must
562    /// never be READ through its lexical collapse: `nope/../link/passwd`
563    /// collapses to `link/passwd`, and `link` is a symlink out of the
564    /// tree, so opening the collapsed path would read the link's target.
565    /// The spell names nothing, so it reports missing and nothing is
566    /// opened through it. A plain missing file under the tree still
567    /// reports missing, and the link asked for directly still refuses
568    /// as the escape it is - the controls.
569    #[test]
570    fn a_spell_that_collapses_onto_an_escaping_symlink_reads_nothing() {
571        let dir = fixture();
572        let base = dir.path().canonicalize().expect("canonical");
573        let cwd = base.join("tree");
574        std::fs::create_dir_all(&cwd).expect("the session tree");
575        let outside = base.join("elsewhere");
576        std::fs::create_dir_all(&outside).expect("the outside dir");
577        write(&base, "elsewhere/passwd", b"secret");
578        std::os::unix::fs::symlink(&outside, cwd.join("link")).expect("the escaping symlink");
579
580        let refusal = read(&cwd, &[], "nope/../link/passwd", false, CEILING)
581            .expect_err("a spell that names nothing never reads");
582        assert!(
583            matches!(refusal, DocumentRefusal::Missing { .. }),
584            "the spell names nothing: {refusal:?}"
585        );
586
587        let refusal = read(&cwd, &[], "gone/deeper/missing.md", false, CEILING)
588            .expect_err("a plain miss under the tree still reads missing");
589        assert!(matches!(refusal, DocumentRefusal::Missing { .. }), "{refusal:?}");
590
591        let refusal = read(&cwd, &[], "link/passwd", false, CEILING)
592            .expect_err("the link asked for directly is the escape");
593        assert!(matches!(refusal, DocumentRefusal::OutsideTheTree { .. }), "{refusal:?}");
594    }
595
596    /// A file the process cannot read is a refusal with the reason, not a
597    /// partial document.
598    #[test]
599    fn an_unreadable_document_is_refused_with_the_reason() {
600        use std::os::unix::fs::PermissionsExt as _;
601
602        let dir = fixture();
603        write(dir.path(), "locked.txt", b"x");
604        let locked = dir.path().join("locked.txt");
605        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("lock");
606
607        let refusal = read(dir.path(), &[], "locked.txt", false, CEILING).expect_err("locked");
608
609        // Unlocked before the assert so a failing explain leaves a deletable
610        // fixture behind rather than one a cleanup walks around.
611        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o644)).expect("unlock");
612        assert!(matches!(refusal, DocumentRefusal::Unreadable { .. }), "{refusal:?}");
613        assert!(
614            refusal.to_string().contains("locked.txt"),
615            "the refusal names the path: {refusal}"
616        );
617    }
618
619    /// A PDF reads read-only - and is refused the moment the hand-off asks
620    /// for it reviewable, naming the reason. The rule lives in the READ, so
621    /// every door onto a document refuses with the same sentence.
622    #[test]
623    fn a_pdf_reads_read_only_and_a_reviewable_flag_is_refused() {
624        let dir = fixture();
625        write(dir.path(), "paper.pdf", b"%PDF-1.4 fake");
626
627        let document = read(dir.path(), &[], "paper.pdf", false, CEILING).expect("a PDF reads");
628        assert_eq!(document.kind, DocumentKind::Pdf);
629        assert_eq!(document.bytes, b"%PDF-1.4 fake");
630
631        let refusal =
632            read(dir.path(), &[], "paper.pdf", true, CEILING).expect_err("reviewable is refused");
633        assert!(matches!(refusal, DocumentRefusal::PdfNotReviewable), "{refusal:?}");
634        assert!(refusal.to_string().contains("read-only"), "names the reason: {refusal}");
635    }
636
637    #[test]
638    fn a_zero_byte_document_reads_with_no_bytes() {
639        let dir = fixture();
640        write(dir.path(), "empty.md", b"");
641
642        let document =
643            read(dir.path(), &[], "empty.md", false, CEILING).expect("an empty file is a document");
644
645        assert_eq!(document.kind, DocumentKind::Markdown);
646        assert!(document.bytes.is_empty());
647    }
648
649    /// **A commentable extension that does not decode is still REFUSED**,
650    /// as it always was: the hand-off cannot carry comments on bytes it
651    /// cannot read, and a corrupt markdown file must not silently become a
652    /// binary. A kind outside that set - an image, an unknown extension -
653    /// is a binary read, which the Files reader draws as its notice.
654    #[test]
655    fn a_non_utf8_document_is_refused_when_commentable_and_binary_otherwise() {
656        let dir = fixture();
657        let bytes = [0xff, 0xfe, 0x00, 0x01];
658        write(dir.path(), "broken.md", &bytes);
659        write(dir.path(), "broken.txt", &bytes);
660        write(dir.path(), "broken.rs", &bytes);
661        write(dir.path(), "blob.png", &bytes);
662        write(dir.path(), "noext", &bytes);
663
664        for probe in ["broken.md", "broken.txt", "broken.rs"] {
665            let refusal = read(dir.path(), &[], probe, false, CEILING)
666                .expect_err("a commentable extension must decode");
667            assert!(matches!(refusal, DocumentRefusal::NotUtf8 { .. }), "{probe}: {refusal:?}");
668            assert!(refusal.to_string().contains("UTF-8"), "{probe}: names the reason: {refusal}");
669        }
670
671        for (probe, ext) in [("blob.png", "png"), ("noext", "")] {
672            let binary =
673                read(dir.path(), &[], probe, false, CEILING).expect("a binary read is a read");
674            assert!(!binary.reviewable, "{probe}: binary is not reviewable");
675            assert_eq!(binary.kind, DocumentKind::Other(ext.to_owned()), "{probe}: its extension");
676        }
677    }
678}