Repository: cargo-generate/cargo-generate
Pull request #386 (2021-07-28 07:55:24)

feat(template:filenames): template filenames now
- solves #159 and is partially based on PR #174
- addresses some issues from PR #182

  > what to do about characters coming out of a template tag and being invalid when part of a filename

  - now filenames are sanitized after templating

  > what to do about using template tags in filenames along with the include option in cargo-generate.toml when you can't specify a filename with curly braces in it ergonomically using glob syntax

  - simply not supported, hence exclusion must be configured with the filename before substitution.

- add tests to check for exploitation
- README.md updated

sassman: @k0pernicus catching up on this that you have once done. Maybe you remember further problems that we better test here to avoid side effects?

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 005ac6f..c31f79c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -215,6 +215,7 @@ dependencies = [
  "predicates",
  "regex",
  "remove_dir_all 0.7.0",
+ "sanitize-filename",
  "serde",
  "structopt",
  "tempfile",
@@ -1444,6 +1445,16 @@ dependencies = [
  "winapi-util",
 ]
 
+[[package]]
+name = "sanitize-filename"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf18934a12018228c5b55a6dae9df5d0641e3566b3630cb46cc55564068e7c2f"
+dependencies = [
+ "lazy_static",
+ "regex",
+]
+
 [[package]]
 name = "schannel"
 version = "0.1.19"
diff --git a/Cargo.toml b/Cargo.toml
index c0f3439..31db935 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -33,6 +33,7 @@ anyhow = "1.0.42"
 toml = "0.5.8"
 thiserror = "1.0.26"
 home = "0.5.3"
+sanitize-filename = "0.3"
 
 [dependencies.openssl]
 version = "0.10.35"
diff --git a/README.md b/README.md
index 7ce3833..33c5b2b 100644
--- a/README.md
+++ b/README.md
@@ -152,10 +152,18 @@ For more information, check out the [Liquid Documentation on `Tags` and `Filters
 
 [liquid]: https://shopify.github.io/liquid
 
+You can use those placeholders in the file names of the generated project.  
+For example, for a project named `awesome`, the filename `{{project_name}}.rs` will be transformed to `awesome.rs` during generation.
+Only files that are **not** listed in the exclude settings will be templated.
+
+> NOTE: invalid characters for a filename will be sanitized after template substitution. Invalid is e.g. `/` or `\`.
+
 You can also add a `.genignore` file to your template. The files listed in the `.genignore` file
 will be removed from the local machine when `cargo-generate` is run on the end user's machine.
 The `.genignore` file is always ignored, so there is no need to list it in the `.genignore` file.
 
+> NOTE: 
+
 Here's a list of [currently available templates](TEMPLATES.md).
 If you have a great template that you'd like to feature here, please [file an issue or a PR]!
 
diff --git a/src/filenames.rs b/src/filenames.rs
new file mode 100644
index 0000000..471a865
--- /dev/null
+++ b/src/filenames.rs
@@ -0,0 +1,111 @@
+use crate::Result;
+
+use liquid::{Object, Parser};
+use std::path::{Path, PathBuf};
+
+pub fn substitute_filename(filepath: &Path, parser: &Parser, context: &Object) -> Result<PathBuf> {
+    let filename = filepath.file_name().and_then(|s| s.to_str()).unwrap();
+    let path = filepath.parent().unwrap_or_else(|| Path::new(""));
+
+    let parsed_filename = parser.parse(filename)?.render(context)?;
+    let parsed_filename = sanitize_filename(parsed_filename.as_str());
+
+    Ok(path.join(Path::new(parsed_filename.as_str())))
+}
+
+fn sanitize_filename(filename: &str) -> String {
+    use sanitize_filename::sanitize_with_options;
+
+    let options = sanitize_filename::Options {
+        truncate: true,   // true by default, truncates to 255 bytes
+        replacement: "_", // str to replace sanitized chars/strings
+        ..sanitize_filename::Options::default()
+    };
+
+    sanitize_with_options(filename, options)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use liquid::model::Value;
+
+    #[test]
+    fn should_do_happy_path() {
+        assert_eq!(
+            substitute_filename("{{author}}.rs", prepare_context("sassman")).unwrap(),
+            "sassman.rs"
+        );
+        #[cfg(unix)]
+        assert_eq!(
+            substitute_filename("/tmp/project/{{author}}.rs", prepare_context("sassman")).unwrap(),
+            "/tmp/project/sassman.rs"
+        );
+        #[cfg(windows)]
+        assert_eq!(
+            substitute_filename(
+                "C:\\tmp\\project\\{{author}}.rs",
+                prepare_context("sassman")
+            )
+            .unwrap(),
+            "C:\\tmp\\project\\sassman.rs"
+        );
+    }
+
+    #[test]
+    fn should_prevent_invalid_filenames() {
+        #[cfg(unix)]
+        assert_eq!(
+            substitute_filename("/tmp/project/{{author}}.rs", prepare_context("s/a/s")).unwrap(),
+            "/tmp/project/s_a_s.rs"
+        );
+        #[cfg(windows)]
+        assert_eq!(
+            substitute_filename(
+                "C:\\tmp\\project\\{{author}}.rs",
+                prepare_context("s\\a\\s")
+            )
+            .unwrap(),
+            "C:\\tmp\\project\\s_a_s.rs"
+        );
+    }
+
+    #[test]
+    fn should_prevent_exploitation() {
+        #[cfg(unix)]
+        assert_eq!(
+            substitute_filename(
+                "/tmp/project/{{author}}.rs",
+                prepare_context("../../etc/passwd")
+            )
+            .unwrap(),
+            "/tmp/project/.._.._etc_passwd.rs"
+        );
+        #[cfg(windows)]
+        assert_eq!(
+            substitute_filename(
+                "C:\\tmp\\project\\{{author}}.rs",
+                prepare_context("..\\..\\etc\\passwd")
+            )
+            .unwrap(),
+            "C:\\tmp\\project\\.._.._etc_passwd.rs"
+        );
+    }
+
+    //region wrapper helpers
+    fn prepare_context(value: &str) -> Object {
+        let mut ctx = Object::default();
+        ctx.entry("author")
+            .or_insert(Value::scalar(value.to_string()));
+
+        ctx
+    }
+
+    fn substitute_filename(f: &str, ctx: Object) -> Result<String> {
+        let parser = Parser::default();
+
+        super::substitute_filename(f.as_ref(), &parser, &ctx)
+            .map(|p| p.to_str().unwrap().to_string())
+    }
+    //endregion
+}
diff --git a/src/include_exclude.rs b/src/include_exclude.rs
index 35ac1a4..db6c213 100644
--- a/src/include_exclude.rs
+++ b/src/include_exclude.rs
@@ -28,7 +28,7 @@ impl Matcher {
     fn create_matcher(project_dir: &Path, patterns: &[String]) -> Result<Gitignore> {
         let mut builder = GitignoreBuilder::new(project_dir);
         for rule in patterns {
-            builder.add_line(None, &rule)?;
+            builder.add_line(None, rule)?;
         }
         Ok(builder.build()?)
     }
diff --git a/src/interactive.rs b/src/interactive.rs
index f2c8668..8df5357 100644
--- a/src/interactive.rs
+++ b/src/interactive.rs
@@ -93,7 +93,7 @@ fn prompt_for_variable(variable: &TemplateSlots) -> Result<String> {
 }
 
 pub(super) fn variable(variable: &TemplateSlots) -> Result<Value> {
-    let user_input = prompt_for_variable(&variable)?;
+    let user_input = prompt_for_variable(variable)?;
     into_value(user_input, &variable.var_info)
 }
 
diff --git a/src/lib.rs b/src/lib.rs
index 9c9c42d..c3ab391 100755
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -82,6 +82,7 @@ mod app_config;
 mod config;
 mod emoji;
 mod favorites;
+mod filenames;
 mod git;
 mod ignore_me;
 mod include_exclude;
@@ -226,10 +227,10 @@ pub fn generate(mut args: Args) -> Result<()> {
         .template_values_file
         .as_ref()
         .map(|p| Path::new(p))
-        .map_or(Ok(Default::default()), |path| get_config_file_values(&path))?;
+        .map_or(Ok(Default::default()), |path| get_config_file_values(path))?;
 
     let template_config = Config::from_path(
-        &locate_template_file(&CONFIG_FILE_NAME, &template_base_dir, &args.subfolder).ok(),
+        &locate_template_file(CONFIG_FILE_NAME, &template_base_dir, &args.subfolder).ok(),
     )?;
 
     expand_template(
@@ -333,7 +334,7 @@ fn clone_git_template_into_temp(args: &Args) -> Result<(TempDir, String)> {
         args.ssh_identity.clone(),
     )?;
 
-    let branch = git::create(&git_clone_dir.path(), git_config).map_err(|e| {
+    let branch = git::create(git_clone_dir.path(), git_config).map_err(|e| {
         anyhow!(
             "{} {} {}",
             emoji::ERROR,
@@ -410,7 +411,7 @@ fn create_project_dir(name: &ProjectName, force: bool) -> Result<PathBuf> {
     let dir_name = if force {
         name.raw()
     } else {
-        rename_warning(&name);
+        rename_warning(name);
         name.kebab_case()
     };
     let project_dir = env::current_dir()
diff --git a/src/template.rs b/src/template.rs
index 84554e5..60958b8 100644
--- a/src/template.rs
+++ b/src/template.rs
@@ -11,6 +11,7 @@ use walkdir::{DirEntry, WalkDir};
 
 use crate::config::TemplateConfig;
 use crate::emoji;
+use crate::filenames::substitute_filename;
 use crate::include_exclude::*;
 use crate::progressbar::spinner;
 use crate::template_variables::{get_authors, get_os_arch, Authors, CrateType, ProjectName};
@@ -201,13 +202,23 @@ pub(crate) fn walk_dir(
                         style(filename.display()).bold()
                     )
                 })?;
-            pb.inc(50);
-            fs::write(filename, new_contents).with_context(|| {
+            pb.inc(25);
+            let new_filename =
+                substitute_filename(filename, &engine, &template).with_context(|| {
+                    format!(
+                        "{} {} `{}`",
+                        emoji::ERROR,
+                        style("Error templating a filename").bold().red(),
+                        style(filename.display()).bold()
+                    )
+                })?;
+            pb.inc(25);
+            fs::write(new_filename.as_path(), new_contents).with_context(|| {
                 format!(
                     "{} {} `{}`",
                     emoji::ERROR,
                     style("Error writing").bold().red(),
-                    style(filename.display()).bold()
+                    style(new_filename.display()).bold()
                 )
             })?;
             pb.inc(50);


Test patch:
diff --git a/tests/integration/basics.rs b/tests/integration/basics.rs
index c4ed54f..4672a59 100644
--- a/tests/integration/basics.rs
+++ b/tests/integration/basics.rs
@@ -1,6 +1,7 @@
 use git2::Repository;
 use predicates::prelude::*;
 
+use crate::helpers::project::binary;
 use crate::helpers::project_builder::tmp_dir;
 
 use assert_cmd::prelude::*;
@@ -9,10 +10,6 @@ use std::fs;
 use std::ops::Not;
 use std::process::Command;
 
-fn binary() -> Command {
-    Command::cargo_bin(env!("CARGO_PKG_NAME")).unwrap()
-}
-
 #[test]
 fn it_removes_git_history() {
     let template = tmp_dir()
diff --git a/tests/integration/filenames.rs b/tests/integration/filenames.rs
new file mode 100644
index 0000000..fcc2da7
--- /dev/null
+++ b/tests/integration/filenames.rs
@@ -0,0 +1,39 @@
+use crate::helpers::project::binary;
+use crate::helpers::project_builder::tmp_dir;
+
+use assert_cmd::prelude::*;
+use predicates::prelude::*;
+
+#[test]
+fn it_substitutes_filename() {
+    let template = tmp_dir()
+        .file("main.rs", r#"extern crate {{crate_name}};"#)
+        .file(
+            "{{project-name}}.rs",
+            r#"println!("Welcome in {{project-name}}");"#,
+        )
+        .init_git()
+        .build();
+
+    let dir = tmp_dir().build();
+
+    binary()
+        .arg("generate")
+        .arg("--git")
+        .arg(template.path())
+        .arg("--name")
+        .arg("foobar-project")
+        .current_dir(&dir.path())
+        .assert()
+        .success()
+        .stdout(predicates::str::contains("Done!").from_utf8());
+
+    assert!(
+        dir.exists("foobar-project/main.rs"),
+        "project should contain foobar-project/main.rs"
+    );
+    assert!(
+        dir.exists("foobar-project/foobar-project.rs"),
+        "project should contain foobar-project/foobar-project.rs"
+    );
+}
diff --git a/tests/integration/helpers/project.rs b/tests/integration/helpers/project.rs
index 5796190..bbcedc7 100644
--- a/tests/integration/helpers/project.rs
+++ b/tests/integration/helpers/project.rs
@@ -1,10 +1,16 @@
+use assert_cmd::prelude::*;
 use std::fs::File;
 use std::io::Read;
 use std::path::Path;
+use std::process::Command;
 use std::str;
 
 use tempfile::TempDir;
 
+pub fn binary() -> Command {
+    Command::cargo_bin(env!("CARGO_PKG_NAME")).unwrap()
+}
+
 #[derive(Debug)]
 pub struct Project {
     pub(crate) root: TempDir,
diff --git a/tests/integration/main.rs b/tests/integration/main.rs
index 4a0c498..0ed9493 100644
--- a/tests/integration/main.rs
+++ b/tests/integration/main.rs
@@ -3,4 +3,5 @@ mod helpers;
 // test modules go here
 mod basics;
 mod favorites;
+mod filenames;
 mod library;