Skip to main content

prov_graph/graph/
load.rs

1//! The read primitive — root-escape clamp, read-scope memo, filesystem read,
2//! [`Document::parse`] — that every pass built on top of the graph shares.
3//! See the module doc at [`crate::graph`] for how this sits beside
4//! [`resolve`](super::resolve) and the census.
5
6use std::path::Path;
7
8use super::Graph;
9use crate::document::{Body, Document};
10use crate::error::{Error, Result};
11use crate::fs::ReadStorage;
12use crate::link;
13
14impl<FS: ReadStorage, Ix> Graph<FS, Ix> {
15    /// Read and parse the workspace-relative document at `path`, returning the
16    /// raw text alongside. The building block traversal, validation, and
17    /// mutation share.
18    pub async fn load(&self, path: &Path) -> Result<(String, Document)> {
19        // Clamp reads to the workspace root: `path` may originate in a document's
20        // own metadata (a `contents`/`part_of` target), so a hostile or careless
21        // `../../../etc/passwd` must be refused here rather than opened. The
22        // traversal turns this error into an `Unreadable` node; a direct caller
23        // sees the `Escape` error itself.
24        if link::escapes_root(path) {
25            return Err(Error::Escape(path.to_path_buf()));
26        }
27        // Inside a `read_scope`, a document already read this operation is
28        // answered from memory — the escape check above still runs first, so a
29        // memo can never be the thing that lets a hostile path through.
30        if let Some(hit) = self.memo_hit(path) {
31            return Ok(hit);
32        }
33        let text = self.fs().read_to_string(&self.root().join(path)).await?;
34        let doc = Document::parse(path, &text)?;
35        self.memo_remember(path, &text, &doc);
36        Ok((text, doc))
37    }
38
39    /// Read and parse the workspace-relative document at `path`, returning its
40    /// full [`Document`] — the public counterpart to [`load`](Self::load), for
41    /// a caller walking a [`Node`](crate::graph::Node) tree who needs more than
42    /// [`Node::title`](crate::graph::Node::title) (the rest of the frontmatter,
43    /// the body, the carrier) without re-reading and re-parsing the file by
44    /// hand.
45    ///
46    /// Unlike the traversal, which degrades a bad target to a
47    /// [`NodeKind::Unreadable`](crate::graph::NodeKind::Unreadable) node, this
48    /// surfaces the [`Error`] directly — a caller who names a path expects to
49    /// know why it failed, not to receive a placeholder.
50    pub async fn document(&self, path: impl AsRef<Path>) -> Result<Document> {
51        let path = link::normalize(path);
52        self.load(&path).await.map(|(_, doc)| doc)
53    }
54
55    /// The prose body of the document at `path`, wherever it physically lives —
56    /// the read-side counterpart to the resolution `content_hash` already makes
57    /// (`prov`'s `Workspace::covered_digest`) and the census already makes
58    /// ([`Graph::census`](super::Graph::census)).
59    ///
60    /// For a combined document this is [`Document::body`] and the document's own
61    /// path, so a caller pays one read and gets what it always had. For a
62    /// *separated* one — a whole-file metadata node whose `content` names a
63    /// sibling prose file — it is the sibling's text. [`Document`] is a per-file
64    /// parse and deliberately stays one: it reports what its own file says, and
65    /// the splicing belongs to the layer that can reach the other file.
66    ///
67    /// The `content` target is clamped the same way [`load`](Self::load) clamps
68    /// its own argument, and for the same reason: it is a path read *out of a
69    /// document*, so `content: ../../../etc/passwd` is data naming a file
70    /// outside the workspace and must be refused rather than opened.
71    ///
72    /// An **attachment sidecar** is refused rather than read. Its `content`
73    /// names opaque bytes — a JPEG, a PDF — and `attach --opaque` promises prov
74    /// will never open them as a document; returning them as a `String` would
75    /// break that promise, and on most payloads would fail as invalid UTF-8
76    /// anyway, which is a confusing way to learn the file was never prose.
77    pub async fn body(&self, path: impl AsRef<Path>) -> Result<Body> {
78        let path = link::normalize(path);
79        let (_, doc) = self.load(&path).await?;
80        let Some(content) = doc.content_path(&path) else {
81            return Ok(Body {
82                text: doc.body,
83                path,
84            });
85        };
86        // Clamped before it is classified. `is_attachment` reads the *target's*
87        // extension, so an escaping path is also an opaque-looking one more often
88        // than not (`../../../etc/passwd` has no extension prov reads) — and
89        // "that is a payload, not prose" is a description of the file, offered
90        // where "that file is not yours to name" is the answer.
91        if link::escapes_root(&content) {
92            return Err(Error::Escape(content));
93        }
94        if doc.is_attachment() {
95            return Err(Error::Structure(format!(
96                "{}: attachment sidecar for {} — an opaque payload, not a prose body",
97                path.display(),
98                content.display(),
99            )));
100        }
101        let text = self.read_text(&content).await?;
102        Ok(Body {
103            text,
104            path: content,
105        })
106    }
107}
108
109// These tests use YAML frontmatter fixtures, so they run under the `yaml` feature.
110#[cfg(all(test, feature = "yaml"))]
111mod tests {
112    use std::path::PathBuf;
113
114    use super::*;
115    use crate::exec::block_on;
116    use crate::fs::StdFs;
117    use crate::graph::ReadSettings;
118    use crate::index::NoIndex;
119
120    use prov_testkit::write;
121    fn tempdir(tag: &str) -> PathBuf {
122        prov_testkit::scratch("load", tag)
123    }
124
125    #[test]
126    fn document_reads_full_metadata_for_a_workspace_relative_path() {
127        let dir = tempdir("document");
128        write(
129            &dir,
130            "notes/a.md",
131            "---\ntitle: A\nauthor: Ada\n---\nbody text\n",
132        );
133
134        let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
135        let doc = block_on(ws.document("notes/a.md")).unwrap();
136        let meta = fig::Value::from(&doc.meta);
137        assert_eq!(meta.get("title").and_then(fig::Value::as_str), Some("A"));
138        assert_eq!(meta.get("author").and_then(fig::Value::as_str), Some("Ada"));
139        assert_eq!(doc.body, "body text\n");
140    }
141
142    #[test]
143    fn document_surfaces_the_error_for_an_unreadable_path() {
144        let dir = tempdir("document-missing");
145        let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
146        assert!(block_on(ws.document("nope.md")).is_err());
147    }
148
149    #[test]
150    fn body_of_a_combined_document_is_its_own_prose_and_its_own_path() {
151        let dir = tempdir("body-combined");
152        write(&dir, "notes/a.md", "---\ntitle: A\n---\nbody text\n");
153
154        let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
155        let body = block_on(ws.body("notes/a.md")).unwrap();
156        assert_eq!(body.text, "body text\n");
157        assert_eq!(body.path, PathBuf::from("notes/a.md"));
158    }
159
160    /// The gap this method exists to close: the node's *own* body is empty, and
161    /// reading that as the document's prose reports "no prose" for a document
162    /// that has plenty.
163    #[test]
164    fn body_of_a_separated_document_is_the_file_its_content_names() {
165        let dir = tempdir("body-separated");
166        write(&dir, "notes/a.yaml", "title: A\ncontent: a.md\n");
167        write(&dir, "notes/a.md", "# Heading\n\nseparated prose\n");
168
169        let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
170        let node = block_on(ws.document("notes/a.yaml")).unwrap();
171        assert_eq!(node.body, "", "the node's own file carries no prose");
172
173        let body = block_on(ws.body("notes/a.yaml")).unwrap();
174        assert_eq!(body.text, "# Heading\n\nseparated prose\n");
175        assert_eq!(
176            body.path,
177            PathBuf::from("notes/a.md"),
178            "the path a caller asks for the body's grammar"
179        );
180    }
181
182    /// `attach --opaque` promises prov never opens the payload as a document.
183    #[test]
184    fn body_refuses_an_attachment_sidecar_rather_than_reading_its_payload() {
185        let dir = tempdir("body-attachment");
186        write(&dir, "photo.jpg.yaml", "title: Photo\ncontent: photo.jpg\n");
187        write(&dir, "photo.jpg", "not really a jpeg");
188
189        let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
190        let err = block_on(ws.body("photo.jpg.yaml")).unwrap_err();
191        assert!(
192            err.to_string().contains("opaque payload"),
193            "unexpected error: {err}"
194        );
195    }
196
197    /// `content` is a path read *out of a document*, so it is data and gets the
198    /// clamp every other data-borne path gets.
199    #[test]
200    fn body_refuses_a_content_target_that_escapes_the_root() {
201        let dir = tempdir("body-escape");
202        write(&dir, "a.yaml", "title: A\ncontent: ../../../etc/passwd\n");
203
204        let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
205        assert!(matches!(block_on(ws.body("a.yaml")), Err(Error::Escape(_))));
206    }
207}