Skip to main content

jsonld_core/loader/
fs.rs

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/// Loading error.
14#[derive(Debug, thiserror::Error)]
15pub enum Error {
16    /// No mount point found for the given IRI.
17    #[error("no mount point")]
18    NoMountPoint,
19
20    /// IO error.
21    #[error("IO: {0}")]
22    IO(std::io::Error),
23
24    /// Parse error.
25    #[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/// File-system loader.
35///
36/// This is a special JSON-LD document loader that can load document from the file system by
37/// attaching a directory to specific URLs.
38///
39/// Loaded documents are not cached: a new file system read is made each time
40/// an URL is loaded even if it has already been queried before.
41#[derive(Default)]
42pub struct FsLoader {
43    mount_points: Vec<(PathBuf, IriBuf)>,
44}
45
46impl FsLoader {
47    /// Creates a new file system loader with the given content `parser`.
48    #[must_use]
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Bind the given IRI prefix to the given path.
54    ///
55    /// Any document with an IRI matching the given prefix will be loaded from
56    /// the referenced local directory.
57    #[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    /// Returns the local file path associated to the given `url` if any.
63    ///
64    /// The URL must match a mount point at a path boundary (mounting
65    /// `http://example.com/a` does not capture `http://example.com/abc`), and
66    /// the path is built only from single normal components: URLs containing
67    /// `.` or `..` segments (or segments the platform would split further,
68    /// like `\` on Windows) return `None`.
69    ///
70    /// This bounds the *constructed* path to the mounted directory. It does
71    /// not follow the file system: a symbolic link inside the mount can still
72    /// point outside it, so mount only directories whose contents you trust.
73    #[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                // Require a path boundary right after the matched prefix.
78                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                    // Only a single normal component may reach the file
89                    // system: `.`, `..` or anything the platform parses as
90                    // several components could escape the mount directory.
91                    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}