1use std::fs::File;
15use std::io::Read as _;
16use std::path::{Path, PathBuf};
17
18use forge_primitives::document::{DocumentKind, DocumentRead};
19
20#[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
38pub 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 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 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 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
126const 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
141fn 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
152fn 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
169enum Landed {
171 Walked(PathBuf),
176 Collapsed(PathBuf),
183}
184
185fn 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
207fn 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
223fn 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
235fn 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 const CEILING: u64 = 1024 * 1024;
259
260 fn fixture() -> TempDir {
261 tempfile::tempdir().expect("a tempdir")
262 }
263
264 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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 #[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 #[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}