1use super::{LD_JSON_MEDIA_TYPE, Loader, RemoteDocument};
2use crate::LoadError;
3use iri_rs::{Iri, IriBuf};
4use jstrict::Parse;
5use mediatype::MediaTypeBuf;
6use std::{
7 fs::File,
8 io::{BufReader, Read},
9 path::{Path, PathBuf},
10 sync::OnceLock,
11};
12
13#[derive(Debug, thiserror::Error)]
15pub enum Error {
16 #[error("no mount point")]
18 NoMountPoint,
19
20 #[error("IO: {0}")]
22 IO(std::io::Error),
23
24 #[error("parse error: {0}")]
26 Parse(jstrict::parse::Error),
27}
28
29fn ld_json_mime() -> MediaTypeBuf {
30 static MIME: OnceLock<MediaTypeBuf> = OnceLock::new();
31 MIME.get_or_init(|| LD_JSON_MEDIA_TYPE.into()).clone()
32}
33
34#[derive(Default)]
42pub struct FsLoader {
43 mount_points: Vec<(PathBuf, IriBuf)>,
44}
45
46impl FsLoader {
47 #[must_use]
49 pub fn new() -> Self {
50 Self::default()
51 }
52
53 #[inline(always)]
58 pub fn mount<P: AsRef<Path>>(&mut self, url: IriBuf, path: P) {
59 self.mount_points.push((path.as_ref().into(), url));
60 }
61
62 #[must_use]
74 pub fn filepath(&self, url: Iri<&str>) -> Option<PathBuf> {
75 for (path, target_url) in &self.mount_points {
76 if let Some(suffix) = url.as_str().strip_prefix(target_url.as_str()) {
77 if !(suffix.is_empty() || suffix.starts_with('/') || target_url.as_str().ends_with('/')) {
79 continue;
80 }
81
82 let mut filepath = path.clone();
83 for seg in suffix.trim_start_matches('/').split('/') {
84 if seg.is_empty() {
85 continue;
86 }
87
88 let mut components = Path::new(seg).components();
92 match (components.next(), components.next()) {
93 (Some(std::path::Component::Normal(_)), None) => filepath.push(seg),
94 _ => return None,
95 }
96 }
97
98 return Some(filepath);
99 }
100 }
101
102 None
103 }
104}
105
106impl Loader for FsLoader {
107 type Error = Error;
108
109 async fn load(&self, url: Iri<&str>) -> Result<RemoteDocument<IriBuf>, LoadError<Self::Error>> {
110 match self.filepath(url) {
111 Some(filepath) => {
112 let file = File::open(filepath).map_err(|e| LoadError::new(url.into(), Error::IO(e)))?;
113 let mut buf_reader = BufReader::new(file);
114 let mut contents = String::new();
115 buf_reader.read_to_string(&mut contents).map_err(|e| LoadError::new(url.into(), Error::IO(e)))?;
116 let (doc, _) = jstrict::Value::parse_str(&contents).map_err(|e| LoadError::new(url.into(), Error::Parse(e)))?;
117 Ok(RemoteDocument::new(Some(url.into()), Some(ld_json_mime()), doc))
118 }
119 None => Err(LoadError::new(url.into(), Error::NoMountPoint)),
120 }
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 #![allow(clippy::unwrap_used)]
127 use super::*;
128
129 fn loader() -> FsLoader {
130 let mut loader = FsLoader::new();
131 loader.mount(IriBuf::new("http://example.com/mount".to_string()).unwrap(), "/srv/data");
132 loader
133 }
134
135 #[test]
136 fn filepath_resolves_inside_mount() {
137 assert_eq!(
138 loader().filepath(Iri::parse("http://example.com/mount/a/b.json").unwrap()),
139 Some(PathBuf::from("/srv/data/a/b.json"))
140 );
141 }
142
143 #[test]
144 fn filepath_rejects_parent_segments() {
145 assert_eq!(loader().filepath(Iri::parse("http://example.com/mount/../../etc/secret").unwrap()), None);
146 assert_eq!(loader().filepath(Iri::parse("http://example.com/mount/a/./b").unwrap()), None);
147 }
148
149 #[test]
150 fn filepath_requires_prefix_boundary() {
151 assert_eq!(loader().filepath(Iri::parse("http://example.com/mounted/a").unwrap()), None);
152 }
153}