diff --git a/src/generate.rs b/src/generate.rs index fca2d2b..75461f4 100644 --- a/src/generate.rs +++ b/src/generate.rs @@ -145,7 +145,9 @@ impl GitsyGenerator { Ok(file) } - fn write_rendered(&self, path: &str, rendered: &str) -> usize { + fn write_rendered>(&self, path: &P, rendered: &str) -> usize { + let path = path.as_ref().to_str() + .expect(&format!("ERROR: attempted to write unrecognizeable path: {}", path.as_ref().display())); // Ensure that the requested output path is actually a child // of the output directory, as a sanity check to ensure we // aren't writing out of bounds. @@ -364,7 +366,7 @@ impl GitsyGenerator { } local_ctx.insert("site_dir", &self.settings.outputs.output_dir()); if self.settings.outputs.global_assets.is_some() { - local_ctx.insert("site_assets", &self.settings.outputs.to_relative(&self.settings.outputs.global_assets(None, None))); + local_ctx.insert("site_assets", &self.settings.outputs.to_relative(&self.settings.outputs.global_assets::(None, None))); } local_ctx.insert("site_generated_ts", &generated_dt.timestamp()); local_ctx.insert("site_generated_offset", &generated_dt.offset().local_minus_utc()); @@ -385,7 +387,7 @@ impl GitsyGenerator { match tera.render(templ_file, &local_ctx) { Ok(rendered) => { repo_bytes += - self.write_rendered(&self.settings.outputs.summary(Some(&parsed_repo), None), &rendered); + self.write_rendered(&self.settings.outputs.summary::(Some(&parsed_repo), None), &rendered); } Err(x) => match x.kind { _ => error!("ERROR: {:?}", x), @@ -402,7 +404,7 @@ impl GitsyGenerator { let pagination = Pagination::new( idx + 1, page_count, - &self.settings.outputs.branches(Some(&parsed_repo), None), + &self.settings.outputs.branches::(Some(&parsed_repo), None), ); paged_ctx.insert("page", &pagination.with_relative_paths()); paged_ctx.insert("branches", &page); @@ -443,7 +445,7 @@ impl GitsyGenerator { let page_count = pages.len(); for (idx, page) in pages.enumerate() { let pagination = - Pagination::new(idx + 1, page_count, &self.settings.outputs.tags(Some(&parsed_repo), None)); + Pagination::new(idx + 1, page_count, &self.settings.outputs.tags::(Some(&parsed_repo), None)); paged_ctx.insert("page", &pagination.with_relative_paths()); paged_ctx.insert("tags", &page); match tera.render(templ_file, &paged_ctx) { @@ -491,7 +493,7 @@ impl GitsyGenerator { let pagination = Pagination::new( idx + 1, page_count, - &self.settings.outputs.history(Some(&parsed_repo), None), + &self.settings.outputs.history::(Some(&parsed_repo), None), ); paged_ctx.insert("page", &pagination.with_relative_paths()); paged_ctx.insert("history", &page); @@ -542,7 +544,7 @@ impl GitsyGenerator { let css: String = css_for_theme_with_class_style(theme, syntect::html::ClassStyle::Spaced) .expect("Invalid syntax highlighting theme specified."); repo_bytes += - self.write_rendered(&self.settings.outputs.syntax_css(Some(&parsed_repo), None), css.as_str()); + self.write_rendered(&self.settings.outputs.syntax_css::(Some(&parsed_repo), None), css.as_str()); } // TODO: parallelize the rest of the processing steps. This one is @@ -620,7 +622,7 @@ impl GitsyGenerator { match tera.render(templ_file, &local_ctx) { Ok(rendered) => { repo_bytes += - self.write_rendered(&self.settings.outputs.files(Some(&parsed_repo), None), &rendered); + self.write_rendered(&self.settings.outputs.files::(Some(&parsed_repo), None), &rendered); } Err(x) => match x.kind { _ => error!("ERROR: {:?}", x), @@ -629,7 +631,7 @@ impl GitsyGenerator { } if repo_desc.asset_files.is_some() { - let target_dir = self.settings.outputs.repo_assets(Some(&parsed_repo), None); + let target_dir = self.settings.outputs.repo_assets::(Some(&parsed_repo), None); for src_file in repo_desc.asset_files.as_ref().unwrap() { let src_file = src_file.replace("%TEMPLATE%", &self.settings.templates.template_dir()); let src_file = src_file.replace("%REPO%", &repo_path); @@ -692,7 +694,7 @@ impl GitsyGenerator { } global_ctx.insert("site_dir", &self.settings.outputs.output_dir()); if self.settings.outputs.global_assets.is_some() { - global_ctx.insert("site_assets", &self.settings.outputs.to_relative(&self.settings.outputs.global_assets(None, None))); + global_ctx.insert("site_assets", &self.settings.outputs.to_relative(&self.settings.outputs.global_assets::(None, None))); } global_ctx.insert("site_generated_ts", &generated_dt.timestamp()); global_ctx.insert("site_generated_offset", &generated_dt.offset().local_minus_utc()); @@ -700,7 +702,7 @@ impl GitsyGenerator { if let Some(templ_file) = self.settings.templates.repo_list.as_deref() { match tera.render(templ_file, &global_ctx) { Ok(rendered) => { - global_bytes += self.write_rendered(&self.settings.outputs.repo_list(None, None), &rendered); + global_bytes += self.write_rendered(&self.settings.outputs.repo_list::(None, None), &rendered); } Err(x) => match x.kind { _ => error!("ERROR: {:?}", x), @@ -711,7 +713,7 @@ impl GitsyGenerator { if let Some(templ_file) = self.settings.templates.error.as_deref() { match tera.render(templ_file, &global_ctx) { Ok(rendered) => { - global_bytes += self.write_rendered(&self.settings.outputs.error(None, None), &rendered); + global_bytes += self.write_rendered(&self.settings.outputs.error::(None, None), &rendered); } Err(x) => match x.kind { _ => error!("ERROR: {:?}", x), @@ -720,7 +722,7 @@ impl GitsyGenerator { } if self.settings.asset_files.is_some() { - let target_dir = self.settings.outputs.global_assets(None, None); + let target_dir = self.settings.outputs.global_assets::(None, None); for src_file in self.settings.asset_files.as_ref().unwrap() { let src_file = src_file.replace("%TEMPLATE%", &self.settings.templates.template_dir()); let src_file = PathBuf::from(src_file); @@ -752,7 +754,7 @@ impl GitsyGenerator { ); if self.cli.should_open { - let _ = open::that(&format!("file://{}", self.settings.outputs.repo_list(None, None))); + let _ = open::that(&format!("file://{}", self.settings.outputs.repo_list::(None, None).display())); } Ok(()) diff --git a/src/git.rs b/src/git.rs index f2d4509..d8268db 100644 --- a/src/git.rs +++ b/src/git.rs @@ -21,11 +21,13 @@ * along with Itsy-Gitsy. If not, see . */ use crate::settings::GitsySettingsRepo; +use crate::util::{sanitize_path_component, SafePathVar, urlify_path}; use crate::{error, loud, loudest}; use git2::{DiffOptions, Error, Repository}; use serde::{Deserialize, Serialize}; use std::cell::RefCell; use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::atomic::Ordering; @@ -86,6 +88,21 @@ impl GitRepo { } } +impl SafePathVar for GitRepo { + fn safe_substitute(&self, path: &impl AsRef) -> PathBuf { + let src: &Path = path.as_ref(); + let mut dst = PathBuf::new(); + let safe_name = sanitize_path_component(&self.name); + for cmp in src.components() { + let cmp = cmp.as_os_str().to_string_lossy().replace("%REPO%", &safe_name); + dst.push(cmp); + } + assert!(src.components().count() == dst.components().count(), + "ERROR: path substitution accidentally created a new folder in: {}", src.display()); + dst + } +} + #[derive(Clone, Serialize, Default)] pub struct GitsyMetadata { pub full_name: Option, @@ -120,6 +137,26 @@ pub struct GitObject { pub diff: Option, } +impl SafePathVar for GitObject { + fn safe_substitute(&self, path: &impl AsRef) -> PathBuf { + let src: &Path = path.as_ref(); + let mut dst = PathBuf::new(); + let safe_full_hash = sanitize_path_component(&self.full_hash); + let safe_ref = self.ref_name.as_deref() + .map(|v| sanitize_path_component(&v)) + .unwrap_or("%REF%".to_string()); + for cmp in src.components() { + let cmp = cmp.as_os_str().to_string_lossy() + .replace("%ID%", &safe_full_hash) + .replace("%REF%", &safe_ref); + dst.push(cmp); + } + assert!(src.components().count() == dst.components().count(), + "ERROR: path substitution accidentally created a new folder in: {}", src.display()); + dst + } +} + #[derive(Clone, Serialize, Default)] pub struct GitStats { pub files: usize, @@ -142,6 +179,26 @@ pub struct GitFile { pub contents_preformatted: bool, } +impl SafePathVar for GitFile { + fn safe_substitute(&self, path: &impl AsRef) -> PathBuf { + let src: &Path = path.as_ref(); + let mut dst = PathBuf::new(); + let safe_id = sanitize_path_component(&self.id); + let safe_name = sanitize_path_component(&self.name); + let safe_path = sanitize_path_component(&urlify_path(&self.path)); + for cmp in src.components() { + let cmp = cmp.as_os_str().to_string_lossy() + .replace("%ID%", &safe_id) + .replace("%NAME%", &safe_name) + .replace("%PATH%", &safe_path); + dst.push(cmp); + } + assert!(src.components().count() == dst.components().count(), + "ERROR: path substitution accidentally created a new folder in: {}", src.display()); + dst + } +} + #[derive(Clone, Serialize, Default)] pub struct GitDiffCommit { pub files: Vec, diff --git a/src/settings.rs b/src/settings.rs index 3958797..8aa01ab 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -21,7 +21,8 @@ * along with Itsy-Gitsy. If not, see . */ use crate::error; -use crate::git::{GitFile, GitObject, GitRepo}; +use crate::git::GitRepo; +use crate::util::SafePathVar; use clap::Parser; use serde::Deserialize; use std::collections::{BTreeMap, HashMap, HashSet}; @@ -157,71 +158,85 @@ pub struct GitsySettingsOutputs { pub repo_assets: Option, } +#[derive(Deserialize, Debug)] +#[allow(non_camel_case_types)] +pub enum GitsySettingsExtraType { + repo_list, + summary, + history, + commit, + branches, + branch, + tags, + tag, + files, + file, + dir +} + +pub fn substitute_path_vars(path: &P, repo: Option<&GitRepo>, obj: Option<&S>) -> PathBuf +where P: AsRef, + S: SafePathVar { + let p: PathBuf = path.as_ref().to_path_buf(); + assert!(p.is_relative(), "ERROR: path must be relative, not absolute: {}", p.display()); + let p: PathBuf = repo.map(|r| r.safe_substitute(&p)).unwrap_or(p); + let p: PathBuf = obj.map(|o| o.safe_substitute(&p)).unwrap_or(p); + p +} + +#[derive(Deserialize, Debug)] +pub struct GitsySettingsExtraOutput { + pub template: String, + pub output: String, + pub kind: GitsySettingsExtraType, +} + macro_rules! output_path_fn { - ($var:ident, $obj:ty, $id:ident, $is_dir:expr, $default:expr) => { - pub fn $var(&self, repo: Option<&GitRepo>, obj: Option<&$obj>) -> String { - let tmpl_str = self.$var.as_deref().unwrap_or($default).to_string(); - let tmpl_str = match (tmpl_str.contains("%REPO%"), repo.is_some()) { - (true, true) => { - let name = repo.map(|x| &x.name).unwrap(); - tmpl_str.replace("%REPO%", name) - } - (true, false) => { - panic!("%REPO% variable not available for output path: {}", tmpl_str); - } - _ => tmpl_str, - }; - let tmpl_str = match (tmpl_str.contains("%ID%"), obj.is_some()) { - (true, true) => { - let name = obj.map(|x| &x.$id).unwrap(); - tmpl_str.replace("%ID%", name) - } - (true, false) => { - panic!("%ID% variable not available for output path: {}", tmpl_str); - } - _ => tmpl_str, - }; - let tmpl = PathBuf::from(tmpl_str); - let mut path = self.path.clone().canonicalize().expect(&format!( - "ERROR: unable to canonicalize output path: {}", - self.path.display() - )); - path.push(tmpl); - match $is_dir { - true => { - let _ = create_dir_all(&path); - } - false => { - if let Some(dir) = path.parent() { - let _ = create_dir_all(dir); - } - } - } - path.to_str() - .expect(&format!("Output is not a valid path: {}", path.display())) - .into() + ($var:ident, $is_dir:expr, $default:expr) => { + pub fn $var(&self, repo: Option<&GitRepo>, obj: Option<&S>) -> PathBuf { + let tmpl_path = PathBuf::from(self.$var.as_deref().unwrap_or($default)); + let new_path = substitute_path_vars(&tmpl_path, repo, obj); + self.canonicalize_and_create(&new_path, $is_dir) } - }; + } } -//step_map_first!(boil_in_wort, Boil, Wort, |b: &Boil| { b.wort_start() }); #[rustfmt::skip] impl GitsySettingsOutputs { - output_path_fn!(repo_list, GitObject, full_hash, false, "index.html"); - output_path_fn!(summary, GitObject, full_hash, false, "%REPO%/index.html"); - output_path_fn!(history, GitObject, full_hash, false, "%REPO%/history%PAGE%.html"); - output_path_fn!(commit, GitObject, full_hash, false, "%REPO%/commit/%ID%.html"); - output_path_fn!(branches, GitObject, full_hash, false, "%REPO%/branches%PAGE%.html"); - output_path_fn!(branch, GitObject, full_hash, false, "%REPO%/branch/%ID%.html"); - output_path_fn!(tags, GitObject, full_hash, false, "%REPO%/tags%PAGE%.html"); - output_path_fn!(tag, GitObject, full_hash, false, "%REPO%/tag/%ID%.html"); - output_path_fn!(files, GitObject, full_hash, false, "%REPO%/files.html"); - output_path_fn!(file, GitFile, id, false, "%REPO%/file/%ID%.html"); - output_path_fn!(syntax_css, GitObject, full_hash, false, "%REPO%/file/syntax.css"); - output_path_fn!(dir, GitFile, id, false, "%REPO%/dir/%ID%.html"); - output_path_fn!(error, GitObject, full_hash, false, "404.html"); - output_path_fn!(global_assets, GitObject, full_hash, true, "assets/"); - output_path_fn!(repo_assets, GitObject, full_hash, true, "%REPO%/assets/"); + output_path_fn!(repo_list, false, "index.html"); + output_path_fn!(summary, false, "%REPO%/index.html"); + output_path_fn!(history, false, "%REPO%/history%PAGE%.html"); + output_path_fn!(commit, false, "%REPO%/commit/%ID%.html"); + output_path_fn!(branches, false, "%REPO%/branches%PAGE%.html"); + output_path_fn!(branch, false, "%REPO%/branch/%ID%.html"); + output_path_fn!(tags, false, "%REPO%/tags%PAGE%.html"); + output_path_fn!(tag, false, "%REPO%/tag/%ID%.html"); + output_path_fn!(files, false, "%REPO%/files.html"); + output_path_fn!(file, false, "%REPO%/file/%ID%.html"); + output_path_fn!(syntax_css, false, "%REPO%/file/syntax.css"); + output_path_fn!(dir, false, "%REPO%/dir/%ID%.html"); + output_path_fn!(error, false, "404.html"); + output_path_fn!(global_assets, true, "assets/"); + output_path_fn!(repo_assets, true, "%REPO%/assets/"); + + fn canonicalize_and_create(&self, path: &Path, is_dir: bool) -> PathBuf { + let mut canonical_path = self.path.clone() + .canonicalize().expect(&format!( + "ERROR: unable to canonicalize output path: {}", + self.path.display())); + canonical_path.push(path); + match is_dir { + true => { + let _ = create_dir_all(&canonical_path); + } + false => { + if let Some(dir) = canonical_path.parent() { + let _ = create_dir_all(dir); + } + } + } + canonical_path + } pub fn output_dir(&self) -> String { self.path.clone().canonicalize() @@ -244,7 +259,10 @@ impl GitsySettingsOutputs { .expect(&format!("ERROR: failed to clean output directory: {}", dir.display())); } - pub fn to_relative(&self, path: &str) -> String { + pub fn to_relative>(&self, path: &P) -> String { + let path = path.as_ref().to_str() + .expect(&format!("ERROR: Unable to make path relative: {}", + path.as_ref().display())); let path_buf = PathBuf::from(path); path_buf.strip_prefix(self.output_dir()) .expect(&format!("ERROR: Unable to make path relative: {}", path)) diff --git a/src/template.rs b/src/template.rs index 9fa77b8..f8d3823 100644 --- a/src/template.rs +++ b/src/template.rs @@ -24,7 +24,7 @@ use crate::git::GitFile; use chrono::{naive::NaiveDateTime, offset::FixedOffset, DateTime}; use serde::Serialize; use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use tera::{from_value, to_value, try_get_value, Filter, Function, Value}; fn ts_to_date(ts: i64, offset: Option, format: Option) -> String { @@ -180,7 +180,10 @@ pub struct Pagination { pub prev_page: Option, } impl Pagination { - pub fn new(cur: usize, total: usize, url_template: &str) -> Self { + pub fn new>(cur: usize, total: usize, url_template: &P) -> Self { + let url_template = url_template.as_ref().to_str() + .expect(&format!("ERROR: attempted to paginate unparseable path: {}", + url_template.as_ref().display())); let digits = total.to_string().len().max(2); let next = match cur + 1 <= total { true => Some(cur + 1), diff --git a/src/util.rs b/src/util.rs index 94b014d..528d0ae 100644 --- a/src/util.rs +++ b/src/util.rs @@ -20,6 +20,8 @@ * You should have received a copy of the GNU General Public License * along with Itsy-Gitsy. If not, see . */ +use std::path::Path; +use std::path::PathBuf; use std::sync::atomic::AtomicUsize; pub static VERBOSITY: AtomicUsize = AtomicUsize::new(0); @@ -130,3 +132,22 @@ impl From for GitsyError { GitsyError::sourced_kind(GitsyErrorKind::Template, Some(&source.to_string()), source) } } + +pub fn sanitize_path_component(var: &str) -> String { + let nasty_chars = r###"<>:"/\|?*&"###.to_string() + "\n\t\0"; + let mut safe_str = var.to_string(); + safe_str.retain(|c| !nasty_chars.contains(c)); + safe_str +} + +pub fn urlify_path(path: &str) -> String { + let path = path + .replace("/", "+") + .replace("\\", "+"); + path.trim().to_string() +} + + +pub trait SafePathVar { + fn safe_substitute(&self, path: &impl AsRef) -> PathBuf; +}