From 77313623c7ff620b28a8bcf57383b6673120b690 Mon Sep 17 00:00:00 2001
From: Hannu Hartikainen <hannu.hartikainen@gmail.com>
Date: Mon, 1 Jun 2020 13:31:34 +0300
Subject: [PATCH] Implement get_file_hash

---
 components/site/src/lib.rs                    |  3 +
 components/site/tests/site.rs                 |  9 ++
 components/templates/src/global_fns/mod.rs    | 98 ++++++++++++++++++-
 .../documentation/templates/overview.md       | 15 +++
 test_site/static/scripts/hello.js             |  1 +
 test_site/templates/index.html                |  2 +
 6 files changed, 125 insertions(+), 3 deletions(-)

diff --git a/components/site/src/lib.rs b/components/site/src/lib.rs
index 97e031dc55..74ad927377 100644
--- a/components/site/src/lib.rs
+++ b/components/site/src/lib.rs
@@ -551,6 +551,9 @@ impl Site {
             "get_taxonomy_url",
             global_fns::GetTaxonomyUrl::new(&self.config.default_language, &self.taxonomies),
         );
+        self.tera.register_function("get_file_hash", global_fns::GetFileHash::new(
+            vec![self.static_path.clone(), self.output_path.clone(), self.content_path.clone()]
+        ));
     }
 
     pub fn register_tera_global_fns(&mut self) {
diff --git a/components/site/tests/site.rs b/components/site/tests/site.rs
index dfa8ad136c..331a2f3132 100644
--- a/components/site/tests/site.rs
+++ b/components/site/tests/site.rs
@@ -693,6 +693,15 @@ fn can_cachebust_static_files() {
         "<link href=\"https://replace-this-with-your-url.com/site.css?h=83bd983e8899946ee33d0fde18e82b04d7bca1881d10846c769b486640da3de9\" rel=\"stylesheet\">"));
 }
 
+#[test]
+fn can_get_hash_for_static_files() {
+    let (_, _tmp_dir, public) = build_site("test_site");
+    assert!(file_contains!(public, "index.html",
+        "src=\"https://replace-this-with-your-url.com/scripts/hello.js\""));
+    assert!(file_contains!(public, "index.html",
+        "integrity=\"sha384-01422f31eaa721a6c4ac8c6fa09a27dd9259e0dfcf3c7593d7810d912a9de5ca2f582df978537bcd10f76896db61fbb9\""));
+}
+
 #[test]
 fn check_site() {
     let (mut site, _tmp_dir, _public) = build_site("test_site");
diff --git a/components/templates/src/global_fns/mod.rs b/components/templates/src/global_fns/mod.rs
index e14d5ae8a3..e33c56f207 100644
--- a/components/templates/src/global_fns/mod.rs
+++ b/components/templates/src/global_fns/mod.rs
@@ -3,7 +3,7 @@ use std::path::PathBuf;
 use std::sync::{Arc, Mutex, RwLock};
 use std::{fs, io, result};
 
-use sha2::{Digest, Sha256};
+use sha2::{Digest, Sha256, Sha384, Sha512};
 use tera::{from_value, to_value, Error, Function as TeraFn, Result, Value};
 
 use config::Config;
@@ -82,7 +82,7 @@ fn open_file(search_paths: &Vec<PathBuf>, url: &String) -> result::Result<fs::Fi
             _ => continue
         };
     }
