summary history branches tags files
commit:78156268bac0102b8da4c6a0c5aba8c9572764a0
author:Trevor Bentley
committer:Trevor Bentley
date:Mon Jan 16 03:42:40 2023 +0100
parents:ca747583d208b6cab63f79a4d4a3b7558e61e523
switch from crazy macro to trait for path variable substitution
diff --git a/src/generate.rs b/src/generate.rs
line changes: +16/-14
index fca2d2b..75461f4
--- 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<P: AsRef<Path>>(&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::<GitFile>(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::<GitFile>(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::<GitFile>(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::<GitFile>(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::<GitFile>(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::<GitFile>(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::<GitFile>(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::<GitFile>(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::<GitFile>(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::<GitFile>(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::<GitFile>(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::<GitFile>(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::<GitFile>(None, None).display()));
         }
 
         Ok(())

diff --git a/src/git.rs b/src/git.rs
line changes: +57/-0
index f2d4509..d8268db
--- a/src/git.rs
+++ b/src/git.rs
@@ -21,11 +21,13 @@
  * along with Itsy-Gitsy.  If not, see <http://www.gnu.org/licenses/>.
  */
 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<Path>) -> 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<String>,
@@ -120,6 +137,26 @@ pub struct GitObject {
     pub diff: Option<GitDiffCommit>,
 }
 
+impl SafePathVar for GitObject {
+    fn safe_substitute(&self, path: &impl AsRef<Path>) -> 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<Path>) -> 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<GitDiffFile>,

diff --git a/src/settings.rs b/src/settings.rs
line changes: +79/-61
index 3958797..8aa01ab
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -21,7 +21,8 @@
  * along with Itsy-Gitsy.  If not, see <http://www.gnu.org/licenses/>.
  */
 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<String>,
 }
 
+#[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<P,S>(path: &P, repo: Option<&GitRepo>, obj: Option<&S>) -> PathBuf
+where P: AsRef<Path>,
+      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<S: SafePathVar>(&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<P: AsRef<Path>>(&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
line changes: +5/-2
index 9fa77b8..f8d3823
--- 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<i64>, format: Option<String>) -> String {
@@ -180,7 +180,10 @@ pub struct Pagination {
     pub prev_page: Option<String>,
 }
 impl Pagination {
-    pub fn new(cur: usize, total: usize, url_template: &str) -> Self {
+    pub fn new<P: AsRef<Path>>(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
line changes: +21/-0
index 94b014d..528d0ae
--- 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 <http://www.gnu.org/licenses/>.
  */
+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<tera::Error> 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<Path>) -> PathBuf;
+}