From 2cb727d97fec6f5b1bb0bb42bc38fbbed8a9c157 Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Wed, 25 Feb 2026 14:37:07 +0100 Subject: [PATCH] feat: add sha256, base64_encode, base64_decode MiniJinja template filters (#23) --- CHANGELOG.md | 10 + Cargo.lock | 2 + Cargo.toml | 2 + docs/templating.md | 101 ++++++++++ examples/template-functions/config.tmpl | 10 + src/main.rs | 1 + src/render.rs | 1 + src/seed/mod.rs | 1 + src/template_funcs.rs | 249 ++++++++++++++++++++++++ 9 files changed, 377 insertions(+) create mode 100644 docs/templating.md create mode 100644 examples/template-functions/config.tmpl create mode 100644 src/template_funcs.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b8ad71f..c2d9343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Custom MiniJinja template filters: `sha256`, `base64_encode`, `base64_decode` available in all templates (render and seed spec files) +- `sha256` filter with optional `mode` parameter (`"hex"` default, `"bytes"` for byte array output) +- `base64_encode` / `base64_decode` filters for standard Base64 encoding and decoding with error handling for invalid input +- Filters are chainable: e.g. `{{ "data" | sha256 | base64_encode }}` +- `src/template_funcs.rs` dedicated module for template utility functions, designed for easy extension +- `docs/templating.md` documenting all template filters with usage patterns, chaining examples, and error reference +- `examples/template-functions/config.tmpl` example demonstrating sha256 and base64 filters +- Unit tests for sha256 (hex, bytes, empty, invalid mode), base64 (encode, decode, roundtrip, invalid input), and template integration (filter chaining) + ### Changed - Clarified that seed phases with only `create_if_missing` can omit the `seed_sets` field entirely (`seed_sets` defaults to empty via `#[serde(default)]`); updated integration test YAML specs accordingly diff --git a/Cargo.lock b/Cargo.lock index db0bb50..bc5f898 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1009,6 +1009,7 @@ dependencies = [ name = "initium" version = "0.1.0" dependencies = [ + "base64 0.22.1", "clap", "minijinja", "mysql", @@ -1019,6 +1020,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2", "tempfile", "tiny_http", "ureq", diff --git a/Cargo.toml b/Cargo.toml index bda86a6..fe52040 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,8 @@ rand = "0.8" ureq = { version = "2", features = ["tls"], default-features = false } rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } minijinja = "2" +sha2 = "0.10" +base64 = "0.22" rusqlite = { version = "0.31", features = ["bundled"], optional = true } postgres = { version = "0.19", optional = true } mysql = { version = "25", optional = true } diff --git a/docs/templating.md b/docs/templating.md new file mode 100644 index 0000000..607e38f --- /dev/null +++ b/docs/templating.md @@ -0,0 +1,101 @@ +# Template Functions + +Initium extends the MiniJinja template engine with utility filters for hashing and encoding. These filters are available in all templates — both `render` templates and `seed` spec files. + +## Available Filters + +### `sha256` + +Compute the SHA-256 hash of a string. + +```jinja +{{ "hello" | sha256 }} +{# → 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 #} +``` + +**Parameters:** + +| Parameter | Type | Default | Description | +| --------- | ------ | ------- | ---------------------------------------- | +| `mode` | string | `"hex"` | Output format: `"hex"` or `"bytes"` | + +**Modes:** + +- `"hex"` (default) — returns a lowercase hex string (64 characters). +- `"bytes"` — returns an array of 32 byte values (integers 0–255). + +```jinja +{{ "hello" | sha256("hex") }} +{# → 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 #} + +{{ "hello" | sha256("bytes") }} +{# → [44, 242, 77, ...] (32 integers) #} +``` + +### `base64_encode` + +Encode a string to Base64 (standard alphabet with padding). + +```jinja +{{ "hello world" | base64_encode }} +{# → aGVsbG8gd29ybGQ= #} +``` + +### `base64_decode` + +Decode a Base64 string back to its original value. Returns an error if the input is not valid Base64 or the decoded bytes are not valid UTF-8. + +```jinja +{{ "aGVsbG8gd29ybGQ=" | base64_decode }} +{# → hello world #} +``` + +## Chaining Filters + +Filters can be chained to compose operations: + +```jinja +{# SHA-256 hash then Base64-encode the hex digest #} +{{ "secret" | sha256 | base64_encode }} + +{# Base64 encode then decode (roundtrip) #} +{{ "data" | base64_encode | base64_decode }} + +{# Hash an environment variable value #} +{{ env.API_KEY | sha256 }} +``` + +## Use Cases + +### Content Fingerprinting + +Generate a checksum for a config value to detect changes: + +```jinja +checksum: {{ env.CONFIG_DATA | sha256 }} +``` + +### Encoding Secrets + +Base64-encode a value for Kubernetes secret manifests: + +```jinja +data: + password: {{ env.DB_PASSWORD | base64_encode }} +``` + +### Verifying Integrity + +Decode and verify Base64-encoded content: + +```jinja +decoded_cert: {{ env.B64_CERT | base64_decode }} +``` + +## Error Handling + +| Error | Cause | +| -------------------------------- | --------------------------------------------------------- | +| `sha256: unsupported mode '…'` | Mode parameter is not `"hex"` or `"bytes"` | +| `base64_decode: invalid input` | Input string is not valid Base64 | +| `base64_decode: not valid UTF-8` | Decoded bytes are not a valid UTF-8 string | diff --git a/examples/template-functions/config.tmpl b/examples/template-functions/config.tmpl new file mode 100644 index 0000000..1a9d168 --- /dev/null +++ b/examples/template-functions/config.tmpl @@ -0,0 +1,10 @@ +{# Example: using sha256 and base64 template filters #} + +[checksums] +config_hash = {{ env.CONFIG_DATA | sha256 }} + +[secrets] +db_password = {{ env.DB_PASSWORD | base64_encode }} + +[chained] +password_hash_b64 = {{ env.DB_PASSWORD | sha256 | base64_encode }} diff --git a/src/main.rs b/src/main.rs index 80a55a5..b849b79 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod render; mod retry; mod safety; mod seed; +mod template_funcs; use clap::{Parser, Subcommand}; diff --git a/src/render.rs b/src/render.rs index a44728a..b5c0da2 100644 --- a/src/render.rs +++ b/src/render.rs @@ -63,6 +63,7 @@ pub fn template_render(input: &str) -> Result { let env_map: std::collections::HashMap = env::vars().collect(); let mut jinja_env = minijinja::Environment::new(); jinja_env.set_undefined_behavior(minijinja::UndefinedBehavior::Lenient); + crate::template_funcs::register(&mut jinja_env); jinja_env .add_template("t", input) .map_err(|e| format!("parsing template: {}", e))?; diff --git a/src/seed/mod.rs b/src/seed/mod.rs index 87e7e73..6618aaf 100644 --- a/src/seed/mod.rs +++ b/src/seed/mod.rs @@ -8,6 +8,7 @@ fn render_template(content: &str) -> Result { let env_map: std::collections::HashMap = std::env::vars().collect(); let mut jinja_env = minijinja::Environment::new(); jinja_env.set_undefined_behavior(minijinja::UndefinedBehavior::Lenient); + crate::template_funcs::register(&mut jinja_env); jinja_env .add_template("seed", content) .map_err(|e| format!("parsing seed template: {}", e))?; diff --git a/src/template_funcs.rs b/src/template_funcs.rs new file mode 100644 index 0000000..9440d1c --- /dev/null +++ b/src/template_funcs.rs @@ -0,0 +1,249 @@ +use base64::prelude::*; +use minijinja::value::Value; +use sha2::{Digest, Sha256}; + +/// Register all custom template filters on the given MiniJinja environment. +pub fn register(env: &mut minijinja::Environment<'_>) { + env.add_filter("sha256", filter_sha256); + env.add_filter("base64_encode", filter_base64_encode); + env.add_filter("base64_decode", filter_base64_decode); +} + +fn filter_sha256(value: String, mode: Option) -> Result { + let mut hasher = Sha256::new(); + hasher.update(value.as_bytes()); + let hash = hasher.finalize(); + + match mode.as_deref().unwrap_or("hex") { + "hex" => Ok(Value::from(hex_encode(&hash))), + "bytes" => { + let list: Vec = hash.iter().map(|b| Value::from(*b as i64)).collect(); + Ok(Value::from(list)) + } + other => Err(minijinja::Error::new( + minijinja::ErrorKind::InvalidOperation, + format!( + "sha256: unsupported mode '{}' (expected 'hex' or 'bytes')", + other + ), + )), + } +} + +fn filter_base64_encode(value: Value) -> Result { + // String values are encoded directly; byte sequences (from sha256 bytes + // mode) are collected into a Vec first. + if value.is_undefined() + || value.is_none() + || value.kind() == minijinja::value::ValueKind::String + { + let s = value.to_string(); + return Ok(BASE64_STANDARD.encode(s.as_bytes())); + } + if let Ok(items) = value.try_iter() { + let bytes: Vec = items + .map(|v| { + let n = i64::try_from(v.clone()).map_err(|_| { + minijinja::Error::new( + minijinja::ErrorKind::InvalidOperation, + "base64_encode: byte sequence contains non-integer value", + ) + })?; + u8::try_from(n).map_err(|_| { + minijinja::Error::new( + minijinja::ErrorKind::InvalidOperation, + "base64_encode: byte value out of 0..255 range", + ) + }) + }) + .collect::>()?; + Ok(BASE64_STANDARD.encode(&bytes)) + } else { + let s = value.to_string(); + Ok(BASE64_STANDARD.encode(s.as_bytes())) + } +} + +fn filter_base64_decode(value: String) -> Result { + let bytes = BASE64_STANDARD.decode(value.as_bytes()).map_err(|e| { + minijinja::Error::new( + minijinja::ErrorKind::InvalidOperation, + format!("base64_decode: invalid input: {}", e), + ) + })?; + String::from_utf8(bytes).map_err(|e| { + minijinja::Error::new( + minijinja::ErrorKind::InvalidOperation, + format!("base64_decode: result is not valid UTF-8: {}", e), + ) + }) +} + +fn hex_encode(bytes: &[u8]) -> String { + use std::fmt::Write; + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + let _ = write!(s, "{:02x}", b); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sha256_hex() { + let result = filter_sha256("hello".into(), Some("hex".into())).unwrap(); + assert_eq!( + result.to_string(), + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + ); + } + + #[test] + fn test_sha256_default_is_hex() { + let result = filter_sha256("hello".into(), None).unwrap(); + assert_eq!( + result.to_string(), + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + ); + } + + #[test] + fn test_sha256_bytes() { + let result = filter_sha256("hello".into(), Some("bytes".into())).unwrap(); + let items: Vec = result.try_iter().expect("should be iterable").collect(); + assert_eq!(items.len(), 32); + // First byte of sha256("hello") is 0x2c = 44 + assert_eq!(i64::try_from(items[0].clone()).unwrap(), 0x2c); + } + + #[test] + fn test_sha256_invalid_mode() { + let result = filter_sha256("hello".into(), Some("raw".into())); + assert!(result.is_err()); + } + + #[test] + fn test_sha256_empty_input() { + let result = filter_sha256(String::new(), None).unwrap(); + assert_eq!( + result.to_string(), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + #[test] + fn test_base64_encode() { + assert_eq!( + filter_base64_encode(Value::from("hello")).unwrap(), + "aGVsbG8=" + ); + } + + #[test] + fn test_base64_encode_empty() { + assert_eq!(filter_base64_encode(Value::from("")).unwrap(), ""); + } + + #[test] + fn test_base64_decode() { + let result = filter_base64_decode("aGVsbG8=".into()).unwrap(); + assert_eq!(result, "hello"); + } + + #[test] + fn test_base64_decode_empty() { + let result = filter_base64_decode(String::new()).unwrap(); + assert_eq!(result, ""); + } + + #[test] + fn test_base64_roundtrip() { + let original = "initium test data with special chars: é ñ ü"; + let encoded = filter_base64_encode(Value::from(original)).unwrap(); + let decoded = filter_base64_decode(encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn test_base64_decode_invalid() { + let result = filter_base64_decode("not-valid-base64!!!".into()); + assert!(result.is_err()); + } + + #[test] + fn test_template_sha256_filter() { + let mut env = minijinja::Environment::new(); + register(&mut env); + env.add_template("t", r#"{{ "hello" | sha256 }}"#).unwrap(); + let tmpl = env.get_template("t").unwrap(); + let result = tmpl.render(minijinja::context!()).unwrap(); + assert_eq!( + result, + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + ); + } + + #[test] + fn test_template_base64_encode_filter() { + let mut env = minijinja::Environment::new(); + register(&mut env); + env.add_template("t", r#"{{ "hello" | base64_encode }}"#) + .unwrap(); + let tmpl = env.get_template("t").unwrap(); + let result = tmpl.render(minijinja::context!()).unwrap(); + assert_eq!(result, "aGVsbG8="); + } + + #[test] + fn test_template_base64_decode_filter() { + let mut env = minijinja::Environment::new(); + register(&mut env); + env.add_template("t", r#"{{ "aGVsbG8=" | base64_decode }}"#) + .unwrap(); + let tmpl = env.get_template("t").unwrap(); + let result = tmpl.render(minijinja::context!()).unwrap(); + assert_eq!(result, "hello"); + } + + #[test] + fn test_template_chained_sha256_then_base64() { + let mut env = minijinja::Environment::new(); + register(&mut env); + env.add_template("t", r#"{{ "hello" | sha256 | base64_encode }}"#) + .unwrap(); + let tmpl = env.get_template("t").unwrap(); + let result = tmpl.render(minijinja::context!()).unwrap(); + // base64 of the hex sha256 of "hello" + let expected = BASE64_STANDARD + .encode("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"); + assert_eq!(result, expected); + } + + #[test] + fn test_sha256_bytes_then_base64_known_vector() { + let mut env = minijinja::Environment::new(); + register(&mut env); + env.add_template( + "t", + r#"{{ "nbp_TestSecretValue1234567890ABCDE05m4Dm" | sha256("bytes") | base64_encode }}"#, + ) + .unwrap(); + let tmpl = env.get_template("t").unwrap(); + let result = tmpl.render(minijinja::context!()).unwrap(); + assert_eq!(result, "7X/8tpDCEeSF536pQUogANtV0NHanRgRpN/JS4UJNKg="); + } + + #[test] + fn test_template_chained_base64_roundtrip() { + let mut env = minijinja::Environment::new(); + register(&mut env); + env.add_template("t", r#"{{ "secret" | base64_encode | base64_decode }}"#) + .unwrap(); + let tmpl = env.get_template("t").unwrap(); + let result = tmpl.render(minijinja::context!()).unwrap(); + assert_eq!(result, "secret"); + } +}