use super::{LD_JSON_MEDIA_TYPE, Loader, RemoteDocument};
use crate::LoadError;
use iri_rs::{Iri, IriBuf};
use jstrict::Parse;
use mediatype::MediaTypeBuf;
use std::{
fs::File,
io::{BufReader, Read},
path::{Path, PathBuf},
sync::OnceLock,
};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("no mount point")]
NoMountPoint,
#[error("IO: {0}")]
IO(std::io::Error),
#[error("parse error: {0}")]
Parse(jstrict::parse::Error),
}
fn ld_json_mime() -> MediaTypeBuf {
static MIME: OnceLock<MediaTypeBuf> = OnceLock::new();
MIME.get_or_init(|| LD_JSON_MEDIA_TYPE.into()).clone()
}
#[derive(Default)]
pub struct FsLoader {
mount_points: Vec<(PathBuf, IriBuf)>,
}
impl FsLoader {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[inline(always)]
pub fn mount<P: AsRef<Path>>(&mut self, url: IriBuf, path: P) {
self.mount_points.push((path.as_ref().into(), url));
}
#[must_use]
pub fn filepath(&self, url: Iri<&str>) -> Option<PathBuf> {
for (path, target_url) in &self.mount_points {
if let Some(suffix) = url.as_str().strip_prefix(target_url.as_str()) {
if !(suffix.is_empty() || suffix.starts_with('/') || target_url.as_str().ends_with('/')) {
continue;
}
let mut filepath = path.clone();
for seg in suffix.trim_start_matches('/').split('/') {
if seg.is_empty() {
continue;
}
let mut components = Path::new(seg).components();
match (components.next(), components.next()) {
(Some(std::path::Component::Normal(_)), None) => filepath.push(seg),
_ => return None,
}
}
return Some(filepath);
}
}
None
}
}
impl Loader for FsLoader {
type Error = Error;
async fn load(&self, url: Iri<&str>) -> Result<RemoteDocument<IriBuf>, LoadError<Self::Error>> {
match self.filepath(url) {
Some(filepath) => {
let file = File::open(filepath).map_err(|e| LoadError::new(url.into(), Error::IO(e)))?;
let mut buf_reader = BufReader::new(file);
let mut contents = String::new();
buf_reader.read_to_string(&mut contents).map_err(|e| LoadError::new(url.into(), Error::IO(e)))?;
let (doc, _) = jstrict::Value::parse_str(&contents).map_err(|e| LoadError::new(url.into(), Error::Parse(e)))?;
Ok(RemoteDocument::new(Some(url.into()), Some(ld_json_mime()), doc))
}
None => Err(LoadError::new(url.into(), Error::NoMountPoint)),
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn loader() -> FsLoader {
let mut loader = FsLoader::new();
loader.mount(IriBuf::new("http://example.com/mount".to_string()).unwrap(), "/srv/data");
loader
}
#[test]
fn filepath_resolves_inside_mount() {
assert_eq!(
loader().filepath(Iri::parse("http://example.com/mount/a/b.json").unwrap()),
Some(PathBuf::from("/srv/data/a/b.json"))
);
}
#[test]
fn filepath_rejects_parent_segments() {
assert_eq!(loader().filepath(Iri::parse("http://example.com/mount/../../etc/secret").unwrap()), None);
assert_eq!(loader().filepath(Iri::parse("http://example.com/mount/a/./b").unwrap()), None);
}
#[test]
fn filepath_requires_prefix_boundary() {
assert_eq!(loader().filepath(Iri::parse("http://example.com/mounted/a").unwrap()), None);
}
}