-    Err(format!("file {} not found; searched in {}", url,
+    Err(format!("file `{}` not found; searched in{}", url,
         search_paths.iter().fold(String::new(), |acc, arg| acc + " " + arg.to_str().unwrap())))
 }
 
@@ -92,6 +92,19 @@ fn compute_file_sha256(mut file: fs::File) -> result::Result<String, String> {
         .and_then(|_| Ok(format!("{:x}", hasher.result())))
         .map_err(|e| format!("{}", e))
 }
+fn compute_file_sha384(mut file: fs::File) -> result::Result<String, String> {
+    let mut hasher = Sha384::new();
+    io::copy(&mut file, &mut hasher)
+        .and_then(|_| Ok(format!("{:x}", hasher.result())))
+        .map_err(|e| format!("{}", e))
+}
+fn compute_file_sha512(mut file: fs::File) -> result::Result<String, String> {
+    let mut hasher = Sha512::new();
+    io::copy(&mut file, &mut hasher)
+        .and_then(|_| Ok(format!("{:x}", hasher.result())))
+        .map_err(|e| format!("{}", e))
+}
+
 
 fn get_cachebust_hash(search_paths: &Vec<PathBuf>, url: &String) -> Option<String> {
     match open_file(search_paths, url).and_then(compute_file_sha256) {
@@ -152,6 +165,47 @@ impl TeraFn for GetUrl {
     }
 }
 
+#[derive(Debug)]
+pub struct GetFileHash {
+    search_paths: Vec<PathBuf>,
+}
+impl GetFileHash {
+    pub fn new(search_paths: Vec<PathBuf>) -> Self {
+        Self { search_paths }
+    }
+}
+
+const DEFAULT_SHA_TYPE: u16 = 384;
+
+impl TeraFn for GetFileHash {
+    fn call(&self, args: &HashMap<String, Value>) -> Result<Value> {
+        let path = required_arg!(
+            String,
+            args.get("path"),
+            "`get_file_hash` requires a `path` argument with a string value"
+        );
+        let sha_type = optional_arg!(
+            u16,
+            args.get("sha_type"),
+            "`get_file_hash`: `sha_type` must be 256, 384 or 512"
+        ).unwrap_or(DEFAULT_SHA_TYPE);
+
+        let compute_hash_fn = match sha_type {
+            256 => compute_file_sha256,
+            384 => compute_file_sha384,
+            512 => compute_file_sha512,
+            _ => return Err("`get_file_hash`: `sha_type` must be 256, 384 or 512".into())
+        };
+
+        let hash = open_file(&self.search_paths, &path).and_then(compute_hash_fn);
+
+        match hash {
+            Ok(digest) => Ok(to_value(digest).unwrap()),
+            Err(e) => Err(e.into())
+        }
+    }
+}
+
 #[derive(Debug)]
 pub struct ResizeImage {
     imageproc: Arc<Mutex<imageproc::Processor>>,
@@ -400,7 +454,7 @@ impl TeraFn for GetTaxonomy {
 
 #[cfg(test)]
 mod tests {
-    use super::{GetTaxonomy, GetTaxonomyUrl, GetUrl, Trans};
+    use super::{GetTaxonomy, GetTaxonomyUrl, GetUrl, Trans, GetFileHash};
 
     use std::collections::HashMap;
     use std::env::temp_dir;
@@ -714,4 +768,42 @@ title = "A title"
             "https://remplace-par-ton-url.fr/en/a_section/a_page/"
         );
     }
+
+    #[test]
+    fn can_get_file_hash_sha256() {
+        let static_fn = GetFileHash::new(vec![TEST_CONTEXT.static_path.clone()]);
+        let mut args = HashMap::new();
+        args.insert("path".to_string(), to_value("app.css").unwrap());
+        args.insert("sha_type".to_string(), to_value(256).unwrap());
+        assert_eq!(static_fn.call(&args).unwrap(), "572e691dc68c3fcd653ae463261bdb38f35dc6f01715d9ce68799319dd158840");
+    }
+
+    #[test]
+    fn can_get_file_hash_sha384() {
+        let static_fn = GetFileHash::new(vec![TEST_CONTEXT.static_path.clone()]);
+        let mut args = HashMap::new();
+        args.insert("path".to_string(), to_value("app.css").unwrap());
+        assert_eq!(static_fn.call(&args).unwrap(), "141c09bd28899773b772bbe064d8b718fa1d6f2852b7eafd5ed6689d26b74883b79e2e814cd69d5b52ab476aa284c414");
+    }
+
+    #[test]
+    fn can_get_file_hash_sha512() {
+        let static_fn = GetFileHash::new(vec![TEST_CONTEXT.static_path.clone()]);
+        let mut args = HashMap::new();
+        args.insert("path".to_string(), to_value("app.css").unwrap());
+        args.insert("sha_type".to_string(), to_value(512).unwrap());
+        assert_eq!(static_fn.call(&args).unwrap(), "379dfab35123b9159d9e4e92dc90e2be44cf3c2f7f09b2e2df80a1b219b461de3556c93e1a9ceb3008e999e2d6a54b4f1d65ee9be9be63fa45ec88931623372f");
+    }
+
+    #[test]
+    fn error_when_file_not_found_for_hash() {
+        let static_fn = GetFileHash::new(vec![TEST_CONTEXT.static_path.clone()]);
+        let mut args = HashMap::new();
+        args.insert("path".to_string(), to_value("doesnt-exist").unwrap());
+        assert_eq!(
+            format!("file `doesnt-exist` not found; searched in {}",
+                    TEST_CONTEXT.static_path.to_str().unwrap()),
+            format!("{}", static_fn.call(&args).unwrap_err())
+        );
+    }
 }
diff --git a/docs/content/documentation/templates/overview.md b/docs/content/documentation/templates/overview.md
index e9c967a7f9..f08385a915 100644
--- a/docs/content/documentation/templates/overview.md
+++ b/docs/content/documentation/templates/overview.md
@@ -146,6 +146,21 @@ In the case of non-internal links, you can also add a cachebust of the format `?
 by passing `cachebust=true` to the `get_url` function.
 
 
+### 'get_file_hash`
+
+Gets the hash digest for a static file. Supported hashes are SHA-256, SHA-384 (default) and SHA-512. Requires `path`. The `sha_type` key is optional and must be one of 256, 384 or 512.
+
+```jinja2
+{{/* get_file_hash(path="js/app.js", sha_type=256) */}}
+```
+
+This can be used to implement subresource integrity. Do note that subresource integrity is typically used when using external scripts, which `get_file_hash` does not support.
+
+```jinja2
+<script src="{{/* get_url(path="js/app.js") */}}"
+        integrity="sha384-{{/* get_file_hash(path="js/app.js", sha_type=256) */}}"></script>
+```
+
 ### `get_image_metadata`
 Gets metadata for an image.  Currently, the only supported keys are `width` and `height`.
 
diff --git a/test_site/static/scripts/hello.js b/test_site/static/scripts/hello.js
index e69de29bb2..d28ad733bf 100644
--- a/test_site/static/scripts/hello.js
+++ b/test_site/static/scripts/hello.js
@@ -0,0 +1 @@
+// test content
diff --git a/test_site/templates/index.html b/test_site/templates/index.html
index 990849376b..14bc3260d3 100644
--- a/test_site/templates/index.html
+++ b/test_site/templates/index.html
@@ -23,5 +23,7 @@ <h3 class="post__title"><a href="{{ page.permalink }}">{{ page.title }}</a></h3>
             </div>
         {% endblock content %}
     </div>
+    <script src="{{ get_url(path="scripts/hello.js") | safe }}"
+            integrity="sha384-{{ get_file_hash(path="scripts/hello.js") }}"></script>
   </body>
 </html>