use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum SsgError {
#[error("Core compilation error: {0}")]
Core(#[from] ssg_core::Error),
#[error("I/O error at '{path}': {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error(
"Security violation: path contains directory traversal ('..'): {path}"
)]
PathTraversal {
path: PathBuf,
},
#[error("Security violation: path resolves to a symlink: {path}")]
SymlinkForbidden {
path: PathBuf,
},
#[error("Validation failed for field '{field}': {message}")]
Validation {
field: String,
message: String,
},
#[cfg(feature = "templates")]
#[error("Template engine error: {0}")]
Template(#[from] minijinja::Error),
}
impl SsgError {
pub fn io(err: impl Into<anyhow::Error>, path: impl Into<PathBuf>) -> Self {
let anyhow_err = err.into();
let io_err = anyhow_err
.downcast::<std::io::Error>()
.unwrap_or_else(|e| std::io::Error::other(e.to_string()));
Self::Io {
path: path.into(),
source: io_err,
}
}
}
pub trait PathErrorExt<T> {
fn with_path(self, path: impl Into<PathBuf>) -> Result<T, SsgError>;
}
impl<T> PathErrorExt<T> for std::io::Result<T> {
fn with_path(self, path: impl Into<PathBuf>) -> Result<T, SsgError> {
self.map_err(|source| SsgError::Io {
path: path.into(),
source,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io;
#[test]
fn test_core_error() {
let core_err = ssg_core::Error::InvalidSlug {
input: "foo bar".into(),
};
let err = SsgError::Core(core_err);
let msg = format!("{err}");
assert!(msg.contains("Core compilation error"));
}
#[test]
fn test_io_error() {
let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found");
let err = SsgError::Io {
path: PathBuf::from("foo/bar"),
source: io_err,
};
let msg = format!("{err}");
assert!(msg.contains("I/O error at 'foo/bar'"));
}
#[test]
fn test_path_traversal() {
let err = SsgError::PathTraversal {
path: PathBuf::from("../escaped"),
};
let msg = format!("{err}");
assert!(msg
.contains("Security violation: path contains directory traversal"));
}
#[test]
fn test_symlink_forbidden() {
let err = SsgError::SymlinkForbidden {
path: PathBuf::from("symlink/path"),
};
let msg = format!("{err}");
assert!(msg.contains("Security violation: path resolves to a symlink"));
}
#[test]
fn test_validation() {
let err = SsgError::Validation {
field: "output".into(),
message: "cannot be empty".into(),
};
let msg = format!("{err}");
assert!(msg.contains("Validation failed for field 'output'"));
}
#[test]
#[cfg(feature = "templates")]
fn test_template_error() {
let source = minijinja::Error::new(
minijinja::ErrorKind::TemplateNotFound,
"missing template",
);
let err = SsgError::from(source);
let msg = format!("{err}");
assert!(msg.contains("Template engine error"));
}
#[test]
fn test_path_error_ext() {
let res: io::Result<()> =
Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied"));
let ssg_res = res.with_path("restricted/file");
assert!(ssg_res.is_err());
let err = ssg_res.unwrap_err();
if let SsgError::Io { path, .. } = err {
assert_eq!(path, PathBuf::from("restricted/file"));
} else {
panic!("Expected SsgError::Io");
}
}
}