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}