libcachebust/src/processor.rs

452 lines
14 KiB
Rust
Raw Normal View History

2021-04-08 20:05:11 +05:30
/*
* Copyright (C) 2021 Aravinth Manivannan <realaravinth@batsense.net>
*
* Use of this source code is governed by the Apache 2.0 and/or the MIT
* License.
*/
2021-04-10 17:45:00 +05:30
//! Module describing file processor that changes filenames to setup cache-busting
//!
//! Run the following during build using `build.rs`:
//!
//! ```rust
//! use cache_buster::BusterBuilder;
//!
2021-07-05 14:46:56 +05:30
//! // note: add error checking yourself.
//! // println!("cargo:rustc-env=GIT_process={}", git_process);
//! let types = vec![
//! mime::IMAGE_PNG,
//! mime::IMAGE_SVG,
//! mime::IMAGE_JPEG,
//! mime::IMAGE_GIF,
//! ];
2021-04-10 17:45:00 +05:30
//!
2021-07-05 14:46:56 +05:30
//! let config = BusterBuilder::default()
//! .source("./dist")
//! .result("./prod")
//! .mime_types(types)
//! .copy(true)
//! .follow_links(true)
//! .build()
//! .unwrap();
2021-04-10 17:45:00 +05:30
//!
2021-07-05 14:46:56 +05:30
//! config.process().unwrap();
2021-04-10 17:45:00 +05:30
//! ```
//!
//! There's a runtime component to this library which will let you read modified
//! filenames from within your program. See [Files]
use std::collections::HashMap;
2021-04-08 20:05:11 +05:30
use std::io::Error;
use std::path::Path;
use std::{fs, path::PathBuf};
use derive_builder::Builder;
use serde::{Deserialize, Serialize};
2021-04-08 20:05:11 +05:30
use walkdir::WalkDir;
2021-04-30 20:37:56 +05:30
use crate::*;
2021-04-08 20:45:38 +05:30
2021-04-10 17:45:00 +05:30
/// Configuration for setting up cache-busting
2021-04-08 20:05:11 +05:30
#[derive(Debug, Clone, Builder)]
2021-07-05 14:39:35 +05:30
#[builder(build_fn(validate = "Self::validate"))]
pub struct Buster<'a> {
2021-04-10 17:45:00 +05:30
/// source directory
2021-04-08 20:05:11 +05:30
#[builder(setter(into))]
source: String,
2021-04-10 17:45:00 +05:30
/// mime_types for hashing
2021-07-05 15:24:56 +05:30
#[builder(setter(into, strip_option), default)]
mime_types: Option<Vec<mime::Mime>>,
2021-04-10 17:45:00 +05:30
/// directory for writing results
2021-04-08 20:05:11 +05:30
#[builder(setter(into))]
result: String,
2021-04-12 18:23:56 +05:30
#[builder(setter(into, strip_option), default)]
/// route prefixes
prefix: Option<String>,
2021-04-10 17:45:00 +05:30
/// copy other non-hashed files from source dire to result dir?
2021-04-08 20:05:11 +05:30
copy: bool,
2021-04-10 17:45:00 +05:30
/// follow symlinks?
2021-04-08 20:05:11 +05:30
follow_links: bool,
2021-07-05 14:39:35 +05:30
/// exclude these files for hashing.
/// They will be copied over without including a hash in the filename
/// Path should be relative to [self.source]
#[builder(default)]
no_hash: Vec<&'a str>,
2021-04-08 20:05:11 +05:30
}
2021-07-05 14:39:35 +05:30
impl<'a> BusterBuilder<'a> {
fn validate(&self) -> Result<(), String> {
for file in self.no_hash.iter() {
for file in file.iter() {
if !Path::new(&self.source.as_ref().unwrap())
.join(file)
.exists()
{
return Err(format!("File {} doesn't exist", file));
}
}
}
Ok(())
}
}
impl<'a> Buster<'a> {
2021-04-10 17:45:00 +05:30
// creates base_dir to output files to
fn init(&self) -> Result<(), Error> {
2021-04-08 20:05:11 +05:30
let res = Path::new(&self.result);
if res.exists() {
fs::remove_dir_all(&self.result).unwrap();
}
fs::create_dir(&self.result).unwrap();
self.create_dir_structure(Path::new(&self.source))?;
Ok(())
}
2021-04-09 12:20:27 +05:30
fn hasher(payload: &[u8]) -> String {
2021-04-08 20:05:11 +05:30
use data_encoding::HEXUPPER;
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(payload);
HEXUPPER.encode(&hasher.finalize())
}
2021-04-10 17:45:00 +05:30
/// Processes files.
///
2021-04-30 20:37:56 +05:30
/// Panics when a weird MIME is encountered.
pub fn process(&self) -> Result<(), Error> {
2021-04-10 17:45:00 +05:30
// panics when mimetypes are detected. This way you'll know which files are ignored
// from processing
self.init()?;
2021-04-09 14:05:27 +05:30
let mut file_map: Files = Files::new(&self.result);
2021-04-08 20:05:11 +05:30
for entry in WalkDir::new(&self.source)
.follow_links(self.follow_links)
.into_iter()
{
let entry = entry?;
let path = entry.path();
2021-07-05 15:24:56 +05:30
if !path.is_dir() {
2021-04-08 20:05:11 +05:30
let path = Path::new(&path);
2021-07-05 15:24:56 +05:30
let mut process_worker = |path: &Path| {
let contents = Self::read_to_string(&path).unwrap();
let hash = Self::hasher(&contents);
let new_name = if self.no_hash.iter().any(|no_hash| {
let no_hash = Path::new(&self.source).join(&no_hash);
no_hash == path
}) {
format!(
"{}.{}",
path.file_stem().unwrap().to_str().unwrap(),
path.extension().unwrap().to_str().unwrap()
)
} else {
format!(
2021-04-08 20:05:11 +05:30
"{}.{}.{}",
path.file_stem().unwrap().to_str().unwrap(),
hash,
path.extension().unwrap().to_str().unwrap()
2021-07-05 15:24:56 +05:30
)
};
self.copy(path, &new_name);
let (source, destination) = self.gen_map(path, &&new_name);
let _ = file_map.add(
source.to_str().unwrap().into(),
destination.to_str().unwrap().into(),
);
};
match self.mime_types.as_ref() {
Some(mime_types) => {
for mime_type in mime_types.iter() {
let file_mime =
mime_guess::from_path(path).first().unwrap_or_else(|| {
panic!("couldn't resolve MIME for file: {:?}", &path)
});
if &file_mime == mime_type {
process_worker(&path);
}
}
2021-04-08 20:05:11 +05:30
}
2021-07-05 15:24:56 +05:30
None => process_worker(&path),
2021-04-08 20:05:11 +05:30
}
}
}
file_map.to_env();
Ok(())
2021-04-08 20:05:11 +05:30
}
2021-04-10 17:45:00 +05:30
// helper fn to read file to string
2021-04-09 12:20:27 +05:30
fn read_to_string(path: &Path) -> Result<Vec<u8>, Error> {
2021-04-08 20:05:11 +05:30
use std::fs::File;
2021-04-09 12:20:27 +05:30
use std::io::Read;
2021-04-08 20:05:11 +05:30
2021-04-09 12:20:27 +05:30
let mut file_content = Vec::new();
let mut file = File::open(path)?;
file.read_to_end(&mut file_content).expect("Unable to read");
Ok(file_content)
2021-04-08 20:05:11 +05:30
}
2021-04-10 17:45:00 +05:30
// helper fn to generate filemap
2021-07-05 14:39:35 +05:30
fn gen_map<'b>(&self, source: &'b Path, name: &str) -> (&'b Path, PathBuf) {
2021-04-08 20:05:11 +05:30
let rel_location = source.strip_prefix(&self.source).unwrap().parent().unwrap();
2021-04-12 18:23:56 +05:30
if let Some(prefix) = &self.prefix {
//panic!("{}", &prefix);
let mut result = self.result.as_str();
2021-07-05 14:46:56 +05:30
if result.starts_with('/') {
2021-04-12 18:23:56 +05:30
result = &self.result[1..];
}
let destination = Path::new(prefix)
2021-04-30 20:37:56 +05:30
.join(&result)
2021-04-12 18:23:56 +05:30
.join(rel_location)
.join(name);
2021-07-05 14:46:56 +05:30
(source, destination)
2021-04-12 18:23:56 +05:30
} else {
let destination = Path::new(&self.result).join(rel_location).join(name);
2021-07-05 14:46:56 +05:30
(source, destination)
2021-04-12 18:23:56 +05:30
}
2021-04-08 20:05:11 +05:30
}
2021-04-10 17:45:00 +05:30
// helper fn to copy files
2021-04-08 20:05:11 +05:30
fn copy(&self, source: &Path, name: &str) {
let rel_location = source.strip_prefix(&self.source).unwrap().parent().unwrap();
let destination = Path::new(&self.result).join(rel_location).join(name);
fs::copy(source, &destination).unwrap();
}
2021-04-10 17:45:00 +05:30
// helper fn to create directory structure in self.base_dir
2021-04-08 20:05:11 +05:30
fn create_dir_structure(&self, path: &Path) -> Result<(), Error> {
for entry in WalkDir::new(&path)
.follow_links(self.follow_links)
.into_iter()
{
let entry = entry?;
let entry_path = entry.path();
let entry_path = Path::new(&entry_path);
if entry_path.is_dir() && path != entry_path {
Self::create_dir_structure(&self, entry_path)?;
2021-07-05 14:46:56 +05:30
} else if entry_path.is_dir() {
let rel_location = entry_path.strip_prefix(&self.source).unwrap();
let destination = Path::new(&self.result).join(rel_location);
if !destination.exists() {
fs::create_dir(destination)?
2021-04-08 20:05:11 +05:30
}
}
}
Ok(())
}
}
/// Filemap struct
///
/// maps original names to generated names
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
struct Files {
/// filemap<original-path, modified-path>
pub map: HashMap<String, String>,
base_dir: String,
}
impl Files {
/// Initialize map
fn new(base_dir: &str) -> Self {
Files {
map: HashMap::default(),
base_dir: base_dir.into(),
}
}
/// Create file map: map original path to modified paths
fn add(&mut self, k: String, v: String) -> Result<(), &'static str> {
2021-07-05 14:46:56 +05:30
if let std::collections::hash_map::Entry::Vacant(e) = self.map.entry(k) {
e.insert(v);
Ok(())
2021-07-05 14:46:56 +05:30
} else {
Err("key exists")
}
}
/// This crate uses compile-time environment variables to transfer
/// data to the main program. This funtction sets that variable
fn to_env(&self) {
2021-04-30 20:37:56 +05:30
let json = serde_json::to_string(&self).unwrap();
// println!("cargo:rustc-env={}={}", ENV_VAR_NAME, json);
let res = Path::new(CACHE_BUSTER_DATA_FILE);
if res.exists() {
fs::remove_file(&res).unwrap();
}
// const PREFIX: &str = r##"pub const FILE_MAP: &str = r#" "##;
// const POSTFIX: &str = r##""#;"##;
// let content = format!("#[allow(dead_code)]\n{}{}{}", &PREFIX, &json, &POSTFIX);
// fs::write(CACHE_BUSTER_DATA_FILE, content).unwrap();
fs::write(CACHE_BUSTER_DATA_FILE, &json).unwrap();
// needed for testing load()
// if the above statement fails(println), then something's broken
// with the rust compiler. So not really worried about that.
2021-04-30 20:37:56 +05:30
// #[cfg(test)]
// std::env::set_var(ENV_VAR_NAME, serde_json::to_string(&self).unwrap());
}
#[cfg(test)]
/// Load filemap in main program. Should be called from main program
fn load() -> Self {
2021-04-30 20:37:56 +05:30
let map = fs::read_to_string(CACHE_BUSTER_DATA_FILE).unwrap();
let res: Files = serde_json::from_str(&map).unwrap();
res
}
}
2021-04-08 20:05:11 +05:30
#[cfg(test)]
2021-04-08 22:00:41 +05:30
pub mod tests {
2021-04-08 20:05:11 +05:30
use super::*;
fn hasher_works() {
2021-04-30 20:37:56 +05:30
delete_file();
2021-04-08 20:05:11 +05:30
let types = vec![
mime::IMAGE_PNG,
mime::IMAGE_SVG,
mime::IMAGE_JPEG,
mime::IMAGE_GIF,
];
let config = BusterBuilder::default()
.source("./dist")
2021-04-10 17:45:00 +05:30
.result("./prod56")
2021-04-08 20:05:11 +05:30
.mime_types(types)
.copy(true)
.follow_links(true)
.build()
.unwrap();
config.process().unwrap();
let mut files = Files::load();
2021-04-08 20:05:11 +05:30
2021-04-08 20:45:38 +05:30
for (k, v) in files.map.drain() {
2021-04-08 20:05:11 +05:30
let src = Path::new(&k);
let dest = Path::new(&v);
assert_eq!(src.exists(), dest.exists());
}
2021-04-12 18:23:56 +05:30
2021-04-08 22:00:41 +05:30
cleanup(&config);
2021-04-08 20:05:11 +05:30
}
2021-04-08 20:14:53 +05:30
2021-04-08 22:00:41 +05:30
pub fn cleanup(config: &Buster) {
let _ = fs::remove_dir_all(&config.result);
2021-04-30 20:37:56 +05:30
delete_file();
2021-04-08 20:14:53 +05:30
}
2021-04-30 20:37:56 +05:30
pub fn delete_file() {
let _ = fs::remove_file(&CACHE_BUSTER_DATA_FILE);
}
2021-04-12 18:23:56 +05:30
fn prefix_works() {
2021-04-30 20:37:56 +05:30
delete_file();
2021-04-12 18:23:56 +05:30
let types = vec![
mime::IMAGE_PNG,
mime::IMAGE_SVG,
mime::IMAGE_JPEG,
mime::IMAGE_GIF,
];
2021-07-05 14:39:35 +05:30
let no_hash = vec!["bell.svg", "eye.svg", "a/b/c/d/s/d/svg/10.svg"];
2021-04-12 18:23:56 +05:30
let config = BusterBuilder::default()
.source("./dist")
2021-07-05 15:24:56 +05:30
.result("/tmp/prod2i2")
2021-04-12 18:23:56 +05:30
.mime_types(types)
.copy(true)
.follow_links(true)
.prefix("/test")
2021-07-05 14:39:35 +05:30
.no_hash(no_hash.clone())
2021-04-12 18:23:56 +05:30
.build()
.unwrap();
config.process().unwrap();
let mut files = Files::load();
if let Some(prefix) = &config.prefix {
2021-07-05 14:39:35 +05:30
no_hash.iter().for_each(|file| {
2021-07-05 15:24:56 +05:30
assert!(files.map.iter().any(|(k, v)| {
let source = Path::new(k);
2021-07-05 14:39:35 +05:30
let dest = Path::new(&v[prefix.len()..]);
let no_hash = Path::new(file);
2021-07-05 15:24:56 +05:30
let stat = source == &Path::new(&config.source).join(file)
&& dest.exists()
&& no_hash.file_name() == dest.file_name();
println!("[{}] file: {}", stat, file);
stat
}));
2021-07-05 14:39:35 +05:30
});
2021-04-12 18:23:56 +05:30
for (k, v) in files.map.drain() {
let src = Path::new(&k);
let dest = Path::new(&v[prefix.len()..]);
assert_eq!(src.exists(), dest.exists());
}
}
cleanup(&config);
}
2021-04-30 20:37:56 +05:30
2021-07-05 14:39:35 +05:30
#[test]
fn no_hash_validation_works() {
let types = vec![
mime::IMAGE_PNG,
mime::IMAGE_SVG,
mime::IMAGE_JPEG,
mime::IMAGE_GIF,
];
let no_hash = vec!["bbell.svg", "eye.svg", "a/b/c/d/s/d/svg/10.svg"];
assert!(BusterBuilder::default()
.source("./dist")
.result("/tmp/prod2i")
.mime_types(types)
.copy(true)
.follow_links(true)
.prefix("/test")
.no_hash(no_hash.clone())
.build()
.is_err())
}
2021-07-05 15:24:56 +05:30
// #[test]
// fn no_specific_mime() {
// let no_hash = vec!["858fd6c482cc75111d54.module.wasm"];
// let config = BusterBuilder::default()
// .source("./dist")
// .result("/tmp/prod2ii")
// .copy(true)
// .follow_links(true)
// .no_hash(no_hash.clone())
// .prefix("/test")
// .build()
// .unwrap();
// config.process().unwrap();
// let files = Files::load();
// files.map.iter().any(|(k, v)| {
// let dest = Path::new(&v[prefix.len()..]);
// let no_hash = Path::new(file);
// k == file && dest.exists() && no_hash.file_name() == dest.file_name()
// });
//
// cleanup(&config);
// }
2021-04-30 20:37:56 +05:30
pub fn runner() {
prefix_works();
hasher_works();
}
2021-04-08 20:05:11 +05:30
}