diff --git a/Cargo.lock b/Cargo.lock index dea5ef5..bf87d9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -295,6 +295,24 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "globset" version = "0.4.18" @@ -420,6 +438,16 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.6" @@ -570,6 +598,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.8.5" @@ -597,7 +631,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.16", ] [[package]] @@ -686,6 +720,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -763,7 +808,14 @@ name = "tmpltool" version = "1.0.4" dependencies = [ "clap", + "glob", + "md-5", + "rand", + "regex", + "sha1", + "sha2", "tera", + "uuid", ] [[package]] @@ -796,6 +848,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" @@ -818,6 +881,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.106" @@ -940,6 +1012,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "zerocopy" version = "0.8.31" diff --git a/Cargo.toml b/Cargo.toml index 92a5faa..870c202 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,3 +6,10 @@ edition = "2024" [dependencies] tera = { version = "1", features = ["builtins"] } clap = { version = "4", features = ["derive"] } +regex = "1" +md-5 = "0.10" +sha1 = "0.10" +sha2 = "0.10" +uuid = { version = "1.11", features = ["v4"] } +rand = "0.8" +glob = "0.3" diff --git a/README.md b/README.md index e521095..1c016b8 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,11 @@ A fast and simple command-line template rendering tool using [Tera](https://keat ## Features - Render Tera templates with environment variable support via `get_env()` function +- Filter environment variables by pattern with `filter_env()` function +- Cryptographic hash functions: `md5()`, `sha1()`, `sha256()`, `sha512()` +- UUID generation with `uuid()` function +- Random string generation with `random_string()` function +- Filesystem functions: `read_file()`, `file_exists()`, `list_dir()`, `glob()`, `file_size()`, `file_modified()` - Output to file or stdout (for piping) - Simple CLI interface - Single binary executable @@ -99,6 +104,10 @@ cat template.txt | tmpltool [OPTIONS] - `-o, --output ` - Output file path (optional) - If not specified, output is printed to stdout +- `--trust` - Trust mode: Allow filesystem functions to access absolute paths and parent directories (optional) + - **WARNING:** This disables security restrictions. Only use with trusted templates. + - Without this flag, filesystem functions are restricted to relative paths within the current working directory + - With this flag, you can access any file on the system (e.g., `/etc/passwd`, `../../secret.txt`) ### Input/Output Combinations @@ -151,6 +160,33 @@ cat k8s-deployment.yaml.tmpl | tmpltool | kubectl apply -f - cat header.tmpl body.tmpl footer.tmpl | tmpltool > complete.html ``` +#### Using Trust Mode for System Files + +```bash +# Create a template that reads system files +cat > system_info.tmpl << 'EOF' +# System Information + +## Hostname +{{ read_file(path="/etc/hostname") }} + +## Hosts File (first 200 chars) +{{ read_file(path="/etc/hosts") | truncate(length=200) }} + +## Files in /etc (first 10) +{% for file in list_dir(path="/etc") | slice(end=10) %} +- {{ file }} +{% endfor %} +EOF + +# Without --trust: Security error +tmpltool system_info.tmpl +# Error: Security: Absolute paths and parent directory (..) access are not allowed + +# With --trust: Works! +tmpltool --trust system_info.tmpl -o system_info.md +``` + #### Using Environment Variables Create a template file `greeting.tmpl`: @@ -321,6 +357,42 @@ Title Case: John Doe Slugified: john-doe ``` +#### Filtering Environment Variables by Pattern + +Use the `filter_env()` function to get all environment variables matching a pattern: + +Template `server-vars.tmpl`: +``` +Server Configuration: +{% for var in filter_env(pattern="SERVER_*") %} + {{ var.key }}={{ var.value }} +{% endfor %} +``` + +Set environment variables: +```bash +SERVER_HOST=localhost \ +SERVER_PORT=8080 \ +SERVER_NAME=myapp \ +OTHER_VAR=ignored \ +tmpltool server-vars.tmpl +``` + +Output: +``` +Server Configuration: + SERVER_HOST=localhost + SERVER_NAME=myapp + SERVER_PORT=8080 +``` + +**Pattern Syntax:** +- `*` - matches any characters (e.g., `SERVER_*` matches `SERVER_HOST`, `SERVER_PORT`, etc.) +- `?` - matches exactly one character (e.g., `DB_?` matches `DB_A`, `DB_B`, but not `DB_AB`) +- Patterns can be at the beginning, middle, or end (e.g., `*_PORT`, `APP_*_NAME`) + +The results are returned as an array of objects with `key` and `value` fields, sorted alphabetically by key. + #### Complex Example - Docker Compose Generator Template `docker-compose.tmpl`: @@ -381,6 +453,276 @@ Generate nginx config and test it: tmpltool nginx.conf.tmpl | nginx -t -c /dev/stdin ``` +#### Comprehensive Example - All Features + +This example demonstrates all tmpltool features in a single template: + +Template `comprehensive-app-config.tmpl`: +```yaml +# Application Configuration +# Generated: {{ now() }} +# Instance ID: {{ uuid() }} + +{# ============================================ + Service Configuration + ============================================ #} +service: + name: {{ get_env(name="APP_NAME", default="myapp") | upper }} + version: {{ get_env(name="APP_VERSION", default="1.0.0") }} + environment: {{ get_env(name="ENV", default="development") | upper }} + + # Unique identifiers + instance_id: {{ uuid() }} + deployment_id: {{ uuid() }} + +{# ============================================ + Security & Authentication + ============================================ #} +security: + # Hash functions for integrity checks + config_checksum: {{ md5(string="v1.0-config") }} + license_hash: {{ sha256(string=get_env(name="LICENSE_KEY", default="trial-license")) }} + + # Generated secrets + api_key: {{ random_string(length=32, charset="hex") }} + secret_token: {{ random_string(length=64) }} + csrf_token: {{ random_string(length=40, charset="hex") }} + session_secret: {{ random_string(length=32, charset="alphanumeric") }} + + # Password hashing (example - use proper password hashing in production!) + {% set admin_pwd = get_env(name="ADMIN_PASSWORD", default="changeme123") %} + admin_password_hash: {{ sha512(string=admin_pwd) }} + +{# ============================================ + Database Configuration + ============================================ #} +database: + # Filter all DB_* environment variables + {% set db_vars = filter_env(pattern="DB_*") %} + {% if db_vars | length > 0 %} + # From environment: + {% for var in db_vars %} + {{ var.key | lower | replace(from="db_", to="") }}: {{ var.value }} + {% endfor %} + {% else %} + # Default configuration: + host: {{ get_env(name="DB_HOST", default="localhost") }} + port: {{ get_env(name="DB_PORT", default="5432") }} + name: {{ get_env(name="DB_NAME", default="myapp_db") }} + user: {{ get_env(name="DB_USER", default="app_user") }} + {% endif %} + + # Connection pool + max_connections: {{ get_env(name="DB_MAX_CONNECTIONS", default="20") }} + connection_id: {{ uuid() }} + +{# ============================================ + Server Configuration + ============================================ #} +server: + {% set servers = filter_env(pattern="SERVER_*") %} + {% if servers | length > 0 %} + # Detected server configuration: + {% for srv in servers %} + {{ srv.key | lower | replace(from="server_", to="") }}: {{ srv.value }} + {% endfor %} + {% else %} + # Default server configuration: + host: {{ get_env(name="HOST", default="0.0.0.0") }} + port: {{ get_env(name="PORT", default="8080") }} + protocol: {{ get_env(name="PROTOCOL", default="http") }} + {% endif %} + + # TLS/SSL + {% set enable_tls = get_env(name="ENABLE_TLS", default="false") %} + {% if enable_tls == "true" %} + tls: + enabled: true + cert_path: {{ get_env(name="TLS_CERT_PATH", default="/etc/ssl/cert.pem") }} + key_path: {{ get_env(name="TLS_KEY_PATH", default="/etc/ssl/key.pem") }} + {% else %} + tls: + enabled: false + {% endif %} + +{# ============================================ + Logging Configuration + ============================================ #} +logging: + {% set env_type = get_env(name="ENV", default="development") %} + {% if env_type == "production" %} + level: ERROR + format: json + output: /var/log/app/production.log + {% elif env_type == "staging" %} + level: WARN + format: json + output: /var/log/app/staging.log + {% else %} + level: DEBUG + format: text + output: stdout + {% endif %} + + # Log rotation ID + rotation_id: {{ uuid() }} + +{# ============================================ + Feature Flags + ============================================ #} +features: + {% set features = get_env(name="FEATURES", default="api,web,admin") | split(pat=",") %} + enabled: [{% for feature in features %}"{{ feature | trim }}"{% if not loop.last %}, {% endif %}{% endfor %}] + count: {{ features | length }} + + # Feature-specific settings + {% for feature in features %} + {{ feature | trim | slugify }}: + enabled: true + token: {{ random_string(length=16, charset="hex") }} + {% endfor %} + +{# ============================================ + External Services + ============================================ #} +external_services: + # All API_* environment variables + {% set api_vars = filter_env(pattern="API_*") %} + {% if api_vars | length > 0 %} + apis: + {% for api in api_vars %} + {{ api.key | lower | replace(from="api_", to="") }}: + url: {{ api.value }} + key: {{ random_string(length=32, charset="hex") }} + checksum: {{ md5(string=api.value) }} + {% endfor %} + {% else %} + apis: [] + {% endif %} + +{# ============================================ + Cache Configuration + ============================================ #} +cache: + {% set cache_type = get_env(name="CACHE_TYPE", default="memory") %} + type: {{ cache_type }} + ttl: {{ get_env(name="CACHE_TTL", default="3600") }} + + {% if cache_type == "redis" %} + redis: + host: {{ get_env(name="REDIS_HOST", default="localhost") }} + port: {{ get_env(name="REDIS_PORT", default="6379") }} + db: {{ get_env(name="REDIS_DB", default="0") }} + password_hash: {{ sha256(string=get_env(name="REDIS_PASSWORD", default="")) }} + {% endif %} + +{# ============================================ + Monitoring & Metrics + ============================================ #} +monitoring: + enabled: {{ get_env(name="ENABLE_MONITORING", default="true") }} + endpoint: {{ get_env(name="METRICS_ENDPOINT", default="/metrics") }} + + # Unique tracking IDs + cluster_id: {{ uuid() }} + node_id: {{ uuid() }} + + # Sample intervals (in seconds) + {% set intervals = get_env(name="SAMPLE_INTERVALS", default="10,30,60") | split(pat=",") %} + sample_intervals: [{% for interval in intervals %}{{ interval }}{% if not loop.last %}, {% endif %}{% endfor %}] + +{# ============================================ + Recovery & Backup + ============================================ #} +recovery: + # Recovery codes (for 2FA backup) + codes: + {% for i in range(end=5) %} + - {{ random_string(length=8, charset="uppercase") }}-{{ random_string(length=8, charset="uppercase") }} + {% endfor %} + + # Backup encryption key + backup_key: {{ random_string(length=64, charset="hex") }} + backup_key_hash: {{ sha256(string=get_env(name="BACKUP_PASSPHRASE", default="default-passphrase")) }} + +{# ============================================ + Metadata + ============================================ #} +metadata: + generated_at: {{ now() }} + generated_by: tmpltool + template_version: "2.0" + config_hash: {{ sha1(string="comprehensive-config-v2.0") }} + + # All environment variables used + environment_variables: + {% set all_env = filter_env(pattern="*") %} + total_count: {{ all_env | length }} + app_vars: {{ filter_env(pattern="APP_*") | length }} + db_vars: {{ filter_env(pattern="DB_*") | length }} + server_vars: {{ filter_env(pattern="SERVER_*") | length }} +``` + +Set environment variables and render: +```bash +# Set application variables +export APP_NAME="mywebapp" +export APP_VERSION="2.1.0" +export ENV="production" + +# Set database variables +export DB_HOST="db.example.com" +export DB_PORT="5432" +export DB_NAME="production_db" +export DB_USER="app_prod" +export DB_MAX_CONNECTIONS="50" + +# Set server variables +export SERVER_HOST="api.example.com" +export SERVER_PORT="443" +export SERVER_PROTOCOL="https" + +# Enable features +export ENABLE_TLS="true" +export TLS_CERT_PATH="/etc/ssl/certs/app.crt" +export TLS_KEY_PATH="/etc/ssl/private/app.key" + +# Set security +export ADMIN_PASSWORD="SecureP@ssw0rd123" +export LICENSE_KEY="PROD-ABC123-XYZ789" + +# Set features +export FEATURES="api,web,admin,analytics,reporting" + +# External services +export API_PAYMENT_URL="https://api.payment.example.com" +export API_EMAIL_URL="https://api.email.example.com" + +# Cache configuration +export CACHE_TYPE="redis" +export REDIS_HOST="cache.example.com" +export REDIS_PORT="6379" +export REDIS_PASSWORD="redis-secure-pass" + +# Render the configuration +tmpltool comprehensive-app-config.tmpl -o app-config.yaml +``` + +This example demonstrates: +- ✅ All hash functions: `md5()`, `sha1()`, `sha256()`, `sha512()` +- ✅ UUID generation: `uuid()` +- ✅ Random strings: `random_string()` with various charsets +- ✅ Environment variables: `get_env()` with defaults +- ✅ Pattern filtering: `filter_env()` +- ✅ Conditionals: `if/elif/else` +- ✅ Loops: `for` loops with ranges and arrays +- ✅ Filters: `upper`, `lower`, `trim`, `slugify`, `replace`, `split`, `length` +- ✅ Comments: `{# ... #}` +- ✅ String operations: concatenation and formatting +- ✅ Complex logic: nested conditions and loops + +**Note:** The comprehensive example does not include filesystem functions. For filesystem function examples, see the [Filesystem Functions](#filesystem-functions) section. + ## Examples The `examples/` directory contains ready-to-use template examples demonstrating various features: @@ -390,6 +732,9 @@ The `examples/` directory contains ready-to-use template examples demonstrating - **`config.tmpl`** - Application configuration file generation - **`docker-compose.tmpl`** - Docker Compose with sensible defaults - **`config-with-defaults.tmpl`** - Advanced config using `get_env()` function (recommended) +- **`server-config.tmpl`** - Server configuration using `filter_env()` pattern matching +- **`hash-crypto.tmpl`** - Demonstrates all hash functions, UUID, and random string generation +- **`comprehensive-app-config.tmpl`** - Complete showcase of ALL features (recommended for learning) ### Try an Example @@ -411,6 +756,19 @@ tmpltool examples/docker-compose.tmpl -o docker-compose.yml # Config with get_env() function and defaults tmpltool examples/config-with-defaults.tmpl + +# Hash and crypto functions +tmpltool examples/hash-crypto.tmpl + +# Comprehensive example with ALL features (great for learning!) +tmpltool examples/comprehensive-app-config.tmpl + +# Comprehensive example with environment variables +APP_NAME="MyWebApp" \ +ENV="production" \ +DB_HOST="db.example.com" \ +FEATURES="api,web,admin" \ +tmpltool examples/comprehensive-app-config.tmpl -o app-config.yaml ``` See the [examples/README.md](examples/README.md) for detailed documentation of each example. @@ -486,6 +844,431 @@ api_key = {{ get_env(name="API_KEY") }} See [examples/config-with-defaults.tmpl](examples/config-with-defaults.tmpl) for a complete example. +### Custom `filter_env()` Function + +tmpltool provides a custom `filter_env()` function to filter environment variables by glob pattern: + +``` +{% for var in filter_env(pattern="PATTERN") %} + {{ var.key }}={{ var.value }} +{% endfor %} +``` + +**Arguments:** +- `pattern` (required) - A glob pattern to match environment variable names + - `*` matches any characters + - `?` matches exactly one character + +**Returns:** +- An array of objects, each with: + - `key` - The environment variable name + - `value` - The environment variable value +- Results are sorted alphabetically by key + +**Examples:** +``` +# Match all SERVER_* variables +{% for var in filter_env(pattern="SERVER_*") %} +export {{ var.key }}="{{ var.value }}" +{% endfor %} + +# Match all database variables +{% set db_vars = filter_env(pattern="DATABASE_*") %} +{% if db_vars | length > 0 %} +Found {{ db_vars | length }} database variables +{% endif %} + +# Match any variable ending with _PORT +{% for var in filter_env(pattern="*_PORT") %} +{{ var.key }}: {{ var.value }} +{% endfor %} +``` + +See [examples/server-config.tmpl](examples/server-config.tmpl) for a complete example. + +### Hash Functions + +tmpltool provides cryptographic hash functions for generating checksums and hashes: + +#### `md5(string)` +Calculates MD5 hash of a string. + +``` +Checksum: {{ md5(string="hello world") }} +# Output: 5eb63bbbe01eeed093cb22bb8f5acdc3 +``` + +#### `sha1(string)` +Calculates SHA1 hash of a string. + +``` +Hash: {{ sha1(string="tmpltool") }} +# Output: c054a2a60ca2fe935ea1056bd90386194116f14f +``` + +#### `sha256(string)` +Calculates SHA256 hash of a string (recommended for password hashing). + +``` +{% set password = get_env(name="PASSWORD", default="secret") %} +Password hash: {{ sha256(string=password) }} +# Output: fcf730b6d95236ecd3c9fc2d92d7b6b2bb061514961aec041d6c7a7192f592e4 +``` + +#### `sha512(string)` +Calculates SHA512 hash of a string (most secure). + +``` +Secure hash: {{ sha512(string="secure-data") }} +# Output: a5c18d86d1d07cc2b22b12284e2f8e5b9705761003f149467995927e36f0e447ddfb158b89a28c0b4d5ac419c979c1fc435a3378b619aed1bab0d15c3b583db9 +``` + +**Important:** These hash functions are for checksums and general-purpose hashing. For production password storage, use dedicated password hashing libraries with salt and proper key derivation functions (bcrypt, argon2, etc.). + +### UUID Generation + +#### `uuid()` +Generates a random UUID v4 (Universally Unique Identifier). + +``` +Request ID: {{ uuid() }} +Session ID: {{ uuid() }} +# Output: +# Request ID: c5b78641-89f8-4d04-a4c9-d53ba4d433f9 +# Session ID: aabc7fe1-f8ed-45ff-944d-9c24f3823ac0 +``` + +Each call to `uuid()` generates a unique identifier. + +### Random String Generation + +#### `random_string(length, charset)` +Generates a random string with customizable length and character set. + +**Arguments:** +- `length` (required) - Length of the string to generate (1-10000) +- `charset` (optional) - Character set to use (default: `alphanumeric`) + +**Character Set Presets:** +- `alphanumeric` - Letters (a-z, A-Z) and digits (0-9) - **default** +- `alphabetic` or `alpha` - Letters only (a-z, A-Z) +- `lowercase` or `lower` - Lowercase letters only (a-z) +- `uppercase` or `upper` - Uppercase letters only (A-Z) +- `numeric` or `digits` - Digits only (0-9) +- `hex` or `hexadecimal` - Hexadecimal characters (0-9, a-f) +- `hex_upper` - Hexadecimal uppercase (0-9, A-F) +- Custom string - Any custom character set (e.g., `"abc123"`) + +**Examples:** +``` +# Alphanumeric (default) +API Key: {{ random_string(length=32) }} +# Output: 0QY92XIYYKIvMVuVc8a7u8O4v19VacO9 + +# Lowercase only +Username: user_{{ random_string(length=8, charset="lowercase") }} +# Output: user_lvaycaxa + +# Uppercase only +Code: {{ random_string(length=6, charset="uppercase") }} +# Output: YFVLRV + +# Numeric only +PIN: {{ random_string(length=4, charset="numeric") }} +# Output: 5858 + +# Hexadecimal +Token: {{ random_string(length=16, charset="hex") }} +# Output: bd2954f90019649b + +# Custom charset +Password: {{ random_string(length=12, charset="abc123") }} +# Output: 3bb3c31bb23c +``` + +**Practical Example - Secure Configuration:** +```yaml +application: + instance_id: {{ uuid() }} + secret_key: {{ random_string(length=64) }} + api_token: {{ random_string(length=32, charset="hex") }} + +security: + password_hash: {{ sha256(string=get_env(name="PASSWORD")) }} + csrf_token: {{ random_string(length=40, charset="hex") }} +``` + +See [examples/hash-crypto.tmpl](examples/hash-crypto.tmpl) for a complete example. + +### Filesystem Functions + +tmpltool provides secure filesystem functions for reading files and querying file information within templates. All filesystem functions enforce security restrictions to prevent unauthorized access. + +**Security Note:** All filesystem functions only allow access to relative paths within the current working directory. Absolute paths (starting with `/`) and parent directory traversal (`..`) are explicitly blocked. + +#### `read_file(path)` +Reads the content of a file into the template. + +**Arguments:** +- `path` (required) - Relative path to the file to read + +**Returns:** String containing the file content + +**Examples:** +``` +# Read a configuration file +{% set config = read_file(path="config.txt") %} +{{ config }} + +# Read and include file content +License: +{{ read_file(path="LICENSE") }} + +# Use with filters +First 100 chars: {{ read_file(path="README.md") | truncate(length=100) }} +``` + +#### `file_exists(path)` +Checks if a file exists at the specified path. + +**Arguments:** +- `path` (required) - Relative path to check + +**Returns:** Boolean (`true` if file exists, `false` otherwise) + +**Examples:** +``` +# Conditional file inclusion +{% if file_exists(path="custom-config.txt") %} +Custom config found! +{{ read_file(path="custom-config.txt") }} +{% else %} +Using default configuration +{% endif %} + +# Check multiple files +{% set has_readme = file_exists(path="README.md") %} +{% set has_license = file_exists(path="LICENSE") %} +Documentation: {% if has_readme %}✓{% else %}✗{% endif %} +License: {% if has_license %}✓{% else %}✗{% endif %} +``` + +#### `list_dir(path)` +Lists all files and directories in a directory. + +**Arguments:** +- `path` (required) - Relative path to the directory + +**Returns:** Array of filenames (sorted alphabetically) + +**Examples:** +``` +# List files in a directory +Files in data/: +{% for file in list_dir(path="data") %} + - {{ file }} +{% endfor %} + +# Count files +{% set files = list_dir(path="templates") %} +Total templates: {{ files | length }} + +# Filter by extension +{% set all_files = list_dir(path="src") %} +Rust files: +{% for file in all_files %} +{% if file is ending_with(".rs") %} + - {{ file }} +{% endif %} +{% endfor %} +``` + +#### `glob(pattern)` +Lists all files matching a glob pattern. + +**Arguments:** +- `pattern` (required) - Glob pattern to match files + - `*` matches any characters + - `?` matches exactly one character + - `**` matches any number of directories + +**Returns:** Array of file paths (sorted alphabetically) + +**Examples:** +``` +# Find all text files +Text files: +{% for file in glob(pattern="*.txt") %} + - {{ file }} +{% endfor %} + +# Find files in subdirectories +All Rust files: +{% for file in glob(pattern="src/**/*.rs") %} + - {{ file }} +{% endfor %} + +# Match specific patterns +Config files: +{% for file in glob(pattern="config*.{json,yaml,toml}") %} + - {{ file }} +{% endfor %} + +# Use in conditionals +{% set test_files = glob(pattern="tests/**/*.rs") %} +{% if test_files | length > 0 %} +Found {{ test_files | length }} test files +{% endif %} +``` + +#### `file_size(path)` +Gets the size of a file in bytes. + +**Arguments:** +- `path` (required) - Relative path to the file + +**Returns:** File size as a number (in bytes) + +**Examples:** +``` +# Get file size +README size: {{ file_size(path="README.md") }} bytes + +# Format with built-in filter +README size: {{ file_size(path="README.md") | filesizeformat }} + +# Compare file sizes +{% set size_a = file_size(path="file_a.txt") %} +{% set size_b = file_size(path="file_b.txt") %} +{% if size_a > size_b %} +file_a.txt is larger +{% else %} +file_b.txt is larger +{% endif %} + +# Calculate total size +{% set files = glob(pattern="data/*.json") %} +{% set total_size = 0 %} +{% for file in files %} +{% set total_size = total_size + file_size(path=file) %} +{% endfor %} +Total data size: {{ total_size | filesizeformat }} +``` + +#### `file_modified(path)` +Gets the last modification time of a file as a Unix timestamp (seconds since epoch). + +**Arguments:** +- `path` (required) - Relative path to the file + +**Returns:** Unix timestamp (number of seconds since January 1, 1970) + +**Examples:** +``` +# Get modification timestamp +Last modified: {{ file_modified(path="config.json") }} + +# Format with date filter +{% set timestamp = file_modified(path="README.md") %} +Last updated: {{ timestamp | date(format="%Y-%m-%d %H:%M:%S") }} + +# Check if file is recent +{% set mod_time = file_modified(path="cache.dat") %} +{% set now_time = now() %} +{% set age_seconds = now_time - mod_time %} +{% if age_seconds < 3600 %} +Cache is fresh (less than 1 hour old) +{% else %} +Cache is stale ({{ age_seconds / 3600 }} hours old) +{% endif %} + +# Find most recently modified file +{% set files = glob(pattern="logs/*.log") %} +{% set newest_time = 0 %} +{% set newest_file = "" %} +{% for file in files %} +{% set mod_time = file_modified(path=file) %} +{% if mod_time > newest_time %} +{% set newest_time = mod_time %} +{% set newest_file = file %} +{% endif %} +{% endfor %} +Most recent log: {{ newest_file }} +``` + +**Practical Example - Build Report:** +``` +# Build Report +Generated: {{ now() | date(format="%Y-%m-%d %H:%M:%S") }} + +## Source Files +{% set rs_files = glob(pattern="src/**/*.rs") %} +Total Rust files: {{ rs_files | length }} + +{% for file in rs_files %} +- {{ file }} + Size: {{ file_size(path=file) | filesizeformat }} + Modified: {{ file_modified(path=file) | date(format="%Y-%m-%d") }} +{% endfor %} + +## Configuration +{% if file_exists(path="Cargo.toml") %} +✓ Cargo.toml found ({{ file_size(path="Cargo.toml") }} bytes) +{% else %} +✗ Cargo.toml missing +{% endif %} + +## Tests +{% set test_files = glob(pattern="tests/**/*.rs") %} +Test files: {{ test_files | length }} +{% for test in test_files %} +- {{ test }} +{% endfor %} +``` + +**Security Restrictions:** + +All filesystem functions enforce the following security rules: + +1. **No Absolute Paths** - Paths starting with `/` are rejected + ``` + {{ read_file(path="/etc/passwd") }} # ✗ ERROR: Security violation + ``` + +2. **No Parent Directory Traversal** - Paths containing `..` are rejected + ``` + {{ read_file(path="../../secret.txt") }} # ✗ ERROR: Security violation + ``` + +3. **Relative Paths Only** - Only files within the current working directory are accessible + ``` + {{ read_file(path="config.txt") }} # ✓ OK + {{ read_file(path="data/file.txt") }} # ✓ OK + {{ file_exists(path="subdir/test.txt") }} # ✓ OK + ``` + +These restrictions ensure templates can only access files in the current working directory and its subdirectories, preventing unauthorized access to system files or files outside the project. + +**Trust Mode:** + +You can bypass these security restrictions by using the `--trust` command-line flag: + +```bash +# Without --trust: Security error +tmpltool template.tmpl # ERROR if template tries to read /etc/passwd + +# With --trust: Unrestricted access +tmpltool --trust template.tmpl # OK, can read any file +``` + +**When to use `--trust`:** +- When you need to access system files or configuration outside your project +- When reading files from absolute paths (e.g., `/etc/hosts`, `/var/log/app.log`) +- When accessing parent directories (e.g., `../config/settings.yml`) +- When you fully trust the template source and know what files it accesses + +**WARNING:** Only use `--trust` with templates you completely trust. Malicious templates could read sensitive files like SSH keys, passwords, or system configurations. + ### Comments ``` {# This is a comment #} @@ -755,6 +1538,15 @@ The project includes comprehensive test coverage. **All tests are located in `te - `test_stdout_output.rs` - Stdout output functionality - `test_direct_var_access_fails.rs` - Direct variable access fails (security test) +**Unit Tests in `tests/`** (58 tests across multiple test files): +- `test_filter_env_unit.rs` - Environment variable filtering (6 tests) +- `test_hash_unit.rs` - Hash functions (6 tests) +- `test_uuid_unit.rs` - UUID generation (3 tests) +- `test_random_string_unit.rs` - Random string generation (11 tests) +- `test_filesystem_unit.rs` - Filesystem functions (23 tests) +- `test_hash_crypto_functions.rs` - Hash and crypto integration (17 tests) +- `test_comprehensive.rs` - Comprehensive template validation (2 tests) + **Test Infrastructure:** - `common.rs` - Shared test utilities and fixture helpers - `fixtures/` - Test fixtures (templates and expected outputs) @@ -762,7 +1554,7 @@ The project includes comprehensive test coverage. **All tests are located in `te **Documentation Tests** (2 tests): - Library documentation examples -Total: **13 tests** covering integration and documentation scenarios. +Total: **71 tests** covering integration, unit tests, and documentation scenarios. #### Adding New Integration Tests @@ -904,6 +1696,13 @@ The project uses minimal dependencies: - Provides built-in filters: `slugify`, `date`, `filesizeformat`, `urlencode`, etc. - Provides built-in functions: `get_env()`, `now()`, `get_random()` - **[clap](https://crates.io/crates/clap)** (v4.x) - Command-line argument parsing +- **[regex](https://crates.io/crates/regex)** (v1.x) - Regular expressions for pattern matching +- **[md-5](https://crates.io/crates/md-5)** (v0.10) - MD5 hash implementation +- **[sha1](https://crates.io/crates/sha1)** (v0.10) - SHA1 hash implementation +- **[sha2](https://crates.io/crates/sha2)** (v0.10) - SHA256 and SHA512 hash implementations +- **[uuid](https://crates.io/crates/uuid)** (v1.x) - UUID generation +- **[rand](https://crates.io/crates/rand)** (v0.8) - Random number generation +- **[glob](https://crates.io/crates/glob)** (v0.3) - Glob pattern matching for filesystem operations To update dependencies: diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..70bed87 --- /dev/null +++ b/TODO.md @@ -0,0 +1,288 @@ +# TODO - Feature Ideas & Improvements + +This document tracks potential features, improvements, and ideas for tmpltool. + +## Custom Functions + +### File System Functions +- [x] `read_file(path)` - Read content from a file into the template ✅ **Implemented v1.0.5** +- [x] `file_exists(path)` - Check if a file exists (returns boolean) ✅ **Implemented v1.0.5** +- [x] `list_dir(path)` - List files in a directory ✅ **Implemented v1.0.5** +- [x] `glob(pattern)` - List files by pattern ✅ **Implemented v1.0.5** +- [x] `file_size(path)` - Get file size in bytes ✅ **Implemented v1.0.5** +- [x] `file_modified(path)` - Get file modification timestamp ✅ **Implemented v1.0.5** + +**Security Note**: All filesystem functions enforce security restrictions: +- Only relative paths allowed (no absolute paths like `/etc/passwd`) +- No parent directory traversal (no `..` in paths) +- Access restricted to current working directory and subdirectories +- Prevents unauthorized access to system files + +**Documentation**: See README.md "Filesystem Functions" section for comprehensive examples and usage. + +### Data Parsing Functions +- [ ] `parse_json(string)` - Parse JSON string into object +- [ ] `parse_yaml(string)` - Parse YAML string into object +- [ ] `parse_toml(string)` - Parse TOML string into object +- [ ] `read_json_file(path)` - Read and parse JSON file +- [ ] `read_yaml_file(path)` - Read and parse YAML file +- [ ] `read_toml_file(path)` - Read and parse TOML file + +### String Manipulation Functions +- [ ] `regex_match(pattern, string)` - Match regex pattern +- [ ] `regex_replace(pattern, replacement, string)` - Replace using regex +- [ ] `substring(string, start, end)` - Extract substring +- [ ] `pad_left(string, length, char)` - Pad string on left +- [ ] `pad_right(string, length, char)` - Pad string on right +- [ ] `repeat(string, count)` - Repeat string N times + +### Encoding/Decoding Functions +- [ ] `base64_encode(string)` - Encode to base64 +- [ ] `base64_decode(string)` - Decode from base64 +- [ ] `hex_encode(string)` - Encode to hexadecimal +- [ ] `hex_decode(string)` - Decode from hexadecimal +- [ ] `url_encode(string)` - URL encode (currently available as filter) +- [ ] `url_decode(string)` - URL decode +- [ ] `json_escape(string)` - Escape string for JSON +- [ ] `html_escape(string)` - Escape HTML entities +- [ ] `html_unescape(string)` - Unescape HTML entities + +### Hash/Crypto Functions +- [x] `md5(string)` - Calculate MD5 hash ✅ **Implemented v1.0.4** +- [x] `sha1(string)` - Calculate SHA1 hash ✅ **Implemented v1.0.4** +- [x] `sha256(string)` - Calculate SHA256 hash ✅ **Implemented v1.0.4** +- [x] `sha512(string)` - Calculate SHA512 hash ✅ **Implemented v1.0.4** +- [x] `uuid()` - Generate UUID v4 ✅ **Implemented v1.0.4** +- [x] `random_string(length, charset)` - Generate random string ✅ **Implemented v1.0.4** + +### Date/Time Functions +- [ ] `format_date(timestamp, format)` - Format timestamp with custom format +- [ ] `parse_date(string, format)` - Parse date string +- [ ] `date_add(timestamp, duration)` - Add duration to timestamp +- [ ] `date_diff(timestamp1, timestamp2)` - Calculate difference between dates +- [ ] `timestamp()` - Get current Unix timestamp +- [ ] `iso8601()` - Get current time in ISO 8601 format (alias for now()) + +### Math Functions +- [ ] `abs(number)` - Absolute value +- [ ] `ceil(number)` - Round up +- [ ] `floor(number)` - Round down +- [ ] `round(number, decimals)` - Round to N decimals +- [ ] `min(array)` - Find minimum value +- [ ] `max(array)` - Find maximum value +- [ ] `sum(array)` - Sum array values +- [ ] `avg(array)` - Calculate average + +### Network Functions +- [ ] `http_get(url)` - Fetch content from URL (consider security implications) +- [ ] `resolve_dns(hostname)` - Resolve DNS hostname to IP + +### Shell/Process Functions +- [ ] `exec(command)` - Execute shell command and return output (consider security implications) +- [ ] `hostname()` - Get system hostname +- [ ] `username()` - Get current username +- [ ] `cwd()` - Get current working directory + +### Validation Functions +- [ ] `is_email(string)` - Validate email format +- [ ] `is_url(string)` - Validate URL format +- [ ] `is_ip(string)` - Validate IP address +- [ ] `is_uuid(string)` - Validate UUID format +- [ ] `matches_regex(pattern, string)` - Check if string matches regex + +## CLI Enhancements + +### Input/Output Options +- [ ] Output directory: `tmpltool -i templates/ -o output/` +- [ ] In-place editing: `tmpltool -i template.tmpl --in-place` +- [ ] Batch processing with glob patterns: `tmpltool templates/*.tmpl` + +### Variable Management +- [ ] Load env variables from JSON file: `tmpltool --env-json vars.json template.tmpl` +- [ ] Load env variables from YAML file: `tmpltool --env-yaml vars.yaml template.tmpl` +- [ ] Pass env variables via CLI: `tmpltool --var key=value template.tmpl` +- [ ] Environment file support: `tmpltool --env-file .env template.tmpl` +- [ ] Variable precedence: CLI > env file > environment + +### Development Features +- [ ] Watch mode: `tmpltool --watch template.tmpl` (auto-reload on changes) +- [ ] REPL mode: `tmpltool --repl` (interactive template testing) +- [ ] Dry-run mode: `tmpltool --dry-run` (validate without writing) +- [ ] Verbose mode: `tmpltool --verbose` (show debug information) +- [ ] Quiet mode: `tmpltool --quiet` (suppress all output except errors) +- [ ] Validate mode: `tmpltool --validate template.tmpl` (syntax check only) + +### Output Formatting +- [ ] Strip whitespace: `tmpltool --strip-whitespace template.tmpl` + +### Error Handling +- [ ] Strict mode: `tmpltool --strict` (fail on undefined variables) +- [ ] Ignore errors: `tmpltool --ignore-errors` (continue on errors) +- [ ] JSON error output: `tmpltool --error-format json` (for tooling integration) + +## Template Features + +### Template Organization +- [ ] Template inheritance support (extend/block) +- [ ] Template includes from filesystem +- [ ] Template includes from URLs +- [ ] Macro library support +- [ ] Partial templates + +### Configuration +- [ ] Custom delimiters: `tmpltool --delimiters '<<' '>>'` +- [ ] Configuration file: `.tmpltool.toml` or `.tmpltool.yaml` +- [ ] Per-project configuration +- [ ] Global configuration in `~/.config/tmpltool/config.toml` + +### Template Functions +- [ ] Custom function plugins (dynamic loading) +- [ ] JavaScript-based custom functions (via embedded runtime) +- [ ] Lua-based custom functions (via embedded runtime) + +## Quality of Life Improvements + +### Documentation +- [ ] Man page: `man tmpltool` +- [ ] Built-in help for functions: `tmpltool --list-functions` +- [ ] Function documentation: `tmpltool --doc filter_env` +- [ ] Example templates library +- [ ] Interactive tutorial + +### Shell Integration +- [ ] Shell completion (bash, zsh, fish) +- [ ] Environment variable completion +- [ ] Template file completion + +### Performance +- [ ] Template caching for repeated renders +- [ ] Parallel processing for multiple files +- [ ] Lazy evaluation for complex expressions +- [ ] Memory-mapped file reading for large files + +### Security +- [ ] Sandbox mode (disable file system access, exec, network) +- [ ] Allowlist for allowed functions +- [ ] Security audit mode (report potentially dangerous operations) +- [ ] Secrets filtering (avoid logging sensitive env vars) + +## Testing & Quality + +### Testing Tools +- [ ] Template test runner: `tmpltool test tests/` +- [ ] Snapshot testing support +- [ ] Coverage reporting for templates +- [ ] Benchmark mode for performance testing + +### Linting +- [ ] Template linter (check for common issues) +- [ ] Style guide enforcement +- [ ] Unused variable detection +- [ ] Cyclomatic complexity warnings + +## Distribution & Packaging + +### Package Managers +- [ ] Homebrew formula (macOS/Linux) +- [ ] APT repository (Debian/Ubuntu) +- [ ] RPM repository (RedHat/Fedora) +- [ ] Chocolatey package (Windows) +- [ ] Scoop package (Windows) +- [ ] AUR package (Arch Linux) + +### Installation +- [ ] Single-binary installer script +- [ ] Docker image on Docker Hub +- [ ] Snap package +- [ ] Flatpak package + +## Integration & Ecosystem + +### CI/CD Integration +- [ ] GitHub Actions integration +- [ ] GitLab CI templates +- [ ] Jenkins plugin +- [ ] CircleCI orb + +### Editor Integration +- [ ] VSCode extension (syntax highlighting, snippets) +- [ ] Vim plugin +- [ ] Emacs mode +- [ ] Language Server Protocol (LSP) server + +### Tools Integration +- [ ] Terraform integration (template provider) +- [ ] Kubernetes integration (ConfigMap/Secret generation) +- [ ] Ansible integration (template module) +- [ ] Docker Compose integration + +## Advanced Features + +### Data Sources +- [ ] Database query support (PostgreSQL, MySQL, SQLite) +- [ ] Redis data fetching +- [ ] S3 object fetching +- [ ] Consul KV store integration +- [ ] Vault secrets integration + +### Output Formats +- [ ] Generate multiple outputs from one template +- [ ] Split output into multiple files +- [ ] Archive output (tar, zip) +- [ ] Stream output to remote destinations + +### Templating Enhancements +- [ ] Conditional includes based on environment +- [ ] Dynamic template loading +- [ ] Template composition (merge multiple templates) +- [ ] Template inheritance chains + +## Ideas for Future Major Versions + +### Version 2.0 +- [ ] Plugin system with hot-reload +- [ ] Built-in template registry/marketplace +- [ ] Cloud-based template sharing +- [ ] Web UI for template development +- [ ] REST API server mode + +### Template Language Extensions +- [ ] TypeScript-like type hints for templates +- [ ] Template compilation to standalone binaries +- [ ] Template optimization/minification +- [ ] Template debugging tools + +## Community & Contribution + +- [ ] Contributing guidelines (CONTRIBUTING.md) +- [ ] Code of conduct +- [ ] Issue templates (bug report, feature request) +- [ ] PR template +- [ ] Roadmap document +- [ ] Changelog automation (already implemented with semantic-release) +- [ ] Community templates repository + +## Questions to Consider + +1. Should we support multiple template engines (Tera, Handlebars, Liquid)? +2. Should we add a server mode (HTTP API for rendering)? +3. Should we support template compilation for better performance? +4. Should we add a GUI for non-technical users? +5. How to balance features vs. simplicity? +6. What's the security model for file/network/exec access? +7. Should we support plugins written in other languages (Python, JavaScript)? + +--- + +**Note**: This is a living document. Ideas should be evaluated based on: +- User demand and use cases +- Maintenance burden +- Security implications +- Performance impact +- Alignment with tool's philosophy (simple, fast, secure) + +**Priority Legend** (to be added as we prioritize): +- 🔥 High priority +- ⭐ Nice to have +- 💡 Needs discussion +- ⚠️ Security/complexity concerns diff --git a/examples/comprehensive-app-config.tmpl b/examples/comprehensive-app-config.tmpl new file mode 100644 index 0000000..81cc8fa --- /dev/null +++ b/examples/comprehensive-app-config.tmpl @@ -0,0 +1,202 @@ +# Application Configuration +# Generated: {{ now() }} +# Instance ID: {{ uuid() }} + +{# ============================================ + Service Configuration + ============================================ #} +service: + name: {{ get_env(name="APP_NAME", default="myapp") | upper }} + version: {{ get_env(name="APP_VERSION", default="1.0.0") }} + environment: {{ get_env(name="ENV", default="development") | upper }} + + # Unique identifiers + instance_id: {{ uuid() }} + deployment_id: {{ uuid() }} + +{# ============================================ + Security & Authentication + ============================================ #} +security: + # Hash functions for integrity checks + config_checksum: {{ md5(string="v1.0-config") }} + license_hash: {{ sha256(string=get_env(name="LICENSE_KEY", default="trial-license")) }} + + # Generated secrets + api_key: {{ random_string(length=32, charset="hex") }} + secret_token: {{ random_string(length=64) }} + csrf_token: {{ random_string(length=40, charset="hex") }} + session_secret: {{ random_string(length=32, charset="alphanumeric") }} + + # Password hashing (example - use proper password hashing in production!) + {% set admin_pwd = get_env(name="ADMIN_PASSWORD", default="changeme123") %} + admin_password_hash: {{ sha512(string=admin_pwd) }} + +{# ============================================ + Database Configuration + ============================================ #} +database: + # Filter all DB_* environment variables + {% set db_vars = filter_env(pattern="DB_*") %} + {% if db_vars | length > 0 %} + # From environment: + {% for var in db_vars %} + {{ var.key | lower | replace(from="db_", to="") }}: {{ var.value }} + {% endfor %} + {% else %} + # Default configuration: + host: {{ get_env(name="DB_HOST", default="localhost") }} + port: {{ get_env(name="DB_PORT", default="5432") }} + name: {{ get_env(name="DB_NAME", default="myapp_db") }} + user: {{ get_env(name="DB_USER", default="app_user") }} + {% endif %} + + # Connection pool + max_connections: {{ get_env(name="DB_MAX_CONNECTIONS", default="20") }} + connection_id: {{ uuid() }} + +{# ============================================ + Server Configuration + ============================================ #} +server: + {% set servers = filter_env(pattern="SERVER_*") %} + {% if servers | length > 0 %} + # Detected server configuration: + {% for srv in servers %} + {{ srv.key | lower | replace(from="server_", to="") }}: {{ srv.value }} + {% endfor %} + {% else %} + # Default server configuration: + host: {{ get_env(name="HOST", default="0.0.0.0") }} + port: {{ get_env(name="PORT", default="8080") }} + protocol: {{ get_env(name="PROTOCOL", default="http") }} + {% endif %} + + # TLS/SSL + {% set enable_tls = get_env(name="ENABLE_TLS", default="false") %} + {% if enable_tls == "true" %} + tls: + enabled: true + cert_path: {{ get_env(name="TLS_CERT_PATH", default="/etc/ssl/cert.pem") }} + key_path: {{ get_env(name="TLS_KEY_PATH", default="/etc/ssl/key.pem") }} + {% else %} + tls: + enabled: false + {% endif %} + +{# ============================================ + Logging Configuration + ============================================ #} +logging: + {% set env_type = get_env(name="ENV", default="development") %} + {% if env_type == "production" %} + level: ERROR + format: json + output: /var/log/app/production.log + {% elif env_type == "staging" %} + level: WARN + format: json + output: /var/log/app/staging.log + {% else %} + level: DEBUG + format: text + output: stdout + {% endif %} + + # Log rotation ID + rotation_id: {{ uuid() }} + +{# ============================================ + Feature Flags + ============================================ #} +features: + {% set features = get_env(name="FEATURES", default="api,web,admin") | split(pat=",") %} + enabled: [{% for feature in features %}"{{ feature | trim }}"{% if not loop.last %}, {% endif %}{% endfor %}] + count: {{ features | length }} + + # Feature-specific settings + {% for feature in features %} + {{ feature | trim | slugify }}: + enabled: true + token: {{ random_string(length=16, charset="hex") }} + {% endfor %} + +{# ============================================ + External Services + ============================================ #} +external_services: + # All API_* environment variables + {% set api_vars = filter_env(pattern="API_*") %} + {% if api_vars | length > 0 %} + apis: + {% for api in api_vars %} + {{ api.key | lower | replace(from="api_", to="") }}: + url: {{ api.value }} + key: {{ random_string(length=32, charset="hex") }} + checksum: {{ md5(string=api.value) }} + {% endfor %} + {% else %} + apis: [] + {% endif %} + +{# ============================================ + Cache Configuration + ============================================ #} +cache: + {% set cache_type = get_env(name="CACHE_TYPE", default="memory") %} + type: {{ cache_type }} + ttl: {{ get_env(name="CACHE_TTL", default="3600") }} + + {% if cache_type == "redis" %} + redis: + host: {{ get_env(name="REDIS_HOST", default="localhost") }} + port: {{ get_env(name="REDIS_PORT", default="6379") }} + db: {{ get_env(name="REDIS_DB", default="0") }} + password_hash: {{ sha256(string=get_env(name="REDIS_PASSWORD", default="")) }} + {% endif %} + +{# ============================================ + Monitoring & Metrics + ============================================ #} +monitoring: + enabled: {{ get_env(name="ENABLE_MONITORING", default="true") }} + endpoint: {{ get_env(name="METRICS_ENDPOINT", default="/metrics") }} + + # Unique tracking IDs + cluster_id: {{ uuid() }} + node_id: {{ uuid() }} + + # Sample intervals (in seconds) + {% set intervals = get_env(name="SAMPLE_INTERVALS", default="10,30,60") | split(pat=",") %} + sample_intervals: [{% for interval in intervals %}{{ interval }}{% if not loop.last %}, {% endif %}{% endfor %}] + +{# ============================================ + Recovery & Backup + ============================================ #} +recovery: + # Recovery codes (for 2FA backup) + codes: + {% for i in range(end=5) %} + - {{ random_string(length=8, charset="uppercase") }}-{{ random_string(length=8, charset="uppercase") }} + {% endfor %} + + # Backup encryption key + backup_key: {{ random_string(length=64, charset="hex") }} + backup_key_hash: {{ sha256(string=get_env(name="BACKUP_PASSPHRASE", default="default-passphrase")) }} + +{# ============================================ + Metadata + ============================================ #} +metadata: + generated_at: {{ now() }} + generated_by: tmpltool + template_version: "2.0" + config_hash: {{ sha1(string="comprehensive-config-v2.0") }} + + # All environment variables used + environment_variables: + {% set all_env = filter_env(pattern="*") %} + total_count: {{ all_env | length }} + app_vars: {{ filter_env(pattern="APP_*") | length }} + db_vars: {{ filter_env(pattern="DB_*") | length }} + server_vars: {{ filter_env(pattern="SERVER_*") | length }} diff --git a/examples/hash-crypto.tmpl b/examples/hash-crypto.tmpl new file mode 100644 index 0000000..ba69d0d --- /dev/null +++ b/examples/hash-crypto.tmpl @@ -0,0 +1,73 @@ +# Hash and Crypto Functions Example + +## MD5 Hash +Original: hello world +MD5: {{ md5(string="hello world") }} + +## SHA1 Hash +Original: tmpltool +SHA1: {{ sha1(string="tmpltool") }} + +## SHA256 Hash +{% set password = get_env(name="PASSWORD", default="secret123") -%} +Password: {{ password }} +SHA256: {{ sha256(string=password) }} + +## SHA512 Hash +Original: secure-data +SHA512: {{ sha512(string="secure-data") }} + +## UUID Generation +Request ID: {{ uuid() }} +Session ID: {{ uuid() }} +Transaction ID: {{ uuid() }} + +## Random String Generation + +### Alphanumeric (default) +API Key: {{ random_string(length=32) }} + +### Lowercase letters only +Username: user_{{ random_string(length=8, charset="lowercase") }} + +### Uppercase letters only +Code: {{ random_string(length=6, charset="uppercase") }} + +### Numeric only +PIN: {{ random_string(length=4, charset="numeric") }} + +### Hexadecimal +Token: {{ random_string(length=16, charset="hex") }} + +### Custom charset +Custom: {{ random_string(length=12, charset="abc123") }} + +## Combined Example: Secure Configuration + +```yaml +application: + instance_id: {{ uuid() }} + secret_key: {{ random_string(length=64, charset="alphanumeric") }} + api_token: {{ random_string(length=32, charset="hex") }} + +security: + password_hash: {{ sha256(string=get_env(name="ADMIN_PASSWORD", default="changeme")) }} + checksum: {{ md5(string="config-v1.0") }} + +session: + session_id: {{ uuid() }} + csrf_token: {{ random_string(length=40, charset="hex") }} +``` + +## Password Hashing Example +{% set user_password = get_env(name="USER_PASSWORD", default="password123") -%} +User: admin +Password (SHA256): {{ sha256(string=user_password) }} +Salt: {{ random_string(length=16, charset="hex") }} + +## Multi-factor Authentication +TOTP Secret: {{ random_string(length=32, charset="uppercase") }} +Recovery Codes: +{%- for i in range(end=5) %} + - {{ random_string(length=8, charset="alphanumeric") }} +{%- endfor %} diff --git a/examples/server-config.tmpl b/examples/server-config.tmpl new file mode 100644 index 0000000..cda3914 --- /dev/null +++ b/examples/server-config.tmpl @@ -0,0 +1,22 @@ +# Server Configuration +# Generated from environment variables + +## All SERVER_* variables: +{% for var in filter_env(pattern="SERVER_*") -%} +{{ var.key }}={{ var.value }} +{% endfor %} + +## All DATABASE_* variables: +{% set db_vars = filter_env(pattern="DATABASE_*") -%} +{% if db_vars | length > 0 -%} +{% for var in db_vars -%} +{{ var.key }}={{ var.value }} +{% endfor -%} +{% else -%} +# No DATABASE_* variables found +{% endif -%} + +## All environment variables starting with APP_: +{% for var in filter_env(pattern="APP_*") -%} +export {{ var.key }}="{{ var.value }}" +{% endfor -%} diff --git a/src/cli.rs b/src/cli.rs index 424f13f..529106b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -11,4 +11,9 @@ pub struct Cli { /// Output file (if not specified, prints to stdout) #[arg(short, long)] pub output: Option, + + /// Trust mode: Allow filesystem functions to access absolute paths and parent directories + /// WARNING: This disables security restrictions. Only use with trusted templates. + #[arg(long)] + pub trust: bool, } diff --git a/src/functions/filesystem.rs b/src/functions/filesystem.rs new file mode 100644 index 0000000..b63b275 --- /dev/null +++ b/src/functions/filesystem.rs @@ -0,0 +1,255 @@ +/// File system functions +/// +/// Provides functions for interacting with the file system: +/// - read_file: Read file contents +/// - file_exists: Check if file exists +/// - list_dir: List directory contents +/// - glob: List files by pattern +/// - file_size: Get file size +/// - file_modified: Get file modification timestamp +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use tera::{Function, Result, Value, to_value}; + +/// Read file content function +pub struct ReadFile { + trust_mode: bool, +} + +impl ReadFile { + pub fn new(trust_mode: bool) -> Self { + ReadFile { trust_mode } + } +} + +impl Function for ReadFile { + fn call(&self, args: &HashMap) -> Result { + let path = args.get("path").and_then(|v| v.as_str()).ok_or_else(|| { + tera::Error::msg("read_file requires a 'path' argument (e.g., path=\"config.txt\")") + })?; + + // Security: Prevent reading absolute paths or paths with parent directory traversal (unless trust mode is enabled) + if !self.trust_mode && (path.starts_with('/') || path.contains("..")) { + return Err(tera::Error::msg(format!( + "Security: Absolute paths and parent directory (..) access are not allowed: {}. Use --trust to bypass this restriction.", + path + ))); + } + + let content = fs::read_to_string(path) + .map_err(|e| tera::Error::msg(format!("Failed to read file '{}': {}", path, e)))?; + + to_value(&content) + .map_err(|e| tera::Error::msg(format!("Failed to convert content: {}", e))) + } +} + +/// Check if file exists function +pub struct FileExists { + trust_mode: bool, +} + +impl FileExists { + pub fn new(trust_mode: bool) -> Self { + FileExists { trust_mode } + } +} + +impl Function for FileExists { + fn call(&self, args: &HashMap) -> Result { + let path = args.get("path").and_then(|v| v.as_str()).ok_or_else(|| { + tera::Error::msg("file_exists requires a 'path' argument (e.g., path=\"file.txt\")") + })?; + + // Security: Prevent checking absolute paths or paths with parent directory traversal (unless trust mode is enabled) + if !self.trust_mode && (path.starts_with('/') || path.contains("..")) { + return Err(tera::Error::msg(format!( + "Security: Absolute paths and parent directory (..) access are not allowed: {}. Use --trust to bypass this restriction.", + path + ))); + } + + let exists = Path::new(path).exists(); + + to_value(exists).map_err(|e| tera::Error::msg(format!("Failed to convert result: {}", e))) + } +} + +/// List directory contents function +pub struct ListDir { + trust_mode: bool, +} + +impl ListDir { + pub fn new(trust_mode: bool) -> Self { + ListDir { trust_mode } + } +} + +impl Function for ListDir { + fn call(&self, args: &HashMap) -> Result { + let path = args.get("path").and_then(|v| v.as_str()).ok_or_else(|| { + tera::Error::msg("list_dir requires a 'path' argument (e.g., path=\"./data\")") + })?; + + // Security: Prevent listing absolute paths or paths with parent directory traversal (unless trust mode is enabled) + if !self.trust_mode && (path.starts_with('/') || path.contains("..")) { + return Err(tera::Error::msg(format!( + "Security: Absolute paths and parent directory (..) access are not allowed: {}. Use --trust to bypass this restriction.", + path + ))); + } + + let entries = fs::read_dir(path) + .map_err(|e| tera::Error::msg(format!("Failed to read directory '{}': {}", path, e)))?; + + let mut files: Vec = Vec::new(); + for entry in entries { + let entry = entry + .map_err(|e| tera::Error::msg(format!("Failed to read directory entry: {}", e)))?; + let file_name = entry + .file_name() + .into_string() + .unwrap_or_else(|_| String::from("?")); + files.push(file_name); + } + + // Sort for consistent output + files.sort(); + + to_value(&files).map_err(|e| tera::Error::msg(format!("Failed to convert result: {}", e))) + } +} + +/// Glob pattern matching function +pub struct GlobFiles { + trust_mode: bool, +} + +impl GlobFiles { + pub fn new(trust_mode: bool) -> Self { + GlobFiles { trust_mode } + } +} + +impl Function for GlobFiles { + fn call(&self, args: &HashMap) -> Result { + let pattern = args + .get("pattern") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + tera::Error::msg("glob requires a 'pattern' argument (e.g., pattern=\"*.txt\")") + })?; + + // Security: Prevent absolute paths or paths with parent directory traversal (unless trust mode is enabled) + if !self.trust_mode && (pattern.starts_with('/') || pattern.contains("..")) { + return Err(tera::Error::msg(format!( + "Security: Absolute paths and parent directory (..) access are not allowed: {}. Use --trust to bypass this restriction.", + pattern + ))); + } + + let glob_result = glob::glob(pattern) + .map_err(|e| tera::Error::msg(format!("Invalid glob pattern '{}': {}", pattern, e)))?; + + let mut files: Vec = Vec::new(); + for entry in glob_result { + match entry { + Ok(path) => { + if let Some(path_str) = path.to_str() { + files.push(path_str.to_string()); + } + } + Err(e) => { + return Err(tera::Error::msg(format!("Glob error: {}", e))); + } + } + } + + // Sort for consistent output + files.sort(); + + to_value(&files).map_err(|e| tera::Error::msg(format!("Failed to convert result: {}", e))) + } +} + +/// Get file size function +pub struct FileSize { + trust_mode: bool, +} + +impl FileSize { + pub fn new(trust_mode: bool) -> Self { + FileSize { trust_mode } + } +} + +impl Function for FileSize { + fn call(&self, args: &HashMap) -> Result { + let path = args.get("path").and_then(|v| v.as_str()).ok_or_else(|| { + tera::Error::msg("file_size requires a 'path' argument (e.g., path=\"data.bin\")") + })?; + + // Security: Prevent accessing absolute paths or paths with parent directory traversal (unless trust mode is enabled) + if !self.trust_mode && (path.starts_with('/') || path.contains("..")) { + return Err(tera::Error::msg(format!( + "Security: Absolute paths and parent directory (..) access are not allowed: {}. Use --trust to bypass this restriction.", + path + ))); + } + + let metadata = fs::metadata(path).map_err(|e| { + tera::Error::msg(format!("Failed to get file metadata for '{}': {}", path, e)) + })?; + + let size = metadata.len(); + + to_value(size).map_err(|e| tera::Error::msg(format!("Failed to convert result: {}", e))) + } +} + +/// Get file modification time function +pub struct FileModified { + trust_mode: bool, +} + +impl FileModified { + pub fn new(trust_mode: bool) -> Self { + FileModified { trust_mode } + } +} + +impl Function for FileModified { + fn call(&self, args: &HashMap) -> Result { + let path = args.get("path").and_then(|v| v.as_str()).ok_or_else(|| { + tera::Error::msg("file_modified requires a 'path' argument (e.g., path=\"file.txt\")") + })?; + + // Security: Prevent accessing absolute paths or paths with parent directory traversal (unless trust mode is enabled) + if !self.trust_mode && (path.starts_with('/') || path.contains("..")) { + return Err(tera::Error::msg(format!( + "Security: Absolute paths and parent directory (..) access are not allowed: {}. Use --trust to bypass this restriction.", + path + ))); + } + + let metadata = fs::metadata(path).map_err(|e| { + tera::Error::msg(format!("Failed to get file metadata for '{}': {}", path, e)) + })?; + + let modified = metadata + .modified() + .map_err(|e| tera::Error::msg(format!("Failed to get modification time: {}", e)))?; + + // Convert to Unix timestamp (seconds since epoch) + let duration = modified + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| tera::Error::msg(format!("Failed to convert timestamp: {}", e)))?; + + let timestamp = duration.as_secs(); + + to_value(timestamp) + .map_err(|e| tera::Error::msg(format!("Failed to convert result: {}", e))) + } +} diff --git a/src/functions/filter_env.rs b/src/functions/filter_env.rs new file mode 100644 index 0000000..d175166 --- /dev/null +++ b/src/functions/filter_env.rs @@ -0,0 +1,96 @@ +/// Filter environment variables by pattern +/// +/// This module provides a Tera function to filter environment variables +/// matching a glob pattern (e.g., "SERVER_*", "DB_*", etc.) +use std::collections::HashMap; +use std::env; +use tera::{Function, Result, Value, to_value}; + +/// A Tera function that filters environment variables by pattern +/// +/// Returns a list of objects with `key` and `value` fields for all +/// environment variables matching the given glob pattern. +/// +/// # Arguments +/// +/// * `pattern` - A glob pattern to match environment variable names +/// - Use `*` to match any characters +/// - Use `?` to match a single character +/// - Examples: "SERVER_*", "DB_*", "*_PORT", "APP_?_NAME" +/// +/// # Returns +/// +/// A list of objects, each containing: +/// * `key` - The environment variable name +/// * `value` - The environment variable value +/// +/// # Examples +/// +/// ```tera +/// {% for var in filter_env(pattern="SERVER_*") %} +/// {{ var.key }}={{ var.value }} +/// {% endfor %} +/// ``` +pub struct FilterEnv; + +impl Function for FilterEnv { + fn call(&self, args: &HashMap) -> Result { + // Get the pattern argument + let pattern = args + .get("pattern") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + tera::Error::msg( + "filter_env requires a 'pattern' argument (e.g., pattern=\"SERVER_*\")", + ) + })?; + + // Convert glob pattern to regex + let regex_pattern = glob_to_regex(pattern); + let re = regex::Regex::new(®ex_pattern) + .map_err(|e| tera::Error::msg(format!("Invalid pattern: {}", e)))?; + + // Filter environment variables + let mut results: Vec> = env::vars() + .filter(|(key, _)| re.is_match(key)) + .map(|(key, value)| { + let mut map = HashMap::new(); + map.insert("key".to_string(), key); + map.insert("value".to_string(), value); + map + }) + .collect(); + + // Sort by key for consistent output + results.sort_by(|a, b| a.get("key").cmp(&b.get("key"))); + + to_value(&results) + .map_err(|e| tera::Error::msg(format!("Failed to convert results: {}", e))) + } +} + +/// Convert a glob pattern to a regex pattern +/// +/// Supports: +/// * `*` - matches any characters (including none) +/// * `?` - matches exactly one character +/// * All other characters are escaped for literal matching +fn glob_to_regex(pattern: &str) -> String { + let mut regex = String::from("^"); + + for ch in pattern.chars() { + match ch { + '*' => regex.push_str(".*"), + '?' => regex.push('.'), + // Escape regex special characters + '.' | '+' | '^' | '$' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\' => { + regex.push('\\'); + regex.push(ch); + } + _ => regex.push(ch), + } + } + + regex.push('$'); + regex +} diff --git a/src/functions/hash.rs b/src/functions/hash.rs new file mode 100644 index 0000000..6cae351 --- /dev/null +++ b/src/functions/hash.rs @@ -0,0 +1,85 @@ +/// Hash functions for cryptographic operations +/// +/// Provides MD5, SHA1, SHA256, and SHA512 hashing functions. +use std::collections::HashMap; +use tera::{Function, Result, Value, to_value}; + +/// MD5 hash function +pub struct Md5; + +impl Function for Md5 { + fn call(&self, args: &HashMap) -> Result { + use md5::{Digest, Md5 as Md5Hasher}; + + let input = args.get("string").and_then(|v| v.as_str()).ok_or_else(|| { + tera::Error::msg("md5 requires a 'string' argument (e.g., string=\"hello\")") + })?; + + let mut hasher = Md5Hasher::new(); + hasher.update(input.as_bytes()); + let result = hasher.finalize(); + let hash = format!("{:x}", result); + + to_value(&hash).map_err(|e| tera::Error::msg(format!("Failed to convert hash: {}", e))) + } +} + +/// SHA1 hash function +pub struct Sha1; + +impl Function for Sha1 { + fn call(&self, args: &HashMap) -> Result { + use sha1::{Digest, Sha1 as Sha1Hasher}; + + let input = args.get("string").and_then(|v| v.as_str()).ok_or_else(|| { + tera::Error::msg("sha1 requires a 'string' argument (e.g., string=\"hello\")") + })?; + + let mut hasher = Sha1Hasher::new(); + hasher.update(input.as_bytes()); + let result = hasher.finalize(); + let hash = format!("{:x}", result); + + to_value(&hash).map_err(|e| tera::Error::msg(format!("Failed to convert hash: {}", e))) + } +} + +/// SHA256 hash function +pub struct Sha256; + +impl Function for Sha256 { + fn call(&self, args: &HashMap) -> Result { + use sha2::{Digest, Sha256 as Sha256Hasher}; + + let input = args.get("string").and_then(|v| v.as_str()).ok_or_else(|| { + tera::Error::msg("sha256 requires a 'string' argument (e.g., string=\"hello\")") + })?; + + let mut hasher = Sha256Hasher::new(); + hasher.update(input.as_bytes()); + let result = hasher.finalize(); + let hash = format!("{:x}", result); + + to_value(&hash).map_err(|e| tera::Error::msg(format!("Failed to convert hash: {}", e))) + } +} + +/// SHA512 hash function +pub struct Sha512; + +impl Function for Sha512 { + fn call(&self, args: &HashMap) -> Result { + use sha2::{Digest, Sha512 as Sha512Hasher}; + + let input = args.get("string").and_then(|v| v.as_str()).ok_or_else(|| { + tera::Error::msg("sha512 requires a 'string' argument (e.g., string=\"hello\")") + })?; + + let mut hasher = Sha512Hasher::new(); + hasher.update(input.as_bytes()); + let result = hasher.finalize(); + let hash = format!("{:x}", result); + + to_value(&hash).map_err(|e| tera::Error::msg(format!("Failed to convert hash: {}", e))) + } +} diff --git a/src/functions/mod.rs b/src/functions/mod.rs index e4a0933..6ed947b 100644 --- a/src/functions/mod.rs +++ b/src/functions/mod.rs @@ -11,6 +11,23 @@ //! - `get_random(start, end)` - Generate random integers //! - And many built-in filters: slugify, date, filesizeformat, urlencode, etc. //! +//! # Custom Functions +//! +//! tmpltool provides additional custom functions: +//! - `filter_env(pattern)` - Filter environment variables by glob pattern (e.g., "SERVER_*") +//! - `md5(string)` - Calculate MD5 hash of a string +//! - `sha1(string)` - Calculate SHA1 hash of a string +//! - `sha256(string)` - Calculate SHA256 hash of a string +//! - `sha512(string)` - Calculate SHA512 hash of a string +//! - `uuid()` - Generate a random UUID v4 +//! - `random_string(length, charset)` - Generate a random string with custom length and character set +//! - `read_file(path)` - Read content from a file +//! - `file_exists(path)` - Check if a file exists +//! - `list_dir(path)` - List files in a directory +//! - `glob(pattern)` - List files matching a glob pattern +//! - `file_size(path)` - Get file size in bytes +//! - `file_modified(path)` - Get file modification timestamp +//! //! # Adding Custom Functions //! //! To add a new custom function: @@ -33,6 +50,12 @@ //! } //! ``` +pub mod filesystem; +pub mod filter_env; +pub mod hash; +pub mod random_string; +pub mod uuid_gen; + use tera::Tera; /// Register all custom functions with the Tera instance @@ -44,6 +67,7 @@ use tera::Tera; /// # Arguments /// /// * `tera` - Mutable reference to a Tera instance +/// * `trust_mode` - If true, disables filesystem security restrictions /// /// # Example /// @@ -52,10 +76,29 @@ use tera::Tera; /// use tmpltool::functions::register_all; /// /// let mut tera = Tera::default(); -/// register_all(&mut tera); +/// register_all(&mut tera, false); /// ``` -pub fn register_all(_tera: &mut Tera) { - // Add custom function registrations here as you create them - // Example: - // tera.register_function("my_function", my_function::my_function); +pub fn register_all(tera: &mut Tera, trust_mode: bool) { + // Register custom functions + tera.register_function("filter_env", filter_env::FilterEnv); + + // Hash functions + tera.register_function("md5", hash::Md5); + tera.register_function("sha1", hash::Sha1); + tera.register_function("sha256", hash::Sha256); + tera.register_function("sha512", hash::Sha512); + + // UUID generation + tera.register_function("uuid", uuid_gen::UuidV4); + + // Random string generation + tera.register_function("random_string", random_string::RandomString); + + // File system functions (with trust mode) + tera.register_function("read_file", filesystem::ReadFile::new(trust_mode)); + tera.register_function("file_exists", filesystem::FileExists::new(trust_mode)); + tera.register_function("list_dir", filesystem::ListDir::new(trust_mode)); + tera.register_function("glob", filesystem::GlobFiles::new(trust_mode)); + tera.register_function("file_size", filesystem::FileSize::new(trust_mode)); + tera.register_function("file_modified", filesystem::FileModified::new(trust_mode)); } diff --git a/src/functions/random_string.rs b/src/functions/random_string.rs new file mode 100644 index 0000000..d5221e2 --- /dev/null +++ b/src/functions/random_string.rs @@ -0,0 +1,78 @@ +/// Random string generation function +/// +/// Generates random strings with customizable length and character sets +use rand::Rng; +use std::collections::HashMap; +use tera::{Function, Result, Value, to_value}; + +/// Character set presets +const CHARSET_ALPHANUMERIC: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +const CHARSET_ALPHABETIC: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +const CHARSET_LOWERCASE: &str = "abcdefghijklmnopqrstuvwxyz"; +const CHARSET_UPPERCASE: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const CHARSET_NUMERIC: &str = "0123456789"; +const CHARSET_HEX: &str = "0123456789abcdef"; +const CHARSET_HEX_UPPER: &str = "0123456789ABCDEF"; + +/// Generate random string +pub struct RandomString; + +impl Function for RandomString { + fn call(&self, args: &HashMap) -> Result { + // Get length (required) + let length = args.get("length").and_then(|v| v.as_u64()).ok_or_else(|| { + tera::Error::msg("random_string requires a 'length' argument (e.g., length=16)") + })?; + + if length == 0 { + return to_value("") + .map_err(|e| tera::Error::msg(format!("Failed to convert result: {}", e))); + } + + if length > 10000 { + return Err(tera::Error::msg( + "random_string length must be <= 10000 to prevent excessive memory usage", + )); + } + + // Get charset (optional, defaults to alphanumeric) + let charset = if let Some(charset_value) = args.get("charset") { + if let Some(charset_str) = charset_value.as_str() { + // Check for preset charsets + match charset_str { + "alphanumeric" => CHARSET_ALPHANUMERIC, + "alphabetic" | "alpha" => CHARSET_ALPHABETIC, + "lowercase" | "lower" => CHARSET_LOWERCASE, + "uppercase" | "upper" => CHARSET_UPPERCASE, + "numeric" | "digits" => CHARSET_NUMERIC, + "hex" | "hexadecimal" => CHARSET_HEX, + "hex_upper" => CHARSET_HEX_UPPER, + _ => charset_str, // Custom charset + } + } else { + return Err(tera::Error::msg( + "charset must be a string (e.g., charset=\"alphanumeric\" or charset=\"abc123\")", + )); + } + } else { + CHARSET_ALPHANUMERIC + }; + + if charset.is_empty() { + return Err(tera::Error::msg("charset cannot be empty")); + } + + // Generate random string + let mut rng = rand::thread_rng(); + let charset_chars: Vec = charset.chars().collect(); + let random_string: String = (0..length) + .map(|_| { + let idx = rng.gen_range(0..charset_chars.len()); + charset_chars[idx] + }) + .collect(); + + to_value(&random_string) + .map_err(|e| tera::Error::msg(format!("Failed to convert result: {}", e))) + } +} diff --git a/src/functions/uuid_gen.rs b/src/functions/uuid_gen.rs new file mode 100644 index 0000000..8c7a869 --- /dev/null +++ b/src/functions/uuid_gen.rs @@ -0,0 +1,19 @@ +/// UUID generation function +/// +/// Generates UUIDs (Universally Unique Identifiers) +use std::collections::HashMap; +use tera::{Function, Result, Value, to_value}; +use uuid::Uuid; + +/// Generate UUID v4 (random) +pub struct UuidV4; + +impl Function for UuidV4 { + fn call(&self, _args: &HashMap) -> Result { + let uuid = Uuid::new_v4(); + let uuid_string = uuid.to_string(); + + to_value(&uuid_string) + .map_err(|e| tera::Error::msg(format!("Failed to convert UUID: {}", e))) + } +} diff --git a/src/main.rs b/src/main.rs index 9321516..8145262 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use tmpltool::{Cli, render_template}; fn main() { let cli = Cli::parse(); - if let Err(e) = render_template(cli.template.as_deref(), cli.output.as_deref()) { + if let Err(e) = render_template(cli.template.as_deref(), cli.output.as_deref(), cli.trust) { eprintln!("Error: {}", e); process::exit(1); } diff --git a/src/renderer.rs b/src/renderer.rs index a27556a..82dc629 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -1,4 +1,5 @@ use crate::functions; +use std::error::Error; use std::fs; use std::io::{self, Read, Write}; use tera::{Context, Tera}; @@ -9,6 +10,7 @@ use tera::{Context, Tera}; /// /// * `template_source` - Optional path to template file. If None, reads from stdin /// * `output_file` - Optional path to output file. If None, prints to stdout +/// * `trust_mode` - If true, disables filesystem security restrictions /// /// # Returns /// @@ -16,6 +18,7 @@ use tera::{Context, Tera}; pub fn render_template( template_source: Option<&str>, output_file: Option<&str>, + trust_mode: bool, ) -> Result<(), Box> { // Read template from file or stdin let template_content = read_template(template_source)?; @@ -24,7 +27,7 @@ pub fn render_template( let context = Context::new(); // Render the template - let rendered = render(&template_content, &context)?; + let rendered = render(&template_content, &context, trust_mode)?; // Write output to file or stdout write_output(&rendered, output_file)?; @@ -60,17 +63,49 @@ fn read_template(template_source: Option<&str>) -> Result Result> { +fn render( + template_content: &str, + context: &Context, + trust_mode: bool, +) -> Result> { let mut tera = Tera::default(); // Register all custom functions - functions::register_all(&mut tera); + functions::register_all(&mut tera, trust_mode); tera.add_raw_template("template", template_content) - .map_err(|e| format!("Failed to parse template: {}", e))?; + .map_err(|e| format_tera_error("Failed to parse template", &e))?; tera.render("template", context) - .map_err(|e| format!("Failed to render template: {}", e).into()) + .map_err(|e| format_tera_error("Failed to render template", &e).into()) +} + +/// Formats Tera errors with detailed information +fn format_tera_error(prefix: &str, error: &tera::Error) -> String { + use std::fmt::Write; + + let mut msg = String::new(); + writeln!(&mut msg, "{}", prefix).ok(); + writeln!(&mut msg).ok(); + + // Main error message + writeln!(&mut msg, "Error: {}", error).ok(); + + // Add source information if available + if let Some(source) = Error::source(error) { + writeln!(&mut msg).ok(); + writeln!(&mut msg, "Caused by:").ok(); + writeln!(&mut msg, " {}", source).ok(); + + // Chain of causes + let mut current_source = Error::source(source); + while let Some(cause) = current_source { + writeln!(&mut msg, " {}", cause).ok(); + current_source = Error::source(cause); + } + } + + msg } /// Writes the rendered content to file or stdout diff --git a/test_data/file3.txt b/test_data/file3.txt new file mode 100644 index 0000000..a2b3229 --- /dev/null +++ b/test_data/file3.txt @@ -0,0 +1 @@ +content3 \ No newline at end of file diff --git a/tests/fixtures/expected/comprehensive.txt b/tests/fixtures/expected/comprehensive.txt new file mode 100644 index 0000000..046ef25 --- /dev/null +++ b/tests/fixtures/expected/comprehensive.txt @@ -0,0 +1,79 @@ +# Comprehensive Template Test +# Tests all custom and built-in Tera functions + +## 1. Built-in get_env() function +### With defaults: +PORT=3000 +HOST=testhost +DEBUG=true + +### Without defaults (set by test): +API_KEY=secret-key-123 + +## 2. Custom filter_env() function +### Filter by pattern: +TEST_SERVER_HOST=server1.example.com +TEST_SERVER_PORT=9000 + + +## 3. Built-in filters +### String filters: +Original: jane smith +Upper: JANE SMITH +Lower: jane smith +Title: Jane Smith +Slugified: jane-smith +Trimmed: spaces + +### Array filters: +Items count: 3 +Items: + - Mango + - Orange + - Pineapple + +### Number formatting: +File size: 1 MB + +### URL encoding: +Encoded: hello%20world%20%26%20foo%3Dbar + +## 4. Conditionals with environment variables +Feature is ENABLED + + +## 5. Loops with filter_env +Database connections: + + TEST_DB_HOST: db.example.com + TEST_DB_NAME: myapp + TEST_DB_PORT: 5432 + + +## 6. Complex operations +### Combining filters and functions: +Tags (3 total): + #PYTHON + #DOCKER + #K8S + +### Nested conditionals: +Environment: PRODUCTION (PRODUCTION) +Log level: ERROR + + +## 7. Comments (should not appear in output) + + + +## 8. Date/time function +Current timestamp: __TIMESTAMP__ + +## 9. Random number function +Random (1-10): __RANDOM__ + +## 10. String concatenation with filters +Service name: API_SERVICE +Container: api-container-__RANDOM__ + +## End of comprehensive test diff --git a/tests/fixtures/expected/filter_env.txt b/tests/fixtures/expected/filter_env.txt new file mode 100644 index 0000000..5f2949f --- /dev/null +++ b/tests/fixtures/expected/filter_env.txt @@ -0,0 +1,7 @@ +Server Configuration: + + SERVER_HOST=localhost + + SERVER_NAME=myapp + + SERVER_PORT=8080 diff --git a/tests/fixtures/templates/comprehensive.tmpl b/tests/fixtures/templates/comprehensive.tmpl new file mode 100644 index 0000000..5816ca3 --- /dev/null +++ b/tests/fixtures/templates/comprehensive.tmpl @@ -0,0 +1,101 @@ +# Comprehensive Template Test +# Tests all custom and built-in Tera functions + +## 1. Built-in get_env() function +### With defaults: +PORT={{ get_env(name="TEST_PORT", default="8080") }} +HOST={{ get_env(name="TEST_HOST", default="localhost") }} +DEBUG={{ get_env(name="TEST_DEBUG", default="false") }} + +### Without defaults (set by test): +API_KEY={{ get_env(name="TEST_API_KEY") }} + +## 2. Custom filter_env() function +### Filter by pattern: +{% for var in filter_env(pattern="TEST_SERVER_*") -%} +{{ var.key }}={{ var.value }} +{% endfor %} + +## 3. Built-in filters +### String filters: +{% set name = get_env(name="TEST_NAME", default="john doe") -%} +Original: {{ name }} +Upper: {{ name | upper }} +Lower: {{ name | lower }} +Title: {{ name | title }} +Slugified: {{ name | slugify }} +Trimmed: {{ " spaces " | trim }} + +### Array filters: +{% set items = get_env(name="TEST_ITEMS", default="apple,banana,cherry") | split(pat=",") -%} +Items count: {{ items | length }} +Items: +{%- for item in items %} + - {{ item | title }} +{%- endfor %} + +### Number formatting: +File size: {{ 1048576 | filesizeformat }} + +### URL encoding: +Encoded: {{ "hello world & foo=bar" | urlencode }} + +## 4. Conditionals with environment variables +{% set enable_feature = get_env(name="TEST_FEATURE_FLAG", default="false") -%} +{% if enable_feature == "true" -%} +Feature is ENABLED +{% else -%} +Feature is DISABLED +{% endif %} + +## 5. Loops with filter_env +Database connections: +{% set db_vars = filter_env(pattern="TEST_DB_*") -%} +{% if db_vars | length > 0 -%} +{%- for var in db_vars %} + {{ var.key }}: {{ var.value }} +{%- endfor %} +{% else -%} + No database variables found +{% endif %} + +## 6. Complex operations +### Combining filters and functions: +{% set tags = get_env(name="TEST_TAGS", default="rust,tera,cli") | split(pat=",") -%} +Tags ({{ tags | length }} total): +{%- for tag in tags %} + #{{ tag | upper }} +{%- endfor %} + +### Nested conditionals: +{% set env_type = get_env(name="TEST_ENV", default="development") -%} +{% if env_type == "production" -%} +Environment: PRODUCTION ({{ env_type | upper }}) +Log level: ERROR +{% elif env_type == "staging" -%} +Environment: STAGING ({{ env_type | upper }}) +Log level: WARN +{% else -%} +Environment: DEVELOPMENT ({{ env_type | upper }}) +Log level: DEBUG +{% endif %} + +## 7. Comments (should not appear in output) +{# This is a comment and should not appear in the output #} +{# Neither should this: + Multi-line comment + with multiple lines +#} + +## 8. Date/time function +Current timestamp: {{ now() }} + +## 9. Random number function +Random (1-10): {{ get_random(start=1, end=10) }} + +## 10. String concatenation with filters +{% set service = get_env(name="TEST_SERVICE", default="web") -%} +Service name: {{ service | upper }}_SERVICE +Container: {{ service }}-container-{{ get_random(start=100, end=999) }} + +## End of comprehensive test diff --git a/tests/fixtures/templates/filter_env.tmpl b/tests/fixtures/templates/filter_env.tmpl new file mode 100644 index 0000000..7e1c02a --- /dev/null +++ b/tests/fixtures/templates/filter_env.tmpl @@ -0,0 +1,4 @@ +Server Configuration: +{% for var in filter_env(pattern="SERVER_*") %} + {{ var.key }}={{ var.value }} +{% endfor %} diff --git a/tests/test_comprehensive.rs b/tests/test_comprehensive.rs new file mode 100644 index 0000000..70c83d0 --- /dev/null +++ b/tests/test_comprehensive.rs @@ -0,0 +1,274 @@ +mod common; + +use common::{cleanup_test_file, get_test_file_path, read_fixture_expected, read_fixture_template}; +use regex::Regex; +use std::env; +use std::fs; +use tmpltool::render_template; + +#[test] +fn test_comprehensive_template() { + // Set all test environment variables + unsafe { + // Basic variables with defaults + env::set_var("TEST_PORT", "3000"); + env::set_var("TEST_HOST", "testhost"); + env::set_var("TEST_DEBUG", "true"); + + // Required variable without default + env::set_var("TEST_API_KEY", "secret-key-123"); + + // Variables for filter_env pattern TEST_SERVER_* + env::set_var("TEST_SERVER_HOST", "server1.example.com"); + env::set_var("TEST_SERVER_PORT", "9000"); + + // String for testing filters + env::set_var("TEST_NAME", "jane smith"); + + // Items for array operations + env::set_var("TEST_ITEMS", "mango,orange,pineapple"); + + // Feature flag for conditionals + env::set_var("TEST_FEATURE_FLAG", "true"); + + // Database variables for filter_env pattern TEST_DB_* + env::set_var("TEST_DB_HOST", "db.example.com"); + env::set_var("TEST_DB_PORT", "5432"); + env::set_var("TEST_DB_NAME", "myapp"); + + // Tags for complex operations + env::set_var("TEST_TAGS", "python,docker,k8s"); + + // Environment type for nested conditionals + env::set_var("TEST_ENV", "production"); + + // Service name + env::set_var("TEST_SERVICE", "api"); + } + + let output_path = get_test_file_path("output_comprehensive.txt"); + let template_content = read_fixture_template("comprehensive.tmpl"); + let template_path = get_test_file_path("template_comprehensive.txt"); + fs::write(&template_path, template_content).unwrap(); + + let result = render_template( + Some(template_path.to_str().unwrap()), + Some(output_path.to_str().unwrap()), + false, + ); + + assert!( + result.is_ok(), + "Template rendering failed: {:?}", + result.err() + ); + + // Read actual output and expected output + let actual_output = fs::read_to_string(&output_path).unwrap(); + let expected_output = read_fixture_expected("comprehensive.txt"); + + // Replace non-deterministic values with patterns for comparison + let actual_normalized = normalize_output(&actual_output); + let expected_normalized = normalize_output(&expected_output); + + // Compare the outputs + assert_eq!( + actual_normalized, expected_normalized, + "Template output does not match expected output" + ); + + // Additional validation for dynamic content + validate_timestamp(&actual_output); + validate_random_numbers(&actual_output); + + // Cleanup + cleanup_test_file(&template_path); + cleanup_test_file(&output_path); + + unsafe { + env::remove_var("TEST_PORT"); + env::remove_var("TEST_HOST"); + env::remove_var("TEST_DEBUG"); + env::remove_var("TEST_API_KEY"); + env::remove_var("TEST_SERVER_HOST"); + env::remove_var("TEST_SERVER_PORT"); + env::remove_var("TEST_NAME"); + env::remove_var("TEST_ITEMS"); + env::remove_var("TEST_FEATURE_FLAG"); + env::remove_var("TEST_DB_HOST"); + env::remove_var("TEST_DB_PORT"); + env::remove_var("TEST_DB_NAME"); + env::remove_var("TEST_TAGS"); + env::remove_var("TEST_ENV"); + env::remove_var("TEST_SERVICE"); + } +} + +/// Normalize output by replacing non-deterministic values with placeholders +fn normalize_output(output: &str) -> String { + // Replace ISO 8601 timestamp format + let timestamp_re = + Regex::new(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+[+-]\d{2}:\d{2}").unwrap(); + let normalized = timestamp_re.replace_all(output, "__TIMESTAMP__"); + + // Replace random number in "Random (1-10): X" pattern + let random_line_re = Regex::new(r"Random \(1-10\): \d+").unwrap(); + let normalized = random_line_re.replace_all(&normalized, "Random (1-10): __RANDOM__"); + + // Replace container random suffix (100-999) + let container_re = Regex::new(r"api-container-\d{3}").unwrap(); + let normalized = container_re.replace_all(&normalized, "api-container-__RANDOM__"); + + normalized.to_string() +} + +/// Validate that timestamp is in correct format (ISO 8601) +fn validate_timestamp(output: &str) { + let timestamp_re = + Regex::new(r"Current timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+[+-]\d{2}:\d{2}") + .unwrap(); + assert!( + timestamp_re.is_match(output), + "Timestamp not found or in wrong format in output" + ); +} + +/// Validate that random numbers are within expected ranges +fn validate_random_numbers(output: &str) { + // Check random number (1-10) + let random_re = Regex::new(r"Random \(1-10\): (\d+)").unwrap(); + if let Some(caps) = random_re.captures(output) { + let random_num: i32 = caps[1].parse().unwrap(); + assert!( + (1..=10).contains(&random_num), + "Random number {} is not in range 1-10", + random_num + ); + } + + // Check container random suffix (100-999) + let container_re = Regex::new(r"api-container-(\d+)").unwrap(); + if let Some(caps) = container_re.captures(output) { + let random_num: i32 = caps[1].parse().unwrap(); + assert!( + (100..=999).contains(&random_num), + "Container random number {} is not in range 100-999", + random_num + ); + } +} + +#[test] +fn test_comprehensive_template_validates_all_functions() { + // This test just ensures all the functions we claim to support actually work + // Set all environment variables to match the main test to avoid conflicts + unsafe { + env::set_var("TEST_PORT", "3000"); + env::set_var("TEST_HOST", "testhost"); + env::set_var("TEST_DEBUG", "true"); + env::set_var("TEST_API_KEY", "secret-key-123"); + env::set_var("TEST_SERVER_HOST", "server1.example.com"); + env::set_var("TEST_SERVER_PORT", "9000"); + env::set_var("TEST_NAME", "jane smith"); + env::set_var("TEST_ITEMS", "mango,orange,pineapple"); + env::set_var("TEST_FEATURE_FLAG", "true"); + env::set_var("TEST_DB_HOST", "db.example.com"); + env::set_var("TEST_DB_PORT", "5432"); + env::set_var("TEST_DB_NAME", "myapp"); + env::set_var("TEST_TAGS", "python,docker,k8s"); + env::set_var("TEST_ENV", "production"); + env::set_var("TEST_SERVICE", "api"); + } + + let output_path = get_test_file_path("output_validation.txt"); + let template_content = read_fixture_template("comprehensive.tmpl"); + let template_path = get_test_file_path("template_validation.txt"); + fs::write(&template_path, template_content).unwrap(); + + let result = render_template( + Some(template_path.to_str().unwrap()), + Some(output_path.to_str().unwrap()), + false, + ); + + assert!( + result.is_ok(), + "Comprehensive template should render without errors" + ); + + let output = fs::read_to_string(&output_path).unwrap(); + + // Verify all sections are present + assert!( + output.contains("Built-in get_env() function"), + "Missing get_env section" + ); + assert!( + output.contains("Custom filter_env() function"), + "Missing filter_env section" + ); + assert!( + output.contains("Built-in filters"), + "Missing filters section" + ); + assert!(output.contains("String filters:"), "Missing string filters"); + assert!(output.contains("Array filters:"), "Missing array filters"); + assert!( + output.contains("Conditionals with environment variables"), + "Missing conditionals" + ); + assert!(output.contains("Loops with filter_env"), "Missing loops"); + assert!( + output.contains("Complex operations"), + "Missing complex operations" + ); + assert!(output.contains("Current timestamp:"), "Missing timestamp"); + assert!(output.contains("Random (1-10):"), "Missing random number"); + + // Verify comments are NOT in output + assert!( + !output.contains("This is a comment"), + "Comments should not appear in output" + ); + assert!( + !output.contains("Multi-line comment"), + "Multi-line comments should not appear" + ); + + // Verify specific function outputs + assert!(output.contains("Upper: "), "upper filter not working"); + assert!(output.contains("Lower: "), "lower filter not working"); + assert!(output.contains("Title: "), "title filter not working"); + assert!(output.contains("Slugified: "), "slugify filter not working"); + assert!( + output.contains("Trimmed: spaces"), + "trim filter not working" + ); + assert!(output.contains("Items count:"), "length filter not working"); + assert!( + output.contains("File size:"), + "filesizeformat filter not working" + ); + assert!(output.contains("Encoded:"), "urlencode filter not working"); + + cleanup_test_file(&template_path); + cleanup_test_file(&output_path); + + unsafe { + env::remove_var("TEST_PORT"); + env::remove_var("TEST_HOST"); + env::remove_var("TEST_DEBUG"); + env::remove_var("TEST_API_KEY"); + env::remove_var("TEST_SERVER_HOST"); + env::remove_var("TEST_SERVER_PORT"); + env::remove_var("TEST_NAME"); + env::remove_var("TEST_ITEMS"); + env::remove_var("TEST_FEATURE_FLAG"); + env::remove_var("TEST_DB_HOST"); + env::remove_var("TEST_DB_PORT"); + env::remove_var("TEST_DB_NAME"); + env::remove_var("TEST_TAGS"); + env::remove_var("TEST_ENV"); + env::remove_var("TEST_SERVICE"); + } +} diff --git a/tests/test_direct_var_access_fails.rs b/tests/test_direct_var_access_fails.rs index 200d882..5223fee 100644 --- a/tests/test_direct_var_access_fails.rs +++ b/tests/test_direct_var_access_fails.rs @@ -21,6 +21,7 @@ fn test_direct_var_access_fails() { let result = render_template( Some(template_path.to_str().unwrap()), Some(output_path.to_str().unwrap()), + false, ); // Should fail because env vars not auto-added to context diff --git a/tests/test_env_with_default.rs b/tests/test_env_with_default.rs index d750182..71d8054 100644 --- a/tests/test_env_with_default.rs +++ b/tests/test_env_with_default.rs @@ -17,6 +17,7 @@ fn test_env_with_default() { let result = render_template( Some(template_path.to_str().unwrap()), Some(output_path.to_str().unwrap()), + false, ); // Verify success diff --git a/tests/test_environment_variable_substitution.rs b/tests/test_environment_variable_substitution.rs index e4a8574..59bedf8 100644 --- a/tests/test_environment_variable_substitution.rs +++ b/tests/test_environment_variable_substitution.rs @@ -23,6 +23,7 @@ fn test_environment_variable_substitution() { let result = render_template( Some(template_path.to_str().unwrap()), Some(output_path.to_str().unwrap()), + false, ); // Verify success diff --git a/tests/test_filesystem_unit.rs b/tests/test_filesystem_unit.rs new file mode 100644 index 0000000..9cbb9b0 --- /dev/null +++ b/tests/test_filesystem_unit.rs @@ -0,0 +1,507 @@ +use std::collections::HashMap; +use std::fs; +use std::io::Write; +use std::sync::atomic::{AtomicU32, Ordering}; +use tera::{Function, Value}; +use tmpltool::functions::filesystem::{ + FileExists, FileModified, FileSize, GlobFiles, ListDir, ReadFile, +}; + +// Global counter for unique test directories +static TEST_COUNTER: AtomicU32 = AtomicU32::new(0); + +// Helper to create a unique test directory +fn get_test_dir() -> String { + let counter = TEST_COUNTER.fetch_add(1, Ordering::SeqCst); + format!("test_data_{}", counter) +} + +// Helper to create a temporary test file +fn create_test_file(test_dir: &str, name: &str, content: &str) -> String { + let path = format!("{}/{}", test_dir, name); + fs::create_dir_all(test_dir).unwrap(); + let mut file = fs::File::create(&path).unwrap(); + file.write_all(content.as_bytes()).unwrap(); + path +} + +// Helper to cleanup test directory +fn cleanup_test_dir(test_dir: &str) { + let _ = fs::remove_dir_all(test_dir); +} + +#[test] +fn test_read_file_basic() { + let test_dir = get_test_dir(); + let path = create_test_file(&test_dir, "test.txt", "Hello, World!"); + + let mut args = HashMap::new(); + args.insert("path".to_string(), Value::String(path.clone())); + + let result = ReadFile::new(false).call(&args).unwrap(); + assert_eq!(result.as_str().unwrap(), "Hello, World!"); + + cleanup_test_dir(&test_dir); +} + +#[test] +fn test_read_file_multiline() { + let test_dir = get_test_dir(); + let content = "Line 1\nLine 2\nLine 3"; + let path = create_test_file(&test_dir, "multiline.txt", content); + + let mut args = HashMap::new(); + args.insert("path".to_string(), Value::String(path.clone())); + + let result = ReadFile::new(false).call(&args).unwrap(); + assert_eq!(result.as_str().unwrap(), content); + + cleanup_test_dir(&test_dir); +} + +#[test] +fn test_read_file_missing_argument() { + let args = HashMap::new(); + let result = ReadFile::new(false).call(&args); + assert!(result.is_err()); + assert!( + result + .err() + .unwrap() + .to_string() + .contains("requires a 'path' argument") + ); +} + +#[test] +fn test_read_file_nonexistent() { + let mut args = HashMap::new(); + args.insert( + "path".to_string(), + Value::String("test_data/nonexistent.txt".to_string()), + ); + + let result = ReadFile::new(false).call(&args); + assert!(result.is_err()); +} + +#[test] +fn test_read_file_security_absolute_path() { + let mut args = HashMap::new(); + args.insert("path".to_string(), Value::String("/etc/passwd".to_string())); + + let result = ReadFile::new(false).call(&args); + assert!(result.is_err()); + assert!(result.err().unwrap().to_string().contains("Security")); +} + +#[test] +fn test_read_file_security_parent_directory() { + let mut args = HashMap::new(); + args.insert( + "path".to_string(), + Value::String("../../../etc/passwd".to_string()), + ); + + let result = ReadFile::new(false).call(&args); + assert!(result.is_err()); + assert!(result.err().unwrap().to_string().contains("Security")); +} + +#[test] +fn test_file_exists_true() { + let test_dir = get_test_dir(); + let path = create_test_file(&test_dir, "exists.txt", "content"); + + let mut args = HashMap::new(); + args.insert("path".to_string(), Value::String(path.clone())); + + let result = FileExists::new(false).call(&args).unwrap(); + assert!(result.as_bool().unwrap()); + + cleanup_test_dir(&test_dir); +} + +#[test] +fn test_file_exists_false() { + let test_dir = get_test_dir(); + + let mut args = HashMap::new(); + args.insert( + "path".to_string(), + Value::String(format!("{}/nonexistent.txt", test_dir)), + ); + + let result = FileExists::new(false).call(&args).unwrap(); + assert!(!result.as_bool().unwrap()); +} + +#[test] +fn test_file_exists_security() { + let mut args = HashMap::new(); + args.insert("path".to_string(), Value::String("/etc/passwd".to_string())); + + let result = FileExists::new(false).call(&args); + assert!(result.is_err()); + assert!(result.err().unwrap().to_string().contains("Security")); +} + +#[test] +fn test_list_dir_basic() { + let test_dir = get_test_dir(); + fs::create_dir_all(&test_dir).unwrap(); + create_test_file(&test_dir, "file1.txt", "content1"); + create_test_file(&test_dir, "file2.txt", "content2"); + create_test_file(&test_dir, "file3.txt", "content3"); + + let mut args = HashMap::new(); + args.insert("path".to_string(), Value::String(test_dir.clone())); + + let result = ListDir::new(false).call(&args).unwrap(); + let files = result.as_array().unwrap(); + + assert_eq!(files.len(), 3); + assert_eq!(files[0].as_str().unwrap(), "file1.txt"); + assert_eq!(files[1].as_str().unwrap(), "file2.txt"); + assert_eq!(files[2].as_str().unwrap(), "file3.txt"); + + cleanup_test_dir(&test_dir); +} + +#[test] +fn test_list_dir_empty() { + let test_dir = get_test_dir(); + fs::create_dir_all(&test_dir).unwrap(); + + let mut args = HashMap::new(); + args.insert("path".to_string(), Value::String(test_dir.clone())); + + let result = ListDir::new(false).call(&args).unwrap(); + let files = result.as_array().unwrap(); + + assert_eq!(files.len(), 0); + + cleanup_test_dir(&test_dir); +} + +#[test] +fn test_list_dir_nonexistent() { + let test_dir = get_test_dir(); + + let mut args = HashMap::new(); + args.insert("path".to_string(), Value::String(test_dir.clone())); + + let result = ListDir::new(false).call(&args); + assert!(result.is_err()); +} + +#[test] +fn test_list_dir_security() { + let mut args = HashMap::new(); + args.insert("path".to_string(), Value::String("/etc".to_string())); + + let result = ListDir::new(false).call(&args); + assert!(result.is_err()); + assert!(result.err().unwrap().to_string().contains("Security")); +} + +#[test] +fn test_glob_basic() { + let test_dir = get_test_dir(); + fs::create_dir_all(&test_dir).unwrap(); + create_test_file(&test_dir, "file1.txt", "content1"); + create_test_file(&test_dir, "file2.txt", "content2"); + create_test_file(&test_dir, "file1.md", "content3"); + + let mut args = HashMap::new(); + args.insert( + "pattern".to_string(), + Value::String(format!("{}/*.txt", test_dir)), + ); + + let result = GlobFiles::new(false).call(&args).unwrap(); + let files = result.as_array().unwrap(); + + assert_eq!(files.len(), 2); + assert!(files[0].as_str().unwrap().contains("file1.txt")); + assert!(files[1].as_str().unwrap().contains("file2.txt")); + + cleanup_test_dir(&test_dir); +} + +#[test] +fn test_glob_no_matches() { + let test_dir = get_test_dir(); + + let mut args = HashMap::new(); + args.insert( + "pattern".to_string(), + Value::String(format!("{}/*.xyz", test_dir)), + ); + + let result = GlobFiles::new(false).call(&args).unwrap(); + let files = result.as_array().unwrap(); + + assert_eq!(files.len(), 0); +} + +#[test] +fn test_glob_security() { + let mut args = HashMap::new(); + args.insert("pattern".to_string(), Value::String("/etc/*".to_string())); + + let result = GlobFiles::new(false).call(&args); + assert!(result.is_err()); + assert!(result.err().unwrap().to_string().contains("Security")); +} + +#[test] +fn test_file_size_basic() { + let test_dir = get_test_dir(); + let content = "Hello, World!"; // 13 bytes + let path = create_test_file(&test_dir, "size_test.txt", content); + + let mut args = HashMap::new(); + args.insert("path".to_string(), Value::String(path.clone())); + + let result = FileSize::new(false).call(&args).unwrap(); + assert_eq!(result.as_u64().unwrap(), 13); + + cleanup_test_dir(&test_dir); +} + +#[test] +fn test_file_size_empty() { + let test_dir = get_test_dir(); + let path = create_test_file(&test_dir, "empty.txt", ""); + + let mut args = HashMap::new(); + args.insert("path".to_string(), Value::String(path.clone())); + + let result = FileSize::new(false).call(&args).unwrap(); + assert_eq!(result.as_u64().unwrap(), 0); + + cleanup_test_dir(&test_dir); +} + +#[test] +fn test_file_size_nonexistent() { + let mut args = HashMap::new(); + args.insert( + "path".to_string(), + Value::String("test_data/nonexistent.txt".to_string()), + ); + + let result = FileSize::new(false).call(&args); + assert!(result.is_err()); +} + +#[test] +fn test_file_size_security() { + let mut args = HashMap::new(); + args.insert("path".to_string(), Value::String("/etc/passwd".to_string())); + + let result = FileSize::new(false).call(&args); + assert!(result.is_err()); + assert!(result.err().unwrap().to_string().contains("Security")); +} + +#[test] +fn test_file_modified_basic() { + let test_dir = get_test_dir(); + let path = create_test_file(&test_dir, "modified_test.txt", "content"); + + // Small delay to ensure file is created + std::thread::sleep(std::time::Duration::from_millis(10)); + + let mut args = HashMap::new(); + args.insert("path".to_string(), Value::String(path.clone())); + + let result = FileModified::new(false).call(&args).unwrap(); + let timestamp = result.as_u64().unwrap(); + + // Timestamp should be recent (within last minute) + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + assert!(timestamp > 0); + assert!(timestamp <= now); + assert!(now - timestamp < 60); // Created within last 60 seconds + + cleanup_test_dir(&test_dir); +} + +#[test] +fn test_file_modified_nonexistent() { + let mut args = HashMap::new(); + args.insert( + "path".to_string(), + Value::String("test_data/nonexistent.txt".to_string()), + ); + + let result = FileModified::new(false).call(&args); + assert!(result.is_err()); +} + +#[test] +fn test_file_modified_security() { + let mut args = HashMap::new(); + args.insert("path".to_string(), Value::String("/etc/passwd".to_string())); + + let result = FileModified::new(false).call(&args); + assert!(result.is_err()); + assert!(result.err().unwrap().to_string().contains("Security")); +} + +// Trust mode tests + +#[test] +fn test_read_file_trust_mode_allows_absolute_path() { + let mut args = HashMap::new(); + // Use a file that should exist on most systems + args.insert("path".to_string(), Value::String("/etc/hosts".to_string())); + + // Without trust mode, should fail + let result_no_trust = ReadFile::new(false).call(&args); + assert!(result_no_trust.is_err()); + assert!( + result_no_trust + .err() + .unwrap() + .to_string() + .contains("Security") + ); + + // With trust mode, should succeed (or fail with file not found, but not security error) + let result_trust = ReadFile::new(true).call(&args); + // Result might succeed or fail depending on file existence/permissions, but should not be a security error + if let Err(e) = result_trust { + assert!(!e.to_string().contains("Security")); + } +} + +#[test] +fn test_file_exists_trust_mode_allows_absolute_path() { + let mut args = HashMap::new(); + args.insert("path".to_string(), Value::String("/etc".to_string())); + + // Without trust mode, should fail + let result_no_trust = FileExists::new(false).call(&args); + assert!(result_no_trust.is_err()); + assert!( + result_no_trust + .err() + .unwrap() + .to_string() + .contains("Security") + ); + + // With trust mode, should succeed + let result_trust = FileExists::new(true).call(&args); + assert!(result_trust.is_ok()); + // /etc should exist on Unix systems + #[cfg(unix)] + assert!(result_trust.unwrap().as_bool().unwrap()); +} + +#[test] +fn test_list_dir_trust_mode_allows_parent_directory() { + let test_dir = get_test_dir(); + fs::create_dir_all(&test_dir).unwrap(); + + let mut args = HashMap::new(); + // Try to access parent directory + args.insert( + "path".to_string(), + Value::String(format!("{}/..", test_dir)), + ); + + // Without trust mode, should fail + let result_no_trust = ListDir::new(false).call(&args); + assert!(result_no_trust.is_err()); + assert!( + result_no_trust + .err() + .unwrap() + .to_string() + .contains("Security") + ); + + // With trust mode, should succeed + let result_trust = ListDir::new(true).call(&args); + assert!(result_trust.is_ok()); + + cleanup_test_dir(&test_dir); +} + +#[test] +fn test_glob_trust_mode_allows_absolute_path() { + let mut args = HashMap::new(); + args.insert( + "pattern".to_string(), + Value::String("/etc/host*".to_string()), + ); + + // Without trust mode, should fail + let result_no_trust = GlobFiles::new(false).call(&args); + assert!(result_no_trust.is_err()); + assert!( + result_no_trust + .err() + .unwrap() + .to_string() + .contains("Security") + ); + + // With trust mode, should succeed + let result_trust = GlobFiles::new(true).call(&args); + assert!(result_trust.is_ok()); +} + +#[test] +fn test_file_size_trust_mode_allows_absolute_path() { + let mut args = HashMap::new(); + args.insert("path".to_string(), Value::String("/etc/hosts".to_string())); + + // Without trust mode, should fail + let result_no_trust = FileSize::new(false).call(&args); + assert!(result_no_trust.is_err()); + assert!( + result_no_trust + .err() + .unwrap() + .to_string() + .contains("Security") + ); + + // With trust mode, should succeed (or fail with file not found, but not security error) + let result_trust = FileSize::new(true).call(&args); + if let Err(e) = result_trust { + assert!(!e.to_string().contains("Security")); + } +} + +#[test] +fn test_file_modified_trust_mode_allows_absolute_path() { + let mut args = HashMap::new(); + args.insert("path".to_string(), Value::String("/etc/hosts".to_string())); + + // Without trust mode, should fail + let result_no_trust = FileModified::new(false).call(&args); + assert!(result_no_trust.is_err()); + assert!( + result_no_trust + .err() + .unwrap() + .to_string() + .contains("Security") + ); + + // With trust mode, should succeed (or fail with file not found, but not security error) + let result_trust = FileModified::new(true).call(&args); + if let Err(e) = result_trust { + assert!(!e.to_string().contains("Security")); + } +} diff --git a/tests/test_filter_env.rs b/tests/test_filter_env.rs new file mode 100644 index 0000000..d85946c --- /dev/null +++ b/tests/test_filter_env.rs @@ -0,0 +1,49 @@ +mod common; + +use common::{cleanup_test_file, get_test_file_path, read_fixture_expected, read_fixture_template}; +use std::env; +use std::fs; +use tmpltool::render_template; + +#[test] +fn test_filter_env() { + // Set test environment variables + unsafe { + env::set_var("SERVER_HOST", "localhost"); + env::set_var("SERVER_PORT", "8080"); + env::set_var("SERVER_NAME", "myapp"); + env::set_var("OTHER_VAR", "should_not_appear"); + } + + let output_path = get_test_file_path("output_filter_env.txt"); + let template_content = read_fixture_template("filter_env.tmpl"); + let template_path = get_test_file_path("template_filter_env.txt"); + fs::write(&template_path, template_content).unwrap(); + + let result = render_template( + Some(template_path.to_str().unwrap()), + Some(output_path.to_str().unwrap()), + false, + ); + + assert!(result.is_ok()); + let output = fs::read_to_string(&output_path) + .unwrap() + .trim_end() + .to_string(); + let expected = read_fixture_expected("filter_env.txt") + .trim_end() + .to_string(); + assert_eq!(output, expected); + + // Cleanup + cleanup_test_file(&template_path); + cleanup_test_file(&output_path); + + unsafe { + env::remove_var("SERVER_HOST"); + env::remove_var("SERVER_PORT"); + env::remove_var("SERVER_NAME"); + env::remove_var("OTHER_VAR"); + } +} diff --git a/tests/test_filter_env_unit.rs b/tests/test_filter_env_unit.rs new file mode 100644 index 0000000..c4a07a6 --- /dev/null +++ b/tests/test_filter_env_unit.rs @@ -0,0 +1,147 @@ +use std::collections::HashMap; +use tera::Value; +use tmpltool::functions::filter_env::FilterEnv; + +// Import the Function trait to use call() +use tera::Function; + +#[test] +fn test_filter_env_basic() { + // Set test environment variables + unsafe { + std::env::set_var("TEST_VAR_ONE", "value1"); + std::env::set_var("TEST_VAR_TWO", "value2"); + std::env::set_var("OTHER_VAR", "other"); + } + + let mut args = HashMap::new(); + args.insert( + "pattern".to_string(), + Value::String("TEST_VAR_*".to_string()), + ); + + let result = FilterEnv.call(&args).unwrap(); + let array = result.as_array().unwrap(); + + assert_eq!(array.len(), 2); + + // Verify both TEST_VAR_* variables are present + let keys: Vec = array + .iter() + .map(|item| item.get("key").unwrap().as_str().unwrap().to_string()) + .collect(); + + assert!(keys.contains(&"TEST_VAR_ONE".to_string())); + assert!(keys.contains(&"TEST_VAR_TWO".to_string())); + + // Cleanup + unsafe { + std::env::remove_var("TEST_VAR_ONE"); + std::env::remove_var("TEST_VAR_TWO"); + std::env::remove_var("OTHER_VAR"); + } +} + +#[test] +fn test_filter_env_wildcard_middle() { + unsafe { + std::env::set_var("PREFIX_MIDDLE_SUFFIX", "value1"); + std::env::set_var("PREFIX_OTHER_SUFFIX", "value2"); + } + + let mut args = HashMap::new(); + args.insert( + "pattern".to_string(), + Value::String("PREFIX_*_SUFFIX".to_string()), + ); + + let result = FilterEnv.call(&args).unwrap(); + let array = result.as_array().unwrap(); + + assert_eq!(array.len(), 2); + + unsafe { + std::env::remove_var("PREFIX_MIDDLE_SUFFIX"); + std::env::remove_var("PREFIX_OTHER_SUFFIX"); + } +} + +#[test] +fn test_filter_env_question_mark() { + unsafe { + std::env::set_var("VAR_A", "value_a"); + std::env::set_var("VAR_B", "value_b"); + std::env::set_var("VAR_AB", "value_ab"); + } + + let mut args = HashMap::new(); + args.insert("pattern".to_string(), Value::String("VAR_?".to_string())); + + let result = FilterEnv.call(&args).unwrap(); + let array = result.as_array().unwrap(); + + // Should match VAR_A and VAR_B, but not VAR_AB (two characters) + assert_eq!(array.len(), 2); + + unsafe { + std::env::remove_var("VAR_A"); + std::env::remove_var("VAR_B"); + std::env::remove_var("VAR_AB"); + } +} + +#[test] +fn test_filter_env_no_matches() { + let mut args = HashMap::new(); + args.insert( + "pattern".to_string(), + Value::String("NONEXISTENT_PATTERN_*".to_string()), + ); + + let result = FilterEnv.call(&args).unwrap(); + let array = result.as_array().unwrap(); + + assert_eq!(array.len(), 0); +} + +#[test] +fn test_filter_env_no_pattern() { + let args = HashMap::new(); + let result = FilterEnv.call(&args); + + assert!(result.is_err()); + assert!(result.err().unwrap().to_string().contains("pattern")); +} + +#[test] +fn test_glob_to_regex() { + // This is an internal function test - we test it indirectly through filter_env + unsafe { + std::env::set_var("SERVER_HOST", "localhost"); + std::env::set_var("SERVER_PORT", "8080"); + std::env::set_var("CLIENT_HOST", "example.com"); + } + + let mut args = HashMap::new(); + args.insert("pattern".to_string(), Value::String("SERVER_*".to_string())); + + let result = FilterEnv.call(&args).unwrap(); + let array = result.as_array().unwrap(); + + assert_eq!(array.len(), 2); + + let keys: Vec = array + .iter() + .map(|item| item.get("key").unwrap().as_str().unwrap().to_string()) + .collect(); + + assert!(keys.contains(&"SERVER_HOST".to_string())); + assert!(keys.contains(&"SERVER_PORT".to_string())); + assert!(!keys.contains(&"CLIENT_HOST".to_string())); + + unsafe { + std::env::remove_var("SERVER_HOST"); + std::env::remove_var("SERVER_PORT"); + std::env::remove_var("CLIENT_HOST"); + } +} diff --git a/tests/test_hash_crypto_functions.rs b/tests/test_hash_crypto_functions.rs new file mode 100644 index 0000000..d09d479 --- /dev/null +++ b/tests/test_hash_crypto_functions.rs @@ -0,0 +1,572 @@ +mod common; + +use common::{cleanup_test_file, get_test_file_path}; +use regex::Regex; +use std::fs; +use tmpltool::render_template; + +#[test] +fn test_md5_function() { + let template_content = r#"{{ md5(string="hello") }}"#; + let template_path = get_test_file_path("template_md5.txt"); + let output_path = get_test_file_path("output_md5.txt"); + + fs::write(&template_path, template_content).unwrap(); + + let result = render_template( + Some(template_path.to_str().unwrap()), + Some(output_path.to_str().unwrap()), + false, + ); + + assert!( + result.is_ok(), + "MD5 template rendering failed: {:?}", + result.err() + ); + + let output = fs::read_to_string(&output_path).unwrap(); + assert_eq!(output, "5d41402abc4b2a76b9719d911017c592"); + + cleanup_test_file(&template_path); + cleanup_test_file(&output_path); +} + +#[test] +fn test_sha1_function() { + let template_content = r#"{{ sha1(string="test") }}"#; + let template_path = get_test_file_path("template_sha1.txt"); + let output_path = get_test_file_path("output_sha1.txt"); + + fs::write(&template_path, template_content).unwrap(); + + let result = render_template( + Some(template_path.to_str().unwrap()), + Some(output_path.to_str().unwrap()), + false, + ); + + assert!( + result.is_ok(), + "SHA1 template rendering failed: {:?}", + result.err() + ); + + let output = fs::read_to_string(&output_path).unwrap(); + assert_eq!(output, "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3"); + + cleanup_test_file(&template_path); + cleanup_test_file(&output_path); +} + +#[test] +fn test_sha256_function() { + let template_content = r#"{{ sha256(string="tmpltool") }}"#; + let template_path = get_test_file_path("template_sha256.txt"); + let output_path = get_test_file_path("output_sha256.txt"); + + fs::write(&template_path, template_content).unwrap(); + + let result = render_template( + Some(template_path.to_str().unwrap()), + Some(output_path.to_str().unwrap()), + false, + ); + + assert!( + result.is_ok(), + "SHA256 template rendering failed: {:?}", + result.err() + ); + + let output = fs::read_to_string(&output_path).unwrap(); + assert_eq!( + output, + "5eb389e31748154d04ff7be14bec47d2a72d26c8f36ec7feb6236cc860b9fbe2" + ); + + cleanup_test_file(&template_path); + cleanup_test_file(&output_path); +} + +#[test] +fn test_sha512_function() { + let template_content = r#"{{ sha512(string="secure") }}"#; + let template_path = get_test_file_path("template_sha512.txt"); + let output_path = get_test_file_path("output_sha512.txt"); + + fs::write(&template_path, template_content).unwrap(); + + let result = render_template( + Some(template_path.to_str().unwrap()), + Some(output_path.to_str().unwrap()), + false, + ); + + assert!( + result.is_ok(), + "SHA512 template rendering failed: {:?}", + result.err() + ); + + let output = fs::read_to_string(&output_path).unwrap(); + assert_eq!( + output, + "66a2d78a8cd30f00d0f8e43434731ce3c9351ce9c7f66bc1cd2e105edc994be0a9106c85bb7eed09a421de36f4af0dc2f24bdc64f8645ce7efd3fd909b93785e" + ); + + cleanup_test_file(&template_path); + cleanup_test_file(&output_path); +} + +#[test] +fn test_hash_with_env_variable() { + let template_content = + r#"{{ sha256(string=get_env(name="TEST_HASH_VAR", default="default")) }}"#; + let template_path = get_test_file_path("template_hash_env.txt"); + let output_path = get_test_file_path("output_hash_env.txt"); + + fs::write(&template_path, template_content).unwrap(); + + // Set environment variable + unsafe { + std::env::set_var("TEST_HASH_VAR", "mypassword"); + } + + let result = render_template( + Some(template_path.to_str().unwrap()), + Some(output_path.to_str().unwrap()), + false, + ); + + assert!( + result.is_ok(), + "Hash with env template rendering failed: {:?}", + result.err() + ); + + let output = fs::read_to_string(&output_path).unwrap(); + // SHA256 of "mypassword" + assert_eq!( + output, + "89e01536ac207279409d4de1e5253e01f4a1769e696db0d6062ca9b8f56767c8" + ); + + cleanup_test_file(&template_path); + cleanup_test_file(&output_path); + + unsafe { + std::env::remove_var("TEST_HASH_VAR"); + } +} + +#[test] +fn test_uuid_function() { + let template_content = r#"{{ uuid() }}"#; + let template_path = get_test_file_path("template_uuid.txt"); + let output_path = get_test_file_path("output_uuid.txt"); + + fs::write(&template_path, template_content).unwrap(); + + let result = render_template( + Some(template_path.to_str().unwrap()), + Some(output_path.to_str().unwrap()), + false, + ); + + assert!( + result.is_ok(), + "UUID template rendering failed: {:?}", + result.err() + ); + + let output = fs::read_to_string(&output_path).unwrap(); + + // Validate UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + let uuid_pattern = + Regex::new(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") + .unwrap(); + assert!( + uuid_pattern.is_match(&output), + "Invalid UUID format: {}", + output + ); + + cleanup_test_file(&template_path); + cleanup_test_file(&output_path); +} + +#[test] +fn test_uuid_uniqueness() { + let template_content = r#"{{ uuid() }} +{{ uuid() }} +{{ uuid() }}"#; + let template_path = get_test_file_path("template_uuid_unique.txt"); + let output_path = get_test_file_path("output_uuid_unique.txt"); + + fs::write(&template_path, template_content).unwrap(); + + let result = render_template( + Some(template_path.to_str().unwrap()), + Some(output_path.to_str().unwrap()), + false, + ); + + assert!( + result.is_ok(), + "UUID uniqueness template rendering failed: {:?}", + result.err() + ); + + let output = fs::read_to_string(&output_path).unwrap(); + let uuids: Vec<&str> = output.lines().collect(); + + assert_eq!(uuids.len(), 3); + assert_ne!(uuids[0], uuids[1]); + assert_ne!(uuids[1], uuids[2]); + assert_ne!(uuids[0], uuids[2]); + + cleanup_test_file(&template_path); + cleanup_test_file(&output_path); +} + +#[test] +fn test_random_string_basic() { + let template_content = r#"{{ random_string(length=16) }}"#; + let template_path = get_test_file_path("template_random_basic.txt"); + let output_path = get_test_file_path("output_random_basic.txt"); + + fs::write(&template_path, template_content).unwrap(); + + let result = render_template( + Some(template_path.to_str().unwrap()), + Some(output_path.to_str().unwrap()), + false, + ); + + assert!( + result.is_ok(), + "Random string template rendering failed: {:?}", + result.err() + ); + + let output = fs::read_to_string(&output_path).unwrap(); + assert_eq!(output.len(), 16); + + // Default charset is alphanumeric + for ch in output.chars() { + assert!( + ch.is_ascii_alphanumeric(), + "Invalid character in random string: {}", + ch + ); + } + + cleanup_test_file(&template_path); + cleanup_test_file(&output_path); +} + +#[test] +fn test_random_string_lowercase() { + let template_content = r#"{{ random_string(length=10, charset="lowercase") }}"#; + let template_path = get_test_file_path("template_random_lower.txt"); + let output_path = get_test_file_path("output_random_lower.txt"); + + fs::write(&template_path, template_content).unwrap(); + + let result = render_template( + Some(template_path.to_str().unwrap()), + Some(output_path.to_str().unwrap()), + false, + ); + + assert!( + result.is_ok(), + "Random string lowercase template rendering failed: {:?}", + result.err() + ); + + let output = fs::read_to_string(&output_path).unwrap(); + assert_eq!(output.len(), 10); + + for ch in output.chars() { + assert!( + ch.is_ascii_lowercase(), + "Invalid character in lowercase random string: {}", + ch + ); + } + + cleanup_test_file(&template_path); + cleanup_test_file(&output_path); +} + +#[test] +fn test_random_string_uppercase() { + let template_content = r#"{{ random_string(length=8, charset="uppercase") }}"#; + let template_path = get_test_file_path("template_random_upper.txt"); + let output_path = get_test_file_path("output_random_upper.txt"); + + fs::write(&template_path, template_content).unwrap(); + + let result = render_template( + Some(template_path.to_str().unwrap()), + Some(output_path.to_str().unwrap()), + false, + ); + + assert!( + result.is_ok(), + "Random string uppercase template rendering failed: {:?}", + result.err() + ); + + let output = fs::read_to_string(&output_path).unwrap(); + assert_eq!(output.len(), 8); + + for ch in output.chars() { + assert!( + ch.is_ascii_uppercase(), + "Invalid character in uppercase random string: {}", + ch + ); + } + + cleanup_test_file(&template_path); + cleanup_test_file(&output_path); +} + +#[test] +fn test_random_string_numeric() { + let template_content = r#"{{ random_string(length=6, charset="numeric") }}"#; + let template_path = get_test_file_path("template_random_num.txt"); + let output_path = get_test_file_path("output_random_num.txt"); + + fs::write(&template_path, template_content).unwrap(); + + let result = render_template( + Some(template_path.to_str().unwrap()), + Some(output_path.to_str().unwrap()), + false, + ); + + assert!( + result.is_ok(), + "Random string numeric template rendering failed: {:?}", + result.err() + ); + + let output = fs::read_to_string(&output_path).unwrap(); + assert_eq!(output.len(), 6); + + for ch in output.chars() { + assert!( + ch.is_ascii_digit(), + "Invalid character in numeric random string: {}", + ch + ); + } + + cleanup_test_file(&template_path); + cleanup_test_file(&output_path); +} + +#[test] +fn test_random_string_hex() { + let template_content = r#"{{ random_string(length=16, charset="hex") }}"#; + let template_path = get_test_file_path("template_random_hex.txt"); + let output_path = get_test_file_path("output_random_hex.txt"); + + fs::write(&template_path, template_content).unwrap(); + + let result = render_template( + Some(template_path.to_str().unwrap()), + Some(output_path.to_str().unwrap()), + false, + ); + + assert!( + result.is_ok(), + "Random string hex template rendering failed: {:?}", + result.err() + ); + + let output = fs::read_to_string(&output_path).unwrap(); + assert_eq!(output.len(), 16); + + for ch in output.chars() { + assert!( + ch.is_ascii_hexdigit() && !ch.is_ascii_uppercase(), + "Invalid character in hex random string: {}", + ch + ); + } + + cleanup_test_file(&template_path); + cleanup_test_file(&output_path); +} + +#[test] +fn test_random_string_custom_charset() { + let template_content = r#"{{ random_string(length=20, charset="abc123") }}"#; + let template_path = get_test_file_path("template_random_custom.txt"); + let output_path = get_test_file_path("output_random_custom.txt"); + + fs::write(&template_path, template_content).unwrap(); + + let result = render_template( + Some(template_path.to_str().unwrap()), + Some(output_path.to_str().unwrap()), + false, + ); + + assert!( + result.is_ok(), + "Random string custom charset template rendering failed: {:?}", + result.err() + ); + + let output = fs::read_to_string(&output_path).unwrap(); + assert_eq!(output.len(), 20); + + for ch in output.chars() { + assert!( + "abc123".contains(ch), + "Invalid character in custom charset random string: {}", + ch + ); + } + + cleanup_test_file(&template_path); + cleanup_test_file(&output_path); +} + +#[test] +fn test_random_string_uniqueness() { + let template_content = r#"{{ random_string(length=32) }} +{{ random_string(length=32) }}"#; + let template_path = get_test_file_path("template_random_unique.txt"); + let output_path = get_test_file_path("output_random_unique.txt"); + + fs::write(&template_path, template_content).unwrap(); + + let result = render_template( + Some(template_path.to_str().unwrap()), + Some(output_path.to_str().unwrap()), + false, + ); + + assert!( + result.is_ok(), + "Random string uniqueness template rendering failed: {:?}", + result.err() + ); + + let output = fs::read_to_string(&output_path).unwrap(); + let strings: Vec<&str> = output.lines().collect(); + + assert_eq!(strings.len(), 2); + assert_ne!(strings[0], strings[1], "Random strings should be unique"); + + cleanup_test_file(&template_path); + cleanup_test_file(&output_path); +} + +#[test] +fn test_combined_hash_crypto_functions() { + let template_content = r#"# Security Configuration +secret_key: {{ random_string(length=64) }} +api_token: {{ random_string(length=32, charset="hex") }} +instance_id: {{ uuid() }} +password_hash: {{ sha256(string="admin123") }} +checksum: {{ md5(string="config-v1") }}"#; + + let template_path = get_test_file_path("template_combined.txt"); + let output_path = get_test_file_path("output_combined.txt"); + + fs::write(&template_path, template_content).unwrap(); + + let result = render_template( + Some(template_path.to_str().unwrap()), + Some(output_path.to_str().unwrap()), + false, + ); + + assert!( + result.is_ok(), + "Combined functions template rendering failed: {:?}", + result.err() + ); + + let output = fs::read_to_string(&output_path).unwrap(); + + // Verify all expected sections are present + assert!(output.contains("secret_key:")); + assert!(output.contains("api_token:")); + assert!(output.contains("instance_id:")); + assert!(output.contains("password_hash:")); + assert!(output.contains("checksum:")); + + // Verify password hash is correct + assert!(output.contains("240be518fabd2724ddb6f04eeb1da5967448d7e831c08c8fa822809f74c720a9")); + + // Verify checksum is correct + assert!(output.contains("dec7d66f96dddff3a20bf58b62a2ef8f")); + + cleanup_test_file(&template_path); + cleanup_test_file(&output_path); +} + +#[test] +fn test_hash_function_missing_argument() { + let template_content = r#"{{ md5() }}"#; + let template_path = get_test_file_path("template_md5_error.txt"); + let output_path = get_test_file_path("output_md5_error.txt"); + + fs::write(&template_path, template_content).unwrap(); + + let result = render_template( + Some(template_path.to_str().unwrap()), + Some(output_path.to_str().unwrap()), + false, + ); + + assert!(result.is_err(), "MD5 without argument should fail"); + + let error = result.err().unwrap(); + let error_msg = error.to_string(); + assert!( + error_msg.contains("md5 requires a 'string' argument"), + "Error message should mention missing argument: {}", + error_msg + ); + + cleanup_test_file(&template_path); +} + +#[test] +fn test_random_string_missing_length() { + let template_content = r#"{{ random_string() }}"#; + let template_path = get_test_file_path("template_random_error.txt"); + let output_path = get_test_file_path("output_random_error.txt"); + + fs::write(&template_path, template_content).unwrap(); + + let result = render_template( + Some(template_path.to_str().unwrap()), + Some(output_path.to_str().unwrap()), + false, + ); + + assert!(result.is_err(), "random_string without length should fail"); + + let error = result.err().unwrap(); + let error_msg = error.to_string(); + assert!( + error_msg.contains("random_string requires a 'length' argument"), + "Error message should mention missing length: {}", + error_msg + ); + + cleanup_test_file(&template_path); +} diff --git a/tests/test_hash_unit.rs b/tests/test_hash_unit.rs new file mode 100644 index 0000000..8179dbf --- /dev/null +++ b/tests/test_hash_unit.rs @@ -0,0 +1,67 @@ +use std::collections::HashMap; +use tera::Value; +use tmpltool::functions::hash::{Md5, Sha1, Sha256, Sha512}; + +// Import the Function trait to use call() +use tera::Function; + +#[test] +fn test_md5() { + let mut args = HashMap::new(); + args.insert("string".to_string(), Value::String("hello".to_string())); + + let result = Md5.call(&args).unwrap(); + assert_eq!(result.as_str().unwrap(), "5d41402abc4b2a76b9719d911017c592"); +} + +#[test] +fn test_md5_empty() { + let mut args = HashMap::new(); + args.insert("string".to_string(), Value::String("".to_string())); + + let result = Md5.call(&args).unwrap(); + assert_eq!(result.as_str().unwrap(), "d41d8cd98f00b204e9800998ecf8427e"); +} + +#[test] +fn test_sha1() { + let mut args = HashMap::new(); + args.insert("string".to_string(), Value::String("hello".to_string())); + + let result = Sha1.call(&args).unwrap(); + assert_eq!( + result.as_str().unwrap(), + "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d" + ); +} + +#[test] +fn test_sha256() { + let mut args = HashMap::new(); + args.insert("string".to_string(), Value::String("hello".to_string())); + + let result = Sha256.call(&args).unwrap(); + assert_eq!( + result.as_str().unwrap(), + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + ); +} + +#[test] +fn test_sha512() { + let mut args = HashMap::new(); + args.insert("string".to_string(), Value::String("hello".to_string())); + + let result = Sha512.call(&args).unwrap(); + assert_eq!( + result.as_str().unwrap(), + "9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043" + ); +} + +#[test] +fn test_md5_no_argument() { + let args = HashMap::new(); + let result = Md5.call(&args); + assert!(result.is_err()); +} diff --git a/tests/test_invalid_template_syntax.rs b/tests/test_invalid_template_syntax.rs index 5aab5b9..3b6cfdd 100644 --- a/tests/test_invalid_template_syntax.rs +++ b/tests/test_invalid_template_syntax.rs @@ -17,6 +17,7 @@ fn test_invalid_template_syntax() { let result = render_template( Some(template_path.to_str().unwrap()), Some(output_path.to_str().unwrap()), + false, ); // Verify error diff --git a/tests/test_missing_template_file.rs b/tests/test_missing_template_file.rs index e5e71f4..3a07e4e 100644 --- a/tests/test_missing_template_file.rs +++ b/tests/test_missing_template_file.rs @@ -15,6 +15,7 @@ fn test_missing_template_file() { let result = render_template( Some(template_path.to_str().unwrap()), Some(output_path.to_str().unwrap()), + false, ); // Verify error diff --git a/tests/test_multiline_template.rs b/tests/test_multiline_template.rs index d649f27..c819e07 100644 --- a/tests/test_multiline_template.rs +++ b/tests/test_multiline_template.rs @@ -23,6 +23,7 @@ fn test_multiline_template() { let result = render_template( Some(template_path.to_str().unwrap()), Some(output_path.to_str().unwrap()), + false, ); // Verify success diff --git a/tests/test_random_string_unit.rs b/tests/test_random_string_unit.rs new file mode 100644 index 0000000..03778c9 --- /dev/null +++ b/tests/test_random_string_unit.rs @@ -0,0 +1,162 @@ +use std::collections::HashMap; +use tera::Value; +use tmpltool::functions::random_string::RandomString; + +// Import the Function trait to use call() +use tera::Function; + +const CHARSET_ALPHANUMERIC: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +const CHARSET_LOWERCASE: &str = "abcdefghijklmnopqrstuvwxyz"; +const CHARSET_UPPERCASE: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const CHARSET_NUMERIC: &str = "0123456789"; +const CHARSET_HEX: &str = "0123456789abcdef"; + +#[test] +fn test_random_string_basic() { + let mut args = HashMap::new(); + args.insert("length".to_string(), Value::Number(16.into())); + + let result = RandomString.call(&args).unwrap(); + let random_str = result.as_str().unwrap(); + + assert_eq!(random_str.len(), 16); + for ch in random_str.chars() { + assert!(CHARSET_ALPHANUMERIC.contains(ch)); + } +} + +#[test] +fn test_random_string_alphanumeric() { + let mut args = HashMap::new(); + args.insert("length".to_string(), Value::Number(20.into())); + args.insert( + "charset".to_string(), + Value::String("alphanumeric".to_string()), + ); + + let result = RandomString.call(&args).unwrap(); + let random_str = result.as_str().unwrap(); + + assert_eq!(random_str.len(), 20); + for ch in random_str.chars() { + assert!(CHARSET_ALPHANUMERIC.contains(ch)); + } +} + +#[test] +fn test_random_string_lowercase() { + let mut args = HashMap::new(); + args.insert("length".to_string(), Value::Number(10.into())); + args.insert( + "charset".to_string(), + Value::String("lowercase".to_string()), + ); + + let result = RandomString.call(&args).unwrap(); + let random_str = result.as_str().unwrap(); + + assert_eq!(random_str.len(), 10); + for ch in random_str.chars() { + assert!(CHARSET_LOWERCASE.contains(ch)); + } +} + +#[test] +fn test_random_string_uppercase() { + let mut args = HashMap::new(); + args.insert("length".to_string(), Value::Number(10.into())); + args.insert( + "charset".to_string(), + Value::String("uppercase".to_string()), + ); + + let result = RandomString.call(&args).unwrap(); + let random_str = result.as_str().unwrap(); + + assert_eq!(random_str.len(), 10); + for ch in random_str.chars() { + assert!(CHARSET_UPPERCASE.contains(ch)); + } +} + +#[test] +fn test_random_string_numeric() { + let mut args = HashMap::new(); + args.insert("length".to_string(), Value::Number(8.into())); + args.insert("charset".to_string(), Value::String("numeric".to_string())); + + let result = RandomString.call(&args).unwrap(); + let random_str = result.as_str().unwrap(); + + assert_eq!(random_str.len(), 8); + for ch in random_str.chars() { + assert!(CHARSET_NUMERIC.contains(ch)); + } +} + +#[test] +fn test_random_string_hex() { + let mut args = HashMap::new(); + args.insert("length".to_string(), Value::Number(12.into())); + args.insert("charset".to_string(), Value::String("hex".to_string())); + + let result = RandomString.call(&args).unwrap(); + let random_str = result.as_str().unwrap(); + + assert_eq!(random_str.len(), 12); + for ch in random_str.chars() { + assert!(CHARSET_HEX.contains(ch)); + } +} + +#[test] +fn test_random_string_custom_charset() { + let mut args = HashMap::new(); + args.insert("length".to_string(), Value::Number(15.into())); + args.insert("charset".to_string(), Value::String("abc123".to_string())); + + let result = RandomString.call(&args).unwrap(); + let random_str = result.as_str().unwrap(); + + assert_eq!(random_str.len(), 15); + for ch in random_str.chars() { + assert!("abc123".contains(ch)); + } +} + +#[test] +fn test_random_string_empty_length() { + let mut args = HashMap::new(); + args.insert("length".to_string(), Value::Number(0.into())); + + let result = RandomString.call(&args).unwrap(); + assert_eq!(result.as_str().unwrap(), ""); +} + +#[test] +fn test_random_string_no_length() { + let args = HashMap::new(); + let result = RandomString.call(&args); + assert!(result.is_err()); +} + +#[test] +fn test_random_string_too_long() { + let mut args = HashMap::new(); + args.insert("length".to_string(), Value::Number(10001.into())); + + let result = RandomString.call(&args); + assert!(result.is_err()); +} + +#[test] +fn test_random_string_uniqueness() { + let mut args = HashMap::new(); + args.insert("length".to_string(), Value::Number(20.into())); + + let result1 = RandomString.call(&args).unwrap(); + let result2 = RandomString.call(&args).unwrap(); + + // Two random strings should be different (with very high probability) + assert_ne!(result1.as_str().unwrap(), result2.as_str().unwrap()); +} diff --git a/tests/test_simple_rendering.rs b/tests/test_simple_rendering.rs index 5366084..e03bb7c 100644 --- a/tests/test_simple_rendering.rs +++ b/tests/test_simple_rendering.rs @@ -17,6 +17,7 @@ fn test_simple_rendering() { let result = render_template( Some(template_path.to_str().unwrap()), Some(output_path.to_str().unwrap()), + false, ); // Verify success diff --git a/tests/test_stdout_output.rs b/tests/test_stdout_output.rs index 580558a..e95d26a 100644 --- a/tests/test_stdout_output.rs +++ b/tests/test_stdout_output.rs @@ -18,7 +18,7 @@ fn test_stdout_output() { fs::write(&template_path, template_content).unwrap(); // Run the function with no output file (should print to stdout) - let result = render_template(Some(template_path.to_str().unwrap()), None); + let result = render_template(Some(template_path.to_str().unwrap()), None, false); // Verify success assert!(result.is_ok()); diff --git a/tests/test_successful_rendering.rs b/tests/test_successful_rendering.rs index 9dbc27b..6271b06 100644 --- a/tests/test_successful_rendering.rs +++ b/tests/test_successful_rendering.rs @@ -22,6 +22,7 @@ fn test_successful_rendering() { let result = render_template( Some(template_path.to_str().unwrap()), Some(output_path.to_str().unwrap()), + false, ); // Verify success diff --git a/tests/test_template_with_conditionals.rs b/tests/test_template_with_conditionals.rs index f22f69f..ebf2419 100644 --- a/tests/test_template_with_conditionals.rs +++ b/tests/test_template_with_conditionals.rs @@ -23,6 +23,7 @@ fn test_template_with_conditionals() { let result = render_template( Some(template_path.to_str().unwrap()), Some(output_path.to_str().unwrap()), + false, ); // Verify success diff --git a/tests/test_template_with_missing_variable.rs b/tests/test_template_with_missing_variable.rs index fa29bbc..06d1102 100644 --- a/tests/test_template_with_missing_variable.rs +++ b/tests/test_template_with_missing_variable.rs @@ -17,6 +17,7 @@ fn test_template_with_missing_variable() { let result = render_template( Some(template_path.to_str().unwrap()), Some(output_path.to_str().unwrap()), + false, ); // Verify it fails (get_env() without default should error on missing var) diff --git a/tests/test_uuid_unit.rs b/tests/test_uuid_unit.rs new file mode 100644 index 0000000..efe7200 --- /dev/null +++ b/tests/test_uuid_unit.rs @@ -0,0 +1,48 @@ +use std::collections::HashMap; +use tmpltool::functions::uuid_gen::UuidV4; + +// Import the Function trait to use call() +use tera::Function; + +#[test] +fn test_uuid_v4_format() { + let args = HashMap::new(); + let result = UuidV4.call(&args).unwrap(); + let uuid_str = result.as_str().unwrap(); + + // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + // where y is one of [8, 9, a, b] + assert_eq!(uuid_str.len(), 36); + assert_eq!(uuid_str.chars().nth(8).unwrap(), '-'); + assert_eq!(uuid_str.chars().nth(13).unwrap(), '-'); + assert_eq!(uuid_str.chars().nth(18).unwrap(), '-'); + assert_eq!(uuid_str.chars().nth(23).unwrap(), '-'); + + // Version should be 4 + assert_eq!(uuid_str.chars().nth(14).unwrap(), '4'); +} + +#[test] +fn test_uuid_v4_uniqueness() { + let args = HashMap::new(); + let result1 = UuidV4.call(&args).unwrap(); + let result2 = UuidV4.call(&args).unwrap(); + + // Two UUIDs should be different + assert_ne!(result1.as_str().unwrap(), result2.as_str().unwrap()); +} + +#[test] +fn test_uuid_v4_valid_hex() { + let args = HashMap::new(); + let result = UuidV4.call(&args).unwrap(); + let uuid_str = result.as_str().unwrap(); + + // Remove dashes and check if all characters are valid hex + let hex_part: String = uuid_str.chars().filter(|c| *c != '-').collect(); + assert_eq!(hex_part.len(), 32); + + for ch in hex_part.chars() { + assert!(ch.is_ascii_hexdigit()); + } +}