use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::{fmt, fs, io};
use garde::Validate;
use minijinja::value::Value;
use minijinja::{Environment, State};
use serde::{Deserialize, Serialize};
use crate::validate::{inside_the_repo, one_file_name};
#[derive(Debug, Deserialize, Validate)]
#[garde(allow_unvalidated)]
struct Declared {
#[serde(default)]
#[garde(custom(inside_the_repo))]
path: PathBuf,
#[garde(custom(one_file_name))]
name: String,
#[serde(default)]
executable: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Served {
target: PathBuf,
body: String,
executable: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct ServeError {
pub template: String,
pub message: String,
io_failure: Option<io::ErrorKind>,
}
impl ServeError {
const fn refusal(template: String, message: String) -> Self {
Self {
template,
message,
io_failure: None,
}
}
fn failed_io(template: String, message: String, error: &io::Error) -> Self {
Self {
io_failure: Some(error.kind()),
..Self::refusal(template, message)
}
}
#[must_use]
pub const fn io_failure(&self) -> Option<io::ErrorKind> {
self.io_failure
}
}
impl fmt::Display for ServeError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}: {}", self.template, self.message)
}
}
impl std::error::Error for ServeError {}
pub fn render(
template: &Path,
source: &str,
suffix: &str,
context: &impl Serialize,
) -> Result<Served, ServeError> {
let failed = |message: String| ServeError::refusal(template.display().to_string(), message);
let own_name = template
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.strip_suffix(suffix))
.filter(|name| !name.is_empty())
.ok_or_else(|| failed(format!("template name does not end in {suffix}")))?;
let environment: Environment<'static> = crate::surface::environment()
.map_err(|error| failed(format!("register ctl-core fragments: {error}")))?;
let name = template.display().to_string();
let compiled = environment
.template_from_named_str(&name, source)
.map_err(|error| failed(format!("parse: {error}")))?;
let captured = compiled
.render_captured(Value::from_serialize(context))
.map_err(|error| failed(format!("render: {error}")))?;
let declared = declarations(captured.state(), own_name)
.map_err(|message| failed(format!("declarations: {message}")))?;
Ok(Served {
target: declared.path.join(&declared.name),
body: captured.output().to_owned(),
executable: declared.executable,
})
}
fn declarations(state: &State<'_, '_>, own_name: &str) -> Result<Declared, String> {
let mut exports: BTreeMap<&str, Value> = state
.exports()
.into_iter()
.filter_map(|name| state.lookup(name).map(|value| (name, value)))
.filter(|(_, value)| !value.is_undefined() && !value.is_none())
.collect();
exports
.entry("name")
.or_insert_with(|| Value::from(own_name));
let declared = Declared::deserialize(Value::from_serialize(&exports))
.map_err(|error| error.to_string())?;
declared.validate().map_err(|report| report.to_string())?;
Ok(declared)
}
impl Served {
#[must_use]
pub fn target(&self) -> &Path {
&self.target
}
#[must_use]
pub fn body(&self) -> &str {
&self.body
}
#[must_use]
pub fn executable(&self) -> bool {
self.executable
}
pub fn write(&self, root: &Path) -> Result<PathBuf, ServeError> {
let failed =
|message: String| ServeError::refusal(self.target.display().to_string(), message);
let io_failed = |message: String, error: &io::Error| {
ServeError::failed_io(self.target.display().to_string(), message, error)
};
let mut walked = root.to_path_buf();
for part in self.target.components() {
walked.push(part);
if fs::symlink_metadata(&walked).is_ok_and(|meta| meta.file_type().is_symlink()) {
return Err(failed(format!(
"{} must not be a symlink",
walked.strip_prefix(root).unwrap_or(&walked).display()
)));
}
}
let path = root.join(&self.target);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
io_failed(format!("create {}: {error}", parent.display()), &error)
})?;
let inside = fs::canonicalize(root)
.and_then(|real_root| Ok(fs::canonicalize(parent)?.starts_with(real_root)))
.map_err(|error| {
io_failed(format!("resolve {}: {error}", parent.display()), &error)
})?;
if !inside {
return Err(failed("target must stay inside the repository".to_owned()));
}
}
fs::write(&path, &self.body)
.map_err(|error| io_failed(format!("write: {error}"), &error))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = if self.executable { 0o755 } else { 0o644 };
fs::set_permissions(&path, fs::Permissions::from_mode(mode))
.map_err(|error| io_failed(format!("chmod: {error}"), &error))?;
}
Ok(path)
}
}