Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,12 +175,19 @@ Mise offers additional functions:
- `choice(n, alphabet)` - Generate a string of `n` with random sample with replacement
of `alphabet`. For example, `choice(64, HEX)` will generate a random
64-character lowercase hex string.
- `read_file(path) -> String` – Reads the contents of a file at the given path and returns
it as a string.

An example of function using `exec`:
Examples of functions:

```toml
# Using exec to get command output
[alias.node.versions]
current = "{{ exec(command='node --version') }}"

# Using read_file to include content from a file
[env]
VERSION = "{{ read_file(path='VERSION') | trim }}"
```

### Exec Options
Expand Down
119 changes: 119 additions & 0 deletions e2e/env/test_env_template_read_file
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# shellcheck disable=SC2016

# Test basic read_file functionality
cat <<EOF >test_version.txt
1.2.3
EOF

cat <<EOF >mise.toml
[env]
VERSION = "{{ read_file(path='test_version.txt') | trim }}"
EOF

assert_contains "mise env -s bash | grep VERSION" "export VERSION=1.2.3"

# Test read_file with relative path
mkdir -p subdir
cat <<EOF >subdir/info.txt
test-content
EOF

cat <<EOF >mise.toml
[env]
CONTENT = "{{ read_file(path='subdir/info.txt') | trim }}"
EOF

assert_contains "mise env -s bash | grep CONTENT" "export CONTENT=test-content"

# Test read_file with multiple lines
cat <<EOF >multiline.txt
line1
line2
line3
EOF

cat <<EOF >mise.toml
[env]
MULTILINE = "{{ read_file(path='multiline.txt') | replace(from='\n', to=' ') | trim }}"
EOF

assert_contains "mise env -s bash | grep MULTILINE" "export MULTILINE='line1 line2 line3'"

# Test read_file with vars
cat <<EOF >config.txt
production
EOF

cat <<EOF >mise.toml
[vars]
CONFIG_FILE = "config.txt"

[env]
ENVIRONMENT = "{{ read_file(path=vars.CONFIG_FILE) | trim }}"
EOF

assert_contains "mise env -s bash | grep ENVIRONMENT" "export ENVIRONMENT=production"

# Test read_file with templated content (should read raw content)
cat <<EOF >template_test.txt
{{ env.USER }}
EOF

cat <<EOF >mise.toml
[env]
RAW_CONTENT = "{{ read_file(path='template_test.txt') | trim }}"
EOF

assert_contains "mise env -s bash | grep RAW_CONTENT" "export RAW_CONTENT='{{ env.USER }}'"

# Test read_file is relative to config_root (not CWD)
# Create a nested project structure with config files at different levels
TEST_ROOT=$PWD
mkdir -p project/subdir
cd project

# Create version files at each level
echo "1.0.0" >version.txt
echo "2.0.0" >subdir/version.txt

# Create a data file only in parent
echo "parent-only-data" >parent-data.txt

# Create mise.toml in parent directory
cat <<EOF >mise.toml
[env]
PARENT_VERSION = "{{ read_file(path='version.txt') | trim }}"
PARENT_DATA = "{{ read_file(path='parent-data.txt') | trim }}"
EOF

# Create mise.toml in subdirectory that reads its own version.txt
cat <<EOF >subdir/mise.toml
[env]
SUB_VERSION = "{{ read_file(path='version.txt') | trim }}"
# This should fail if uncommented - file doesn't exist relative to subdir:
# SUB_PARENT_DATA = "{{ read_file(path='parent-data.txt') | trim }}"
EOF

# Test from parent directory - should read parent's version.txt (1.0.0)
assert_contains "mise env -s bash | grep PARENT_VERSION" "export PARENT_VERSION=1.0.0"
assert_contains "mise env -s bash | grep PARENT_DATA" "export PARENT_DATA=parent-only-data"

# Test from subdirectory - should read both configs, each with their own files
cd subdir
assert_contains "mise env -s bash | grep PARENT_VERSION" "export PARENT_VERSION=1.0.0"
assert_contains "mise env -s bash | grep SUB_VERSION" "export SUB_VERSION=2.0.0"
assert_contains "mise env -s bash | grep PARENT_DATA" "export PARENT_DATA=parent-only-data"

# Now test that running mise from a completely different directory still works
PROJECT_DIR=$TEST_ROOT/project
cd /tmp
assert_contains "mise env -s bash -C $PROJECT_DIR | grep PARENT_VERSION" "export PARENT_VERSION=1.0.0"
assert_contains "mise env -s bash -C $PROJECT_DIR/subdir | grep SUB_VERSION" "export SUB_VERSION=2.0.0"

# Go back to original test directory
cd "$TEST_ROOT"

# Cleanup
rm -f test_version.txt multiline.txt config.txt template_test.txt
rm -rf subdir project
5 changes: 5 additions & 0 deletions mise.lock
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ checksum = "blake3:dbfd6f2b7d0f399f6ec8cd251a76656c27af25aa327c015ef1acf4307c6a4
size = 6984984
url = "https://github.com/jdx/hk/releases/download/v1.15.5/hk-x86_64-unknown-linux-gnu.tar.gz"

[tools.hk.platforms.macos-arm64]
checksum = "blake3:98c9e44790bcff4b4988e6a13f9524272164e98fd475dc43f364cfb41bb4e897"
size = 6016496
url = "https://github.com/jdx/hk/releases/download/v1.15.5/hk-aarch64-apple-darwin.tar.gz"

[[tools.jq]]
version = "1.8.1"
backend = "aqua:jqlang/jq"
Expand Down
60 changes: 59 additions & 1 deletion src/tera.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,8 @@ static TERA: Lazy<Tera> = Lazy::new(|| {
pub fn get_tera(dir: Option<&Path>) -> Tera {
let mut tera = TERA.clone();
let dir = dir.map(PathBuf::from);
tera.register_function("exec", tera_exec(dir, env::PRISTINE_ENV.clone()));
tera.register_function("exec", tera_exec(dir.clone(), env::PRISTINE_ENV.clone()));
tera.register_function("read_file", tera_read_file(dir));

tera
}
Expand Down Expand Up @@ -374,6 +375,32 @@ pub fn tera_exec(
}
}

pub fn tera_read_file(
dir: Option<PathBuf>,
) -> impl Fn(&HashMap<String, Value>) -> tera::Result<Value> {
move |args: &HashMap<String, Value>| -> tera::Result<Value> {
match args.get("path") {
Some(Value::String(path_str)) => {
let path = if let Some(ref base_dir) = dir {
// Resolve relative to config directory
base_dir.join(path_str)
} else {
// Use path as-is if no directory context
PathBuf::from(path_str)
Comment on lines +388 to +389
Copy link

Copilot AI Sep 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When no directory context is provided, the function uses the path as-is, which could allow reading files from anywhere on the filesystem. Consider either requiring a base directory or defaulting to a safe directory like the current working directory to prevent unintended file access.

Suggested change
// Use path as-is if no directory context
PathBuf::from(path_str)
// Default to current working directory if no directory context
match std::env::current_dir() {
Ok(cwd) => cwd.join(path_str),
Err(e) => {
return Err(format!("Failed to get current directory: {}", e).into());
}
}

Copilot uses AI. Check for mistakes.
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Tera Template Engine Path Traversal Vulnerability

The tera_read_file function is vulnerable to path traversal, allowing arbitrary file reads. PathBuf::join() doesn't restrict paths to the base directory, permitting ../ sequences and absolute paths to escape. Without a base directory, absolute paths can read any system file.

Fix in Cursor Fix in Web


match std::fs::read_to_string(&path) {
Ok(contents) => Ok(Value::String(contents)),
Err(e) => {
Err(format!("Failed to read file '{}': {}", path.display(), e).into())
}
}
Comment on lines +392 to +397
Copy link

Copilot AI Sep 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The read_file function allows reading arbitrary files from the filesystem without any path validation or security checks. Consider adding validation to prevent directory traversal attacks (e.g., paths containing ../ or absolute paths) and restrict file access to only files within the configuration directory tree.

Copilot uses AI. Check for mistakes.
}
_ => Err("read_file path must be a string".into()),
}
}
}

#[cfg(test)]
mod tests {
use crate::config::Config;
Expand Down Expand Up @@ -654,6 +681,37 @@ mod tests {
assert_eq!(s.trim(), "ok");
}

#[tokio::test]
#[cfg(unix)]
async fn test_read_file() {
use std::fs;
use tempfile::TempDir;

let _config = Config::get().await.unwrap();

// Create a temp directory and test file
let temp_dir = TempDir::new().unwrap();
let test_file_path = temp_dir.path().join("test.txt");
fs::write(&test_file_path, "test content\nwith multiple lines").unwrap();

// Test with the temp file
let mut tera_ctx = BASE_CONTEXT.clone();
tera_ctx.insert("config_root", &temp_dir.path().to_str().unwrap());
tera_ctx.insert("cwd", temp_dir.path().to_str().unwrap());
let mut tera = get_tera(Some(temp_dir.path()));

let s = tera
.render_str(r#"{{ read_file(path="test.txt") }}"#, &tera_ctx)
.unwrap();
assert_eq!(s, "test content\nwith multiple lines");

// Test with trim filter
let s = tera
.render_str(r#"{{ read_file(path="test.txt") | trim }}"#, &tera_ctx)
.unwrap();
assert_eq!(s, "test content\nwith multiple lines");
}

fn render(s: &str) -> String {
let config_root = Path::new("/");
let mut tera_ctx = BASE_CONTEXT.clone();
Expand Down
Loading