security(config): keep untrusted repo content from hanging or crashing hook-env (#14916)

<!-- entire-trail-link-start -->
https://entire.com/gh/jdx/-/trails/2754/changes/mise/1019
<!-- entire-trail-link-end -->

Simply `cd`-ing into an untrusted repository could hang or crash the
shell prompt, or make mise echo the contents of files outside the repo.
Ways it could happen:
- a `mise.toml` linked to `/dev/zero`;
- a symlink loop in a task directory;
- a non-UTF-8 directory name;
- a malformed `rust-toolchain.toml`;
- an aqua version with multibyte characters;
- a lockfile path or `.miserc.toml` filename that expands as a glob;
- an oversized session variable.

This PR makes config loading and `hook-env` robust against these inputs.

## What changes

- **Regular files only**: config files, `mise.lock`, task `file =`
targets and git pointer files are read only when they are regular files.
A config linked to a device or FIFO is skipped.
- **Task directory walk**: stops with an error after 100,000 entries, so
a symlink maze can't hang task discovery.
- **"Did you mean" suggestions**: cached per name, skipped for names
over 100 bytes, and limited to 20 distinct names per process.
- **`__MISE_SESSION`**: `hook-env` errors instead of exporting a
variable over 128 KiB, which would stop every later command from
starting.
- **Non-UTF-8 paths**: template contexts, trust record names and
`--json` output handle them instead of panicking.
- **aqua**: `split_version_prefix` splits on character boundaries, and
malformed `version_constraint` values are treated as unmatched instead
of panicking.
- **`rust-toolchain.toml`**: a field of the wrong type is an error
naming the field, not a panic.
- **Literal paths, not globs**: the Gemfile `ruby file:` watch, the
lockfile watch path and `override_*_filenames` (rooted at their
directory) are matched literally.
- **TOML parse errors**: they no longer quote a project file reached
through a symlink, so a repo can't make mise print a file from elsewhere
on disk. The error gives the file, line and column only; the parser's
message is withheld too, since a value error such as `invalid type:
string "…"` quotes the value. Projects that themselves sit behind a
link, like macOS `/tmp`, still get the excerpt.
- **pypi**: sdist filenames in a simple index are parsed in linear time,
so a crafted index can't stall `mise ls`.

- **Version files linked to another file**: a `.tool-versions` or
idiomatic version file (such as `.node-version`) that is a symlink to a
file with a different name is skipped with a warning that doesn't show
its contents, and mise won't write `.tool-versions` back through such a
link. A repo can no longer make mise read, print or overwrite something
like `~/.netrc`. A same-name link, such as a shared `.tool-versions`,
still works.
- **Config size limit**: `mise.toml`, `.tool-versions`, idiomatic
version files, `mise.lock`, `.miserc.toml` and task TOML files larger
than 16 MiB are refused with an error. A command that edits a config
(`mise use`, `mise set`, …) fails instead of saving when the file can no
longer be read, rather than overwriting it with an empty document.
- **Idiomatic files in monorepos**: the monorepo root only counts
`[settings]` from trusted configs when deciding which idiomatic version
files to read.
- **TOML parse errors**: a file in a symlinked task subdirectory (for
example `mise-tasks/group -> ~/private`) no longer has its text quoted.

## Compatibility

- `MISE_OVERRIDE_*_FILENAMES` entries are now literal file names. Glob
patterns there were never documented.
- A task directory with more than 100,000 entries now errors.
- A `.tool-versions` or idiomatic version file that links to a file with
a different name is skipped.
- Config, lock and task files over 16 MiB are refused.

## Validation

Each fix has a regression test that fails, or times out, without it.
mise library, binary, mise-util and aqua-registry unit tests pass, apart
from one that needs write access to `/usr/local/share/mise` and can't
run in this sandbox. The four new e2e tests and about 25 related ones
pass, as do clippy with `-D warnings` and `cargo +1.95 check`.

*AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5-5;
version: 2.1.308.*

🤖 Generated with [Claude Code](https://claude.com/claude-code)



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Configuration files larger than 16 MiB are rejected, and task
discovery stops with an error after 100,000 entries.
* Symlinked and non-regular files are handled more safely. Project
version-file links to differently named files are skipped with a
warning, and some linked configuration errors show location details
without source excerpts.
* Non-UTF-8 paths, invalid version constraints, and glob characters in
directory names are handled more reliably.
* Environment hooks report an error when added or changed values exceed
the supported size, without printing partial updates.
  * Unknown-tool suggestions are limited to reduce repeated searches.

* **Documentation**
* Clarified symlink behavior for version files, configuration errors,
and task discovery.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jdxandClaude Opus 5.5 authored and GitHub committed 2026-10-23 17:26:19 -05:00
1 parent 3edcfc7752
commit b71ae7cfd6
54 files changed
+1875 -231

No files matched your search

+12 -22
View File
@@ -110,28 +110,18 @@ pub(super) fn normalize_libc(libc: Option<&str>) -> Option<&str> {
}
/// splits a version number into an optional prefix and the remaining version string
pub(super) fn split_version_prefix(version: &str) -> (String, String) {
version
.char_indices()
.find_map(|(i, c)| {
if c.is_ascii_digit() {
if i == 0 {
return Some(i);
}
// If the previous char is a delimiter or 'v', we found a split point.
let prev_char = version.chars().nth(i - 1).unwrap();
if ['-', '_', '/', '.', 'v', 'V'].contains(&prev_char) {
return Some(i);
}
}
None
})
.map_or_else(
|| ("".into(), version.into()),
|i| {
let (prefix, version) = version.split_at(i);
(prefix.into(), version.into())
},
)
let mut prev_char = None;
for (i, c) in version.char_indices() {
// If the previous char is a delimiter or 'v', we found a split point.
if c.is_ascii_digit()
&& (i == 0 || prev_char.is_some_and(|c| ['-', '_', '/', '.', 'v', 'V'].contains(&c)))
{
let (prefix, version) = version.split_at(i);
return (prefix.into(), version.into());
}
prev_char = Some(c);
}
("".into(), version.into())
}
pub(super) const AQUA_ASSET_FORMATS: &[&str] = &[
+14 -7
View File
@@ -170,13 +170,20 @@ impl AquaPackage {
if vo.version_constraint.is_empty() {
true
} else {
expr.eval(&vo.version_constraint, ctx)
.map_err(|e| {
log::debug!("error parsing {}: {e}", vo.version_constraint)
})
.unwrap_or(false.into())
.as_bool()
.unwrap()
match expr.eval(&vo.version_constraint, ctx) {
Ok(Value::Bool(matched)) => matched,
Ok(_) => {
log::warn!(
"invalid response from version constraint: {}",
vo.version_constraint
);
false
}
Err(e) => {
log::debug!("error parsing {}: {e}", vo.version_constraint);
false
}
}
}
})
})
@@ -39,9 +39,10 @@ impl AquaPackage {
if c.args.len() != 1 {
return Err("semver() takes exactly one argument".to_string().into());
}
let requirements = c.args[0]
.as_string()
.unwrap()
let Some(requirements) = c.args[0].as_string() else {
return Err("semver() takes a string argument".to_string().into());
};
let requirements = requirements
.replace(' ', "")
.split(',')
.map(AquaRequirement::parse)
@@ -107,6 +107,31 @@ fn test_semver_constraint_without_an_operator_is_not_satisfied() {
assert!(!constraint_holds("semver(\"<= \")", "5.32.1.0"));
}
#[test]
fn test_version_constraint_with_a_non_bool_result_is_not_satisfied() {
assert!(!constraint_holds("Version", "1.0.0"));
assert!(!constraint_holds("1", "1.0.0"));
assert!(!constraint_holds("nil", "1.0.0"));
let pkg = AquaPackage {
version_constraint: "Version".to_string(),
..Default::default()
};
assert!(!pkg.version_constraint_ok(&["1.0.0"]));
}
#[test]
fn test_semver_with_a_non_string_argument_is_an_error() {
assert!(!constraint_holds("semver(1)", "1.0.0"));
let mut pkg = AquaPackage {
version_filter: Some("semver(1)".to_string()),
..Default::default()
};
pkg.setup_version_filter().unwrap();
assert!(pkg.version_filter_ok("1.0.0").is_err());
}
#[test]
fn test_version_override_matches_version_prefix() {
let pkg = AquaPackage {
@@ -179,3 +204,27 @@ fn test_unconditional_package_ignores_version_prefix() {
assert_eq!(resolved.version_prefix.as_deref(), Some("jq-"));
assert_eq!(resolved.asset, "jq-{{.OS}}-{{.Arch}}");
}
#[test]
fn test_split_version_prefix() {
assert_eq!(split_version_prefix("latest"), ("".into(), "latest".into()));
assert_eq!(split_version_prefix("v1.2.3"), ("v".into(), "1.2.3".into()));
assert_eq!(
split_version_prefix("cli/1.2.3"),
("cli/".into(), "1.2.3".into())
);
assert_eq!(
split_version_prefix("\u{5de5}\u{5177}-v1.2.3"),
("\u{5de5}\u{5177}-v".into(), "1.2.3".into())
);
assert_eq!(
split_version_prefix("\u{5de5}1"),
("".into(), "\u{5de5}1".into())
);
}
#[test]
fn test_version_override_multibyte_version() {
assert!(!constraint_holds("semver(\">= 1.0.0\")", "\u{5de5}1"));
assert!(constraint_holds("semver(\">= 1.0.0\")", "\u{5de5}-v1.2.3"));
}
+1 -1
View File
@@ -423,7 +423,7 @@ fn template_literal(target: &str) -> String {
fn render_variant_target(target: &str, origin: &ResourceOrigin) -> Result<String> {
let rendered = if mise_util::tera::contains_template_syntax(target) {
let mut ctx = mise_util::tera::BASE_CONTEXT.clone();
ctx.insert("config_root", &origin.config_root);
ctx.insert("config_root", &origin.config_root.to_string_lossy());
let config_source =
std::path::absolute(&origin.config).unwrap_or_else(|_| origin.config.clone());
ctx.insert("config_source", &config_source.to_string_lossy());
+1 -1
View File
@@ -174,7 +174,7 @@ pub async fn run_phase(
let mut context = config.bootstrap_tera_ctx(&hook.config_path).clone();
if context.get("config_root").is_none() {
let config_root = crate::host::config_root(&hook.config_path);
context.insert("config_root", &config_root);
context.insert("config_root", &config_root.to_string_lossy());
}
let rendered = if dry_run {
match mise_util::tera::render_for_dry_run(
+1 -1
View File
@@ -1040,7 +1040,7 @@ fn render_repo_str(config: &Config, config_path: &Path, path_raw: &str) -> Resul
let mut ctx = config.bootstrap_tera_ctx(config_path).clone();
if ctx.get("config_root").is_none() {
let config_root = crate::host::config_root(config_path);
ctx.insert("config_root", &config_root);
ctx.insert("config_root", &config_root.to_string_lossy());
}
let mut tera = mise_util::tera::get_tera_without_exec(config_path.parent());
Ok(crate::host::render_config_str(
+2 -2
View File
@@ -248,8 +248,8 @@ impl SecretValues {
{
context.insert("vars", &config.vars());
}
context.insert("config_root", base);
context.insert("target", target);
context.insert("config_root", &base.to_string_lossy());
context.insert("target", &target.to_string_lossy());
let rendered = match config {
Some((_, config_path)) => crate::host::render_config_str(
&mut TeraEngine::V2(Box::new(tera)),
+1 -1
View File
@@ -41,7 +41,7 @@ impl<T: DeserializeOwned> TemplatedExt<T> for Templated<T> {
let mut ctx = config.bootstrap_tera_ctx(config_path).clone();
if ctx.get("config_root").is_none() {
let config_root = crate::host::config_root(config_path);
ctx.insert("config_root", &config_root);
ctx.insert("config_root", &config_root.to_string_lossy());
}
self.render_with(&ctx, config_path)
}
+19 -2
View File
@@ -1,3 +1,4 @@
use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsString;
use std::fmt::Debug;
@@ -266,7 +267,7 @@ impl EnvDiff {
v: u32,
old: IndexMap<&'a str, &'a str>,
new: IndexMap<&'a str, String>,
path: &'a [PathBuf],
path: Vec<Cow<'a, str>>,
}
let wire = Wire {
v: ENV_STATE_VERSION,
@@ -281,7 +282,10 @@ impl EnvDiff {
.iter()
.map(|(k, v)| (k.as_str(), hash_env_value(v)))
.collect(),
path: &self.path,
// serde refuses a path that is not valid UTF-8. PATH is exported
// as a lossy string, so that is also the form the next run has to
// find in it.
path: self.path.iter().map(|p| p.to_string_lossy()).collect(),
};
let mut gz = ZlibEncoder::new(Vec::new(), Compression::fast());
gz.write_all(&rmp_serde::to_vec_named(&wire)?)?;
@@ -618,6 +622,19 @@ mod tests {
assert!(back.old.is_empty());
}
#[cfg(unix)]
#[test]
fn test_serialize_path_that_is_not_utf8() {
use std::os::unix::ffi::OsStrExt;
let path = PathBuf::from(std::ffi::OsStr::from_bytes(b"/tmp/caf\xe9/bin"));
let diff = EnvDiff {
path: vec![path.clone()],
..Default::default()
};
let back = EnvDiff::deserialize(&diff.serialize().unwrap()).unwrap();
assert_eq!(back.path, [PathBuf::from(&*path.to_string_lossy())]);
}
#[test]
fn test_serialize_does_not_store_new_values() {
let original: EnvMap = [("CHANGED".into(), "before".into())].into();
+68
View File
@@ -783,6 +783,74 @@ pub fn read_to_string<P: AsRef<Path>>(path: P) -> Result<String> {
.wrap_err_with(|| format!("failed read_to_string: {}", path.display_user()))
}
/// Largest config file mise reads: `mise.toml`, `.tool-versions`, idiomatic
/// version files, `mise.lock`, `.miserc.toml` and task TOML. These are found in
/// whatever directory mise runs in, before anything is trusted, and are read
/// whole, so without a limit a checkout could make every prompt allocate as much
/// as the file holds. Real files are kilobytes: mise's own lockfile, covering
/// dozens of tools on every platform, is about 70 KiB, and a config file a
/// person writes is a few KiB. 16 MiB leaves more than two hundred times that
/// while keeping the cost of a hostile file small.
pub const CONFIG_FILE_LIMIT: u64 = 16 * 1024 * 1024;
fn config_file_too_large(path: &Path) -> eyre::Report {
eyre::eyre!(
"not reading {}: it is larger than {} MiB, the most mise reads from a config file",
path.display_user(),
CONFIG_FILE_LIMIT / 1024 / 1024
)
}
/// Opens `path` only if it is a regular file, judged from the opened handle so
/// that nothing swapped in after an earlier check is read instead. On unix the
/// open does not block, so a pipe put in the file's place cannot hang it.
pub fn open_regular_file<P: AsRef<Path>>(path: P) -> std::io::Result<File> {
let mut options = fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::custom_flags(&mut options, nix::libc::O_NONBLOCK);
let file = options.open(path.as_ref())?;
if !file.metadata()?.is_file() {
return Err(std::io::Error::other("not a regular file"));
}
Ok(file)
}
/// [`read_to_string`] for a config file, refusing one that is not a regular
/// file or is larger than [`CONFIG_FILE_LIMIT`].
pub fn read_config_to_string<P: AsRef<Path>>(path: P) -> Result<String> {
let path = path.as_ref();
trace!("cat {}", path.display_user());
let mut body = String::new();
open_regular_file(path)
.and_then(|file| file.take(CONFIG_FILE_LIMIT + 1).read_to_string(&mut body))
.wrap_err_with(|| format!("failed read_to_string: {}", path.display_user()))?;
if body.len() as u64 > CONFIG_FILE_LIMIT {
return Err(config_file_too_large(path));
}
Ok(body)
}
/// Fails when `path` is a config file larger than [`CONFIG_FILE_LIMIT`], for
/// readers that open the file themselves. A file that cannot be inspected is
/// left to the reader to report.
pub fn ensure_config_file_size<P: AsRef<Path>>(path: P) -> Result<()> {
let path = path.as_ref();
match fs::metadata(path) {
Ok(metadata) if metadata.len() > CONFIG_FILE_LIMIT => Err(config_file_too_large(path)),
_ => Ok(()),
}
}
/// [`read_config_to_string`] on the blocking pool, for async callers: the same
/// regular-file check and size limit, judged on the opened handle.
pub async fn read_config_to_string_async<P: AsRef<Path>>(path: P) -> Result<String> {
let path = path.as_ref().to_path_buf();
tokio::task::spawn_blocking(move || read_config_to_string(&path))
.await
.wrap_err("config file read was cancelled")?
}
/// The bytes of a UTF-8 byte-order mark, as [`decode_text`] matches them below.
#[cfg(windows)]
pub const UTF8_BOM_BYTES: [u8; 3] = [0xef, 0xbb, 0xbf];
+78
View File
@@ -121,3 +121,81 @@ async fn test_run_blocking_multi_thread_runtime() {
// tokio::task::block_in_place path
assert_eq!(run_blocking(|| 42), 42);
}
/// A pipe in a config file's place is refused without waiting for a writer,
/// even when nothing checked the name before the open.
#[cfg(unix)]
#[test]
fn open_regular_file_refuses_a_pipe_without_blocking() {
let tmp = tempfile::tempdir().unwrap();
let fifo = tmp.path().join("mise.toml");
nix::unistd::mkfifo(
&fifo,
nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
)
.unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let probe = fifo.clone();
std::thread::spawn(move || {
let _ = tx.send(read_config_to_string(&probe).is_err());
});
let refused = rx
.recv_timeout(std::time::Duration::from_secs(10))
.expect("opening a pipe blocked");
assert!(refused);
let dir = tmp.path().join("dir.toml");
std::fs::create_dir(&dir).unwrap();
assert!(open_regular_file(&dir).is_err());
}
/// The async reader makes the same checks on the opened handle: a pipe that a
/// size check by name would pass (it reports no length) is refused at once.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn read_config_to_string_async_refuses_a_pipe_without_blocking() {
let tmp = tempfile::tempdir().unwrap();
let fifo = tmp.path().join("tasks.toml");
nix::unistd::mkfifo(
&fifo,
nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
)
.unwrap();
let result = tokio::time::timeout(
std::time::Duration::from_secs(10),
read_config_to_string_async(&fifo),
)
.await
.expect("reading a pipe blocked");
let err = result.unwrap_err();
assert!(format!("{err:#}").contains("not a regular file"), "{err:#}");
}
/// A config file larger than any real one is refused rather than read whole.
#[test]
fn config_files_over_the_limit_are_refused() {
let tmp = tempfile::tempdir().unwrap();
let small = tmp.path().join("mise.toml");
std::fs::write(&small, "[tools]\n").unwrap();
assert_eq!(read_config_to_string(&small).unwrap(), "[tools]\n");
ensure_config_file_size(&small).unwrap();
// Sparse, so the test writes nothing near the limit to disk.
let at_limit = tmp.path().join("at_limit.toml");
File::create(&at_limit)
.unwrap()
.set_len(CONFIG_FILE_LIMIT)
.unwrap();
assert_eq!(
read_config_to_string(&at_limit).unwrap().len() as u64,
CONFIG_FILE_LIMIT
);
let large = tmp.path().join("large.toml");
File::create(&large)
.unwrap()
.set_len(CONFIG_FILE_LIMIT + 1)
.unwrap();
let err = read_config_to_string(&large).unwrap_err().to_string();
assert!(err.contains("larger than 16 MiB"), "{err}");
assert!(ensure_config_file_size(&large).is_err());
}
+22 -8
View File
@@ -1008,11 +1008,7 @@ fn worktree_gitdir(dotgit_file: &Path) -> Option<PathBuf> {
/// For a worktree of an ordinary repository this is `<main>/.git`; for one of a
/// bare repository it is the bare repo itself.
fn worktree_common_dir(gitdir: &Path) -> Option<PathBuf> {
let common = PathBuf::from(
std::fs::read_to_string(gitdir.join("commondir"))
.ok()?
.trim(),
);
let common = PathBuf::from(read_pointer_file(&gitdir.join("commondir"))?.trim());
let common = if common.is_relative() {
gitdir.join(common)
} else {
@@ -1061,10 +1057,28 @@ fn is_submodule_gitdir(dotgit_file: &Path) -> bool {
false
}
/// Longest pointer file read: any path git records is far shorter.
const POINTER_FILE_LIMIT: u64 = 16 * 1024;
/// The contents of one of git's pointer files (`.git`, `gitdir`, `commondir`),
/// each holding a single path. They are followed out of directories mise does
/// not control, where the name may be a pipe or a link to a device, so anything
/// other than a regular file of pointer size is skipped instead of read whole.
fn read_pointer_file(path: &Path) -> Option<String> {
use std::io::Read;
let mut contents = String::new();
crate::file::open_regular_file(path)
.ok()?
.take(POINTER_FILE_LIMIT + 1)
.read_to_string(&mut contents)
.ok()?;
(contents.len() as u64 <= POINTER_FILE_LIMIT).then_some(contents)
}
/// The git dir a `.git` *file* names, resolved against that file's own
/// directory when the recorded path is relative.
fn read_gitdir(dotgit_file: &Path) -> Option<PathBuf> {
let contents = std::fs::read_to_string(dotgit_file).ok()?;
let contents = read_pointer_file(dotgit_file)?;
let gitdir = PathBuf::from(contents.strip_prefix("gitdir:")?.trim());
Some(if gitdir.is_relative() {
dotgit_file.parent()?.join(gitdir)
@@ -1099,7 +1113,7 @@ pub fn has_sibling_worktrees(worktree_root: &Path) -> bool {
// inherit a namespace. Git decides that by the `gitdir` pointer
// still naming something, so this does too, rather than sending a
// user off to change a namespace nothing else shares.
let Ok(pointer) = std::fs::read_to_string(entry.path().join("gitdir")) else {
let Some(pointer) = read_pointer_file(&entry.path().join("gitdir")) else {
return false;
};
// `worktree.useRelativePaths` writes the pointer relative to the
@@ -1142,7 +1156,7 @@ fn main_checkout_root(dotgit_file: &Path) -> Option<PathBuf> {
/// whatever its `.git` file says.
fn registered_main_checkout_root(dotgit_file: &Path) -> Option<PathBuf> {
let gitdir = worktree_gitdir(dotgit_file)?;
let pointer = PathBuf::from(std::fs::read_to_string(gitdir.join("gitdir")).ok()?.trim());
let pointer = PathBuf::from(read_pointer_file(&gitdir.join("gitdir"))?.trim());
// `worktree.useRelativePaths` writes the pointer relative to the entry
// holding it, as `commondir` is written.
let pointer = if pointer.is_relative() {
@@ -426,3 +426,66 @@ fn git_commands_ignore_inherited_work_tree() {
assert!(clone_cache.join("file.txt").exists());
assert!(!work_tree.join("file.txt").exists());
}
/// A `.git` pointer file in an unpacked archive can lead to a `commondir`
/// that is a pipe, which an unbounded read would wait on forever.
#[cfg(unix)]
#[test]
fn worktree_detection_skips_a_commondir_that_is_a_pipe() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().canonicalize().unwrap();
let checkout = base.join("proj");
let private = checkout.join("g/worktrees/x");
std::fs::create_dir_all(&private).unwrap();
std::fs::write(checkout.join(".git"), "gitdir: g/worktrees/x\n").unwrap();
nix::unistd::mkfifo(
&private.join("commondir"),
nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
)
.unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let probe = checkout.clone();
std::thread::spawn(move || {
let _ = tx.send(super::in_linked_worktree(&probe));
});
let linked = rx
.recv_timeout(std::time::Duration::from_secs(10))
.expect("worktree detection blocked on a commondir pipe");
assert!(!linked);
}
/// The worktree entry's `gitdir` pointer is read while trust is resolved, and
/// a pipe there must not block it.
#[cfg(unix)]
#[test]
fn main_checkout_equivalent_skips_a_gitdir_pointer_that_is_a_pipe() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().canonicalize().unwrap();
let main = base.join("main");
let wt = base.join("wt");
std::fs::create_dir_all(main.join(".git/worktrees/wt")).unwrap();
std::fs::create_dir_all(&wt).unwrap();
std::fs::write(main.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
std::fs::write(main.join(".git/worktrees/wt/commondir"), "../..\n").unwrap();
nix::unistd::mkfifo(
&main.join(".git/worktrees/wt/gitdir"),
nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
)
.unwrap();
std::fs::write(
wt.join(".git"),
format!("gitdir: {}\n", main.join(".git/worktrees/wt").display()),
)
.unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let probe = wt.clone();
std::thread::spawn(move || {
let _ = tx.send(super::main_checkout_equivalent(&probe));
});
let equivalent = rx
.recv_timeout(std::time::Duration::from_millis(500))
.expect("worktree trust blocked on a gitdir pipe");
assert_eq!(equivalent, None);
}
+9 -7
View File
@@ -375,20 +375,22 @@ fn tera_v1_float(value: Value, args: Kwargs) -> TeraResult<Value> {
pub static BASE_CONTEXT: Lazy<Context> = Lazy::new(|| {
let mut context = Context::new();
context.insert("env", &*env::PRISTINE_ENV);
context.insert("mise_bin", &*env::MISE_BIN);
// `Context::insert` panics when the value does not serialize, which a
// path that is not valid UTF-8 does not, so paths go in as lossy strings.
context.insert("mise_bin", &env::MISE_BIN.to_string_lossy());
context.insert("mise_pid", &*env::MISE_PID);
let mise_env = env::mise_env();
if !mise_env.is_empty() {
context.insert("mise_env", mise_env);
}
if let Ok(dir) = env::current_dir() {
context.insert("cwd", &dir);
context.insert("cwd", &dir.to_string_lossy());
}
context.insert("xdg_cache_home", &*env::XDG_CACHE_HOME);
context.insert("xdg_config_home", &*env::XDG_CONFIG_HOME);
context.insert("xdg_data_home", &*env::XDG_DATA_HOME);
context.insert("xdg_state_home", &*env::XDG_STATE_HOME);
context.insert("mise_config_dir", &*env::MISE_CONFIG_DIR);
context.insert("xdg_cache_home", &env::XDG_CACHE_HOME.to_string_lossy());
context.insert("xdg_config_home", &env::XDG_CONFIG_HOME.to_string_lossy());
context.insert("xdg_data_home", &env::XDG_DATA_HOME.to_string_lossy());
context.insert("xdg_state_home", &env::XDG_STATE_HOME.to_string_lossy());
context.insert("mise_config_dir", &env::MISE_CONFIG_DIR.to_string_lossy());
context.insert("system_brew_prefix", &env::system_brew_prefix());
context
});
+9
View File
@@ -215,6 +215,15 @@ mise settings add idiomatic_version_file_enable_tools node
They accept the same aliases as the tool, so an `.nvmrc` that contains
`lts/hydrogen` works in both mise and nvm.
A `.tool-versions` or idiomatic version file may be a symlink to a version file
of the same name elsewhere, such as one shared across a monorepo. mise does not
read one that links to a file of another name and warns
`not reading <path>: it is a symlink to a file of another name`: a project could
otherwise point it at a file such as `~/.netrc` and have its text shown as tool
names and versions. Your own files are exempt: global config such as
`~/.tool-versions`, and version files directly in your home directory, may link
to a file of any name.
### Enable idiomatic version files {#enabling-idiomatic-version-files}
[`idiomatic_version_file_enable_tools`](/configuration/settings.html#idiomatic_version_file_enable_tools)
+8
View File
@@ -48,6 +48,14 @@ whether it needs trust, so an untrusted file with a syntax error fails with
`TOML parse error at line <N>, column <M>` warning above that error shows the
real problem.
A project file that is a symlink, or that mise reaches through a linked config
directory such as `.config`, is not quoted, because the text belongs to
whatever the link points at rather than to the project. mise prints the
position and the parser's message on their own:
`Invalid TOML in config file <path>:<line>:<column>: <message>`. Links inside
the global and system config directories are the user's own and keep the
excerpt.
### `Config files in <path> are not trusted` {#untrusted-config}
mise found a project config file that needs trust before it can load: one with
+4
View File
@@ -56,6 +56,10 @@ name, as described in [file task grouping](/tasks/file-tasks.html#task-grouping)
When two of these directories define the same task, the one later in the list
wins.
The search follows symlinks. It stops with an error after 100,000 entries in one
task directory, which a real task directory never reaches but symlinks that
lead into the same directories by many routes can.
On Windows there is no execute permission; see
[Windows](/tasks/file-tasks.html#windows) for how mise decides that a file is a
task there.
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Linux starts no program while one environment variable is 128 KiB or longer,
# including the `mise hook-env` that would replace it, so hook-env must not
# export such a variable. __MISE_SESSION lists every loaded config file, and
# empty conf.d fragments load without trust.
mkdir -p project/.config/mise/conf.d
# random names, so that compression does not shrink the list
head -c 90000 /dev/urandom | base64 | tr -d '\n' | tr '+/' 'ab' | fold -w 240 |
while read -r name; do : >"project/.config/mise/conf.d/$name.toml"; done
assert_fail "cd project && mise hook-env -s bash" "__MISE_SESSION would be"
# Nothing reaches the shell, not even the commands that undo the previous
# directory's environment, so it is left as it was rather than half updated.
assert_empty "cd project && mise hook-env -s bash 2>/dev/null || true"
assert_succeed "mise hook-env -s bash --force"
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# mise has to work in a directory whose name is not valid UTF-8. Git stores
# file names as bytes, so a repository can carry one. serde refuses to
# serialize such a path, which panicked in the template context, in the trust
# record name and in __MISE_SESSION.
# macOS file systems refuse such names
if [[ $(uname) != Linux ]]; then
exit 0
fi
# "café" in ISO-8859-1: 0xE9 alone is not valid UTF-8
dir="$PWD/$(printf 'caf\xe9')"
mkdir -p "$dir/empty"
eval "$(mise activate bash)"
cd "$dir/empty" || exit 1
assert_succeed "mise hook-env -s bash"
eval "$(mise hook-env -s bash)"
assert_succeed "mise hook-env -s bash"
cat <<'EOF' >"$dir/mise.toml"
[env]
FOO = "bar"
[tasks.hi]
run = "echo hi"
EOF
touch "$dir/.tool-versions"
cd "$dir" || exit 1
mise trust
eval "$(mise hook-env -s bash)"
assert "echo $FOO" "bar"
assert_succeed "mise ls"
assert_succeed "mise tasks --json"
assert_succeed "mise tasks info hi --json"
assert "mise run hi" "hi"
cd ../ || exit 1
eval "$(mise hook-env -s bash)"
assert "echo ${FOO:-unset}" "unset"
# An untrusted config whose resolved name is not valid UTF-8 names its trust
# record from that name.
export MISE_TRUSTED_CONFIG_PATHS="$HOME/.config/mise"
unset CI GITHUB_ACTIONS GITHUB_ACTION
mkdir linked
printf '[env]\nBAR = "1"\n' >"linked/$(printf 'caf\xe9').toml"
ln -s "$(printf 'caf\xe9').toml" linked/mise.toml
cd linked || exit 1
assert_succeed "mise hook-env -s bash --force"
assert_succeed "mise trust"
assert_contains "mise env -s bash" "BAR"
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Monorepo-wide commands resolve each config root's idiomatic version file
# settings from that root's own config hierarchy. Like the main settings load,
# that resolution skips the [settings] of untrusted project configs, so a
# directory between the monorepo root and a config root cannot turn on
# idiomatic version files for it. A trusted monorepo root trusts every config
# below it, so paranoid mode, where each config is trusted on its own, is what
# leaves `packages/mise.toml` untrusted here.
export MISE_EXPERIMENTAL=1
export MISE_PARANOID=1
export MISE_YES=0
unset CI GITHUB_ACTIONS GITHUB_ACTION
mise plugin install tiny
cat <<EOF >mise.toml
monorepo_root = true
[monorepo]
config_roots = ["packages/*"]
EOF
mkdir -p packages/app1
cat <<EOF >packages/mise.toml
[settings]
idiomatic_version_file_enable_tools = ["tiny"]
EOF
echo "1.0.1" >packages/app1/.tiny-version
cat <<EOF >packages/app1/mise.toml
[env]
APP = "app1"
EOF
export MISE_TRUSTED_CONFIG_PATHS="$PWD/mise.toml:$PWD/packages/app1"
assert_not_contains "mise ls --monorepo tiny 2>&1 || true" "1.0.1"
# Once the config holding the setting is trusted, it applies.
export MISE_TRUSTED_CONFIG_PATHS="$PWD/mise.toml:$PWD/packages/app1:$PWD/packages/mise.toml"
assert_contains "mise ls --monorepo tiny 2>&1" "1.0.1"
+2 -2
View File
@@ -10,7 +10,7 @@ dummy = { version = "1.0.0", postinstall = "false" }
tiny = { version = "1.0.0", postinstall = "mv mise.toml saved.toml; mkdir mise.toml" }
TOML
# One install fails, another succeeds, and saving its updated config fails too.
# One install fails, another succeeds, and updating its config fails too.
if output=$(MISE_FORCE_PROGRESS=1 mise upgrade --bump 2>&1); then
fail "upgrade should report installation and config-save errors: $output"
fi
@@ -20,4 +20,4 @@ assert_contains 'cat output.log' 'tiny 1.0.0 → 3.1.0'
# Check the final error after the summary, not the earlier progress diagnostics.
python3 -c 'from pathlib import Path; print(Path("output.log").read_text().split("Upgraded 1 tool:", 1)[1])' >final-error.log
assert_contains 'cat final-error.log' 'Failed to install asdf:dummy@2.0.0'
assert_contains 'cat final-error.log' 'Failed to save config'
assert_contains 'cat final-error.log' 'Failed to update config for tiny'
+4 -3
View File
@@ -8,13 +8,14 @@ cat >mise.toml <<'TOML'
dummy = { version = "1.0.0", postinstall = "mv mise.toml saved.toml; mkdir mise.toml" }
TOML
# The hook lets installation succeed but makes the subsequent config save fail.
# A directory at the destination fails even when the test runs as root.
# The hook lets installation succeed but leaves a directory where mise.toml was,
# so updating the config fails, even when the test runs as root. mise refuses to
# edit a config it can no longer read rather than writing a new one over it.
if output=$(MISE_FORCE_PROGRESS=1 mise upgrade --bump 2>&1); then
fail "upgrade should fail when its config cannot be saved: $output"
fi
printf '%s' "$output" | python3 -c 'import re, sys; print(re.sub(r"\x1b\[[0-9;?]*[a-zA-Z]", "", sys.stdin.read()))' >output.log
assert_contains 'cat output.log' 'Failed to save config'
assert_contains 'cat output.log' 'Failed to update config for dummy'
assert_contains 'cat output.log' 'Upgraded 1 tool:'
assert_contains 'cat output.log' 'dummy 1.0.0 → 2.0.0'
assert_not_contains 'cat output.log' '✓ dummy@2.0.0'
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Config files found in a checkout are read whole before anything is trusted,
# so mise refuses one larger than 16 MiB, far more than any real config or
# lockfile needs, instead of allocating as much as the file holds. The files
# below are sparse, so the test writes almost nothing to disk.
echo 'tools.dummy = "1"' >mise.toml
assert_contains "mise ls dummy" "1"
truncate -s 17M .tool-versions
assert_fail "mise ls" "not reading ~/workdir/.tool-versions: it is larger than 16 MiB"
rm .tool-versions
truncate -s 17M mise.lock
assert_contains "mise ls 2>&1" "not reading ~/workdir/mise.lock: it is larger than 16 MiB"
rm mise.lock
truncate -s 17M .miserc.toml
assert_fail "mise ls" "not reading ~/workdir/.miserc.toml: it is larger than 16 MiB"
rm .miserc.toml
mkdir mise-tasks
truncate -s 17M mise-tasks/big.toml
assert_fail "mise tasks ls" "not reading ~/workdir/mise-tasks/big.toml: it is larger than 16 MiB"
rm -r mise-tasks
# An executable file task is read for its `#MISE` header the same way.
mkdir mise-tasks
printf '#!/bin/sh\n#MISE description="big"\n' >mise-tasks/big
truncate -s 17M mise-tasks/big
chmod +x mise-tasks/big
assert_fail "mise tasks ls" "not reading ~/workdir/mise-tasks/big: it is larger than 16 MiB"
rm -r mise-tasks
truncate -s 17M .node-version
assert_fail "MISE_IDIOMATIC_VERSION_FILE_ENABLE_TOOLS=node mise ls" "not reading ~/workdir/.node-version: it is larger than 16 MiB"
rm .node-version
rm mise.toml
truncate -s 17M mise.toml
assert_fail "mise ls" "not reading ~/workdir/mise.toml: it is larger than 16 MiB"
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# A project can ship `.tool-versions` or an idiomatic version file as a symlink
# to a file of the user's. Read as a version file, `~/.netrc` turns into tools
# named `machine` and `password` with the secret as a version, which mise then
# prints in warnings and errors. A linked version file is read only when the
# target has the same name, the way a monorepo shares one.
mkdir -p "$HOME/private"
printf 'machine example.com\nlogin me\npassword s3cr3t\n' >"$HOME/private/netrc"
ln -s "$HOME/private/netrc" .tool-versions
for cmd in "mise hook-env -s bash" "mise ls" "mise env" "mise current" "mise install" "MISE_SAFE=1 mise hook-env -s bash"; do
assert_not_contains "$cmd 2>&1 || true" "s3cr3t"
done
assert_contains "mise ls 2>&1" "not reading ~/workdir/.tool-versions"
# Nothing is written back through the link either.
assert_fail "mise use --path .tool-versions dummy@1" "not writing ~/workdir/.tool-versions"
assert_contains "cat $HOME/private/netrc" "password s3cr3t"
rm .tool-versions
echo "s3cr3t" >"$HOME/private/token"
ln -s "$HOME/private/token" .node-version
export MISE_IDIOMATIC_VERSION_FILE_ENABLE_TOOLS=node
for cmd in "mise ls" "mise current" "mise install"; do
assert_not_contains "$cmd 2>&1 || true" "s3cr3t"
done
rm .node-version
unset MISE_IDIOMATIC_VERSION_FILE_ENABLE_TOOLS
# A link to a version file of the same name is read as before.
mkdir shared
echo "dummy 1.0.0" >shared/.tool-versions
ln -s shared/.tool-versions .tool-versions
assert_contains "mise ls dummy" "1.0.0"
rm .tool-versions
# The user's own global `~/.tool-versions` may link to a file of any name, as
# a dotfiles manager sets it up.
mkdir -p "$HOME/dotfiles"
echo "dummy 2.0.0" >"$HOME/dotfiles/tool-versions"
ln -s "$HOME/dotfiles/tool-versions" "$HOME/.tool-versions"
assert_contains "mise ls dummy 2>&1" "2.0.0"
assert_not_contains "mise ls dummy 2>&1" "not reading"
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# override_config_filenames and override_tool_versions_filenames hold filenames,
# not glob patterns. A project .miserc.toml sets them without trust, and config
# discovery looks for each name in every ancestor directory, so `**` used to walk
# the whole filesystem and `*` turned every file on the way up into a config.
echo 'tools.dummy = "1"' >mise.toml
echo 'dummy 1' >.tool-versions
for setting in override_config_filenames override_tool_versions_filenames; do
echo "$setting = [\"**\"]" >.miserc.toml
assert_succeed "timeout 30s mise hook-env -s bash"
echo "$setting = [\"*\"]" >.miserc.toml
assert_succeed "mise hook-env -s bash"
done
# A wildcard matches no other file...
echo 'override_config_filenames = ["*.toml"]' >.miserc.toml
echo 'override_tool_versions_filenames = []' >>.miserc.toml
assert_empty "mise ls dummy"
# ...only the file that really has that name
echo 'tools.dummy = "2"' >'*.toml'
assert "mise ls dummy" "dummy 2.0.0 (missing) ~/workdir/*.toml 2"
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# A TOML parse error quotes the failing line. A project can ship the file mise parses as a symlink,
# and then that line belongs to whichever file of the user's the link names -- while `.miserc.toml`,
# `mise.lock` and, in safe mode, `mise.toml` are parsed with no trust decision behind them, and
# stderr is often a CI log the project's author can read. `/proc/self/environ` has no newline, so
# its failing "line" is the whole environment. The error has to say where the file broke without
# showing what is in it.
mkdir -p "$HOME/private"
echo "token s3cr3t" >"$HOME/private/mise.toml"
# Control: a file that is really here is still quoted, so nothing below passes by accident.
cp "$HOME/private/mise.toml" mise.toml
assert_fail_contains "mise ls" "s3cr3t"
rm mise.toml
# The error names the link, and the position inside it, but not the text behind it.
ln -s "$HOME/private/mise.toml" mise.toml
assert_fail_contains "mise ls" "Invalid TOML in config file ~/workdir/mise.toml:1:7"
assert_not_contains "mise ls 2>&1 || true" "s3cr3t"
assert_not_contains "MISE_SAFE=1 mise ls 2>&1 || true" "s3cr3t"
rm mise.toml
# `.miserc.toml` and `mise.lock` are read in every mode, and the settings layer parses `mise.toml`
# before the trust check does, with its own warning.
ln -s "$HOME/private/mise.toml" .miserc.toml
assert_fail_contains "mise ls" "Invalid TOML in config file ~/workdir/.miserc.toml:1:7"
assert_not_contains "mise ls 2>&1 || true" "s3cr3t"
rm .miserc.toml
echo "tools.dummy = '1'" >mise.toml
ln -s "$HOME/private/mise.toml" mise.lock
assert_contains "mise ls 2>&1" "failed to read lockfile"
assert_not_contains "mise ls 2>&1" "s3cr3t"
rm mise.lock
# TOML task files in a task directory.
mkdir mise-tasks
ln -s "$HOME/private/mise.toml" mise-tasks/x.toml
assert_not_contains "mise tasks ls 2>&1 || true" "s3cr3t"
rm -r mise-tasks
# A linked subdirectory of a task directory, whatever its name.
cp "$HOME/private/mise.toml" "$HOME/private/x.toml"
mkdir mise-tasks
ln -s "$HOME/private" mise-tasks/group
assert_contains "mise tasks ls 2>&1 || true" "x.toml"
assert_not_contains "mise tasks ls 2>&1 || true" "s3cr3t"
rm -r mise-tasks "$HOME/private/x.toml"
# A link in the directory leading to the file is as good as a link to the file.
ln -s "$HOME/private" .config
assert_not_contains "mise ls 2>&1 || true" "s3cr3t"
rm .config
# So is a link in a directory a configured filename names, whatever its name.
cp "$HOME/private/mise.toml" "$HOME/private/private.toml"
ln -s "$HOME/private" custom
echo 'override_config_filenames = ["custom/private.toml"]' >.miserc.toml
assert_fail_contains "MISE_SAFE=1 mise ls" "Invalid TOML in config file ~/workdir/custom/private.toml"
assert_not_contains "MISE_SAFE=1 mise ls 2>&1 || true" "s3cr3t"
rm custom .miserc.toml "$HOME/private/private.toml"
# The user's own links are fine: dotfile managers keep the global config behind one.
ln -s "$HOME/private/mise.toml" "$MISE_CONFIG_DIR/config.toml"
assert_fail_contains "mise ls" "s3cr3t"
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Listing tasks in an untrusted checkout checks each task file for templates
# before loading it. That check must not read an oversized TOML task file
# whole: it is refused at the size limit first, as the loader refuses it. The
# address-space limit turns a whole read of the 4 GiB sparse file into a
# failure, so only the bounded path reaches the size error.
export MISE_YES=0
export MISE_TRUSTED_CONFIG_PATHS=""
unset CI GITHUB_ACTIONS GITHUB_ACTION
mkdir mise-tasks
truncate -s 4G mise-tasks/big.toml
assert_fail "ulimit -v 3000000 && mise tasks ls" "not reading ~/workdir/mise-tasks/big.toml: it is larger than 16 MiB"
+85 -16
View File
@@ -13,12 +13,71 @@ use crate::{backend, config, dirs, file, lockfile, registry};
use contracts::requires;
use eyre::{Result, bail};
use heck::ToShoutySnakeCase;
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::env;
use std::fmt::{Debug, Display};
use std::hash::Hash;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::{LazyLock as Lazy, Mutex};
/// Longest tool name searched for "did you mean" candidates. No registry name
/// comes near it, so a longer name cannot be a typo of one.
const SUGGEST_MAX_NAME_LEN: usize = 100;
/// Distinct unknown names searched per process; later ones get no suggestions.
const SUGGEST_MAX_SEARCHES: usize = 20;
/// "Did you mean?" candidates for a tool name that is in no registry.
///
/// Each search scores the name against every mise and aqua registry entry. The
/// name can come from a config file that loads without trust, and
/// `BackendArg::backend()` runs many times per tool for callers that drop the
/// error, so each name is searched once per process, and both its length and
/// the number of names searched are bounded.
fn similar_tool_names(short: &str) -> Vec<String> {
static SEARCHED: Lazy<Mutex<HashMap<String, Vec<String>>>> = Lazy::new(Default::default);
let mut searched = SEARCHED
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
bounded_suggestions(&mut searched, short, search_similar_tool_names)
}
fn bounded_suggestions(
searched: &mut HashMap<String, Vec<String>>,
short: &str,
search: impl FnOnce(&str) -> Vec<String>,
) -> Vec<String> {
if short.len() > SUGGEST_MAX_NAME_LEN {
return vec![];
}
if let Some(suggestions) = searched.get(short) {
return suggestions.clone();
}
if searched.len() >= SUGGEST_MAX_SEARCHES {
return vec![];
}
let suggestions = search(short);
searched.insert(short.to_string(), suggestions.clone());
suggestions
}
fn search_similar_tool_names(short: &str) -> Vec<String> {
let registry_shorts: Vec<&str> = REGISTRY.keys().collect();
let mut suggestions: Vec<String> =
xx::suggest::similar_n_with_threshold(short, &registry_shorts, 3, 0.8)
.into_iter()
.filter(|s| *s != short)
.map(|s| s.to_string())
.collect();
let mise_names: HashSet<String> = suggestions.iter().cloned().collect();
for aqua in crate::aqua::aqua_registry_wrapper::aqua_suggest(short) {
if !mise_names.contains(&aqua.name) {
suggestions.push(aqua.backend);
}
}
suggestions
}
/// Metadata about how a backend was resolved.
/// This struct is designed for extensibility - additional fields can be added
@@ -396,21 +455,7 @@ impl BackendArg {
);
}
let registry_shorts: Vec<&str> = REGISTRY.keys().collect();
let mut suggestions: Vec<String> =
xx::suggest::similar_n_with_threshold(&self.short, &registry_shorts, 3, 0.8)
.into_iter()
.filter(|s| *s != self.short)
.map(|s| s.to_string())
.collect();
let mise_names: HashSet<String> = suggestions.iter().cloned().collect();
for aqua in crate::aqua::aqua_registry_wrapper::aqua_suggest(&self.short) {
if !mise_names.contains(&aqua.name) {
suggestions.push(aqua.backend);
}
}
let suggestions = similar_tool_names(&self.short);
let mut msg = format!("{self} not found in mise tool registry");
if !suggestions.is_empty() {
msg.push_str("\n\nDid you mean?");
@@ -1067,6 +1112,30 @@ mod tests {
use crate::config::Config;
use pretty_assertions::{assert_eq, assert_str_eq};
/// Tool names from a config that loads without trust reach the registry
/// search, so a long name is not searched, each name is searched once,
/// and only a bounded number of names are searched at all.
#[test]
fn suggestion_search_is_bounded() {
let mut searched = HashMap::new();
let searches = std::cell::Cell::new(0);
let mut suggest = |short: &str| {
bounded_suggestions(&mut searched, short, |s| {
searches.set(searches.get() + 1);
vec![format!("{s}-hint")]
})
};
assert!(suggest(&"x".repeat(SUGGEST_MAX_NAME_LEN + 1)).is_empty());
assert_eq!(searches.get(), 0);
for i in 0..SUGGEST_MAX_SEARCHES {
assert_eq!(suggest(&format!("nod{i}")), vec![format!("nod{i}-hint")]);
}
assert_eq!(suggest("nod0"), vec!["nod0-hint".to_string()]);
assert!(suggest("pythn").is_empty());
assert_eq!(searches.get(), SUGGEST_MAX_SEARCHES);
}
#[tokio::test]
async fn registry_min_version_keeps_backend_instances_separate() {
use crate::args::ToolArg;
+4 -1
View File
@@ -123,7 +123,10 @@ fn parse_template(config: &Config, tv: &ToolVersion, tmpl: &str) -> eyre::Result
}
let mut ctx = BASE_CONTEXT.clone();
ctx.insert("project_root", &config.project_root);
ctx.insert(
"project_root",
&config.project_root.as_ref().map(|p| p.to_string_lossy()),
);
ctx.insert("opts", &tv.request.options().opts_as_strings());
let mut tera = get_tera(
config
+59 -4
View File
@@ -1146,11 +1146,31 @@ impl PIPXBackend {
return Some(fields[1].to_string());
}
// The name ends at the first `-` whose prefix normalizes to the package.
// Normalizing the prefix anew at every `-` is quadratic in the filename,
// which the index controls, so normalize the stem in one pass (mirroring
// `normalize_package_name`) and stop as soon as it diverges from the
// package: normalization only appends, so no longer prefix can match.
let stem = filename.strip_suffix(".tar.gz")?;
stem.match_indices('-').find_map(|(index, _)| {
(Self::normalize_package_name(&stem[..index]) == normalized_package)
.then(|| stem[index + 1..].to_string())
})
let mut rest = normalized_package.as_str();
let mut separator = false;
for (index, c) in stem.char_indices() {
if c == '-' && rest.is_empty() {
return Some(stem[index + 1..].to_string());
}
if matches!(c, '-' | '_' | '.') {
if !separator {
rest = rest.strip_prefix('-')?;
separator = true;
}
} else {
for c in c.to_lowercase() {
rest = rest.strip_prefix(c)?;
}
separator = false;
}
}
None
}
fn normalize_package_name(package: &str) -> String {
@@ -2283,6 +2303,41 @@ mod tests {
);
}
#[test]
fn splits_sdist_filenames_at_the_end_of_the_package_name() {
for (package, filename, version) in [
("demo", "demo-1.0.tar.gz", Some("1.0")),
("demo-pkg", "demo-pkg-1.0.tar.gz", Some("1.0")),
("demo-pkg", "Demo._-PKG-1.0.tar.gz", Some("1.0")),
("demo", "demo-pkg-1.0.tar.gz", Some("pkg-1.0")),
("demo", "demo--1.0.tar.gz", Some("-1.0")),
("demo", "demo_1.0.tar.gz", None),
("demo", "demos-1.0.tar.gz", None),
("demo-pkg", "demo-1.0.tar.gz", None),
("demo", "demo.tar.gz", None),
] {
assert_eq!(
PIPXBackend::version_from_distribution_filename(package, filename).as_deref(),
version,
"{package} {filename}"
);
}
}
#[test]
fn sdist_filename_parsing_is_linear_in_the_filename() {
// Quadratic work on names this long does not finish in a test run.
for (package, stem) in [
("demo", "x-".repeat(2_000_000)),
("demo-pkg", format!("demo{}", "-".repeat(4_000_000))),
] {
assert_eq!(
PIPXBackend::version_from_distribution_filename(package, &format!("{stem}.tar.gz")),
None
);
}
}
#[test]
fn parses_versions_from_git_ls_remote() {
let output = "\
+10 -2
View File
@@ -115,7 +115,14 @@ impl HookEnv {
_ => None,
})
.collect();
miseprint!("{}", hook_env::build_env_commands(&*shell, &old_patches))?;
// Printed only after the new environment is known to fit, together with
// it, so a refused update leaves the shell as it was rather than half
// undone. These undo commands are not size-checked themselves: they put
// back values the shell held before mise changed them, and a PATH no
// longer than the current one, so they fit wherever the shell's
// environment already does. Refusing them over a variable the user set
// would instead keep mise's old variables in the shell indefinitely.
let old_output = hook_env::build_env_commands(&*shell, &old_patches);
// Use env_with_path_and_split which handles caching internally
let (mut mise_env, env_remove, user_paths, tool_paths, env_watch_files) =
@@ -223,8 +230,9 @@ impl HookEnv {
hook_env::clear_untrusted_config_warning(&mut patches);
}
hook_env::ensure_env_fits(&patches)?;
let output = hook_env::build_env_commands(&*shell, &patches);
miseprint!("{output}")?;
miseprint!("{old_output}{output}")?;
if !untrusted_configs.is_empty()
&& hook_env::should_show_untrusted_config_warning(&untrusted_configs)
{
+5 -1
View File
@@ -627,7 +627,11 @@ impl ServerHandler for MiseServer {
})?;
let config_info = json!({
"config_files": config.config_files.keys().collect::<Vec<_>>(),
"config_files": config
.config_files
.keys()
.map(|p| p.to_string_lossy())
.collect::<Vec<_>>(),
"project_root": config.project_root.as_ref().map(|p| p.to_string_lossy()),
});
+7 -3
View File
@@ -165,8 +165,12 @@ impl TasksInfo {
"name": task.display_name,
"aliases": task.aliases,
"description": task.description,
"source": task.config_source,
"config_sources": task.config_sources(),
"source": task.config_source.to_string_lossy(),
"config_sources": task
.config_sources()
.iter()
.map(|source| source.to_string_lossy())
.collect::<Vec<_>>(),
"depends": task.depends,
"depends_post": task.depends_post,
"daemons": task.daemons,
@@ -192,7 +196,7 @@ impl TasksInfo {
"silent": task.silent,
"tools": task.tools,
"run": task.run(),
"file": task.file,
"file": task.file.as_ref().map(|p| p.to_string_lossy()),
"usage_spec": spec,
});
miseprintln!("{}", serde_json::to_string_pretty(&o)?);
+7 -3
View File
@@ -336,8 +336,12 @@ impl TasksLs {
"name": task.display_name,
"aliases": task.aliases,
"description": task.description,
"source": task.config_source,
"config_sources": task.config_sources(),
"source": task.config_source.to_string_lossy(),
"config_sources": task
.config_sources()
.iter()
.map(|source| source.to_string_lossy())
.collect::<Vec<_>>(),
"depends": task.depends,
"daemons": task.daemons,
"secrets": task.secret_names(),
@@ -359,7 +363,7 @@ impl TasksLs {
"timeout": task.timeout,
"run": task.run(),
"args": task.args,
"file": task.file,
"file": task.file.as_ref().map(|p| p.to_string_lossy()),
}));
}
miseprintln!("{}", serde_json::to_string_pretty(&array_items)?);
+270 -1
View File
@@ -1,9 +1,10 @@
#![allow(unused_assignments)] // Fields are read by miette's derive macros at runtime
use crate::dirs;
use crate::file::display_path;
use miette::{Diagnostic, NamedSource, SourceSpan};
use std::fmt;
use std::path::Path;
use std::path::{Path, PathBuf};
use thiserror::Error;
/// A TOML parsing error with source location information for rich display.
@@ -96,6 +97,145 @@ impl MiseDiagnostic {
}
}
/// Whether a diagnostic about `path` may quote the text that was read from it.
///
/// A project can ship any file mise parses as a symlink, and some are parsed
/// with no trust decision behind them: `.miserc.toml`, `mise.lock` and
/// idiomatic version files always, `mise.toml` in safe mode. The text behind
/// such a link is the user's, not the project's (`/proc/self/environ` has no
/// newline, so its failing "line" is the whole environment), while stderr is
/// often a job log the project's author can read. Links inside the global and
/// system config directories are the user's own; those are told apart by
/// directory because the trust lookups need settings, which are still loading
/// while `.miserc.toml` is parsed.
pub(crate) fn may_quote(path: &Path) -> bool {
may_quote_below(path, path.parent().unwrap_or(path))
}
/// [`may_quote`] for a file found under `root`, such as a task file found in a
/// task directory of the project at `root`: every directory between `root` and
/// the file is checked too, whatever its name.
///
/// Config discovery starts in the working directory and its ancestors, and a
/// configured filename may name any path below them
/// (`override_config_filenames = ["custom/private.toml"]`), so the check also
/// reaches back to the deepest directory the file shares with the working
/// directory. The working directory comes from the OS already resolved, so the
/// directories it shares hold no link the project put there.
pub(crate) fn may_quote_below(path: &Path, root: &Path) -> bool {
path.starts_with(*dirs::CONFIG)
|| path.starts_with(*dirs::SYSTEM_CONFIG)
|| !reached_through_link(path, &discovery_root(path, root))
}
/// The shallower of `root` and the deepest ancestor `path` shares with the
/// working directory.
fn discovery_root(path: &Path, root: &Path) -> PathBuf {
let Some(cwd) = dirs::CWD.as_deref() else {
return root.to_path_buf();
};
match path.ancestors().find(|dir| cwd.starts_with(dir)) {
Some(shared) if root.starts_with(shared) => shared.to_path_buf(),
_ => root.to_path_buf(),
}
}
/// Whether `path`, a directory between `root` and it, or a config directory
/// mise descended into to find it (`.config`, `.mise`, `mise`, `conf.d` and its
/// folders, task directories), is a symlink. `root` and the directories above
/// it are not checked: the user chose to be there, and macOS keeps `/tmp` and
/// `/var` behind links.
fn reached_through_link(path: &Path, root: &Path) -> bool {
let is_layout_dir = |dir: &Path| {
let name = dir.file_name().and_then(|n| n.to_str());
matches!(
name,
Some(".config" | ".mise" | "mise" | "conf.d" | "tasks" | "mise-tasks" | ".mise-tasks")
) || dir
.parent()
.and_then(Path::file_name)
.is_some_and(|n| n == "conf.d")
};
let below_root = |dir: &Path| dir != root && dir.starts_with(root);
let mut current = path;
loop {
if current.is_symlink() {
return true;
}
match current.parent() {
Some(parent) if below_root(parent) || is_layout_dir(parent) => current = parent,
_ => return false,
}
}
}
/// Whether a project version file (`.tool-versions`, `.node-version`, ...)
/// is reached through a symlink, as [`may_quote`] decides, to a file of
/// another name. Such a file is not read: a version file links to another
/// version file, while a link to `~/.netrc` or a token file would put that
/// file's text into tool names and versions, which mise prints in warnings,
/// `mise ls` and install errors, and puts into download URLs.
///
/// The user's own files are exempt: global config such as `~/.tool-versions`,
/// and any version file directly in the home directory, which a dotfiles
/// manager may well link to a file of another name. Unlike [`may_quote`], this
/// runs after settings load, so it can ask which files are global.
pub(crate) fn links_to_another_name(path: &Path) -> bool {
if may_quote(path)
|| path.parent() == Some(&**dirs::HOME)
|| crate::config::is_global_config(path)
{
return false;
}
let target = path.canonicalize().ok();
target.as_deref().and_then(Path::file_name) != path.file_name()
}
/// The warning for a version file [`links_to_another_name`] skips.
pub(crate) fn warn_linked_version_file(path: &Path) {
warn_once!(
"not reading {}: it is a symlink to a file of another name, and mise reads a linked version file only when the target has the same name",
display_path(path)
);
}
/// The 1-based line and column of `offset`, for a diagnostic that cannot show
/// the line itself.
fn position(source: &str, offset: usize) -> (usize, usize) {
let before = source.get(..offset).unwrap_or("");
let line = before.matches('\n').count() + 1;
let column = before.rsplit('\n').next().unwrap_or("").chars().count() + 1;
(line, column)
}
/// For callers that report a `toml::de::Error` as is. Its `Display` quotes the
/// failing line and its message can quote a value, so only the position is kept
/// unless [`may_quote`] allows `path` to be quoted.
pub(crate) fn hide_linked_source(err: toml::de::Error, source: &str, path: &Path) -> eyre::Report {
hide_linked_source_below(err, source, path, path.parent().unwrap_or(path))
}
/// [`hide_linked_source`] for a file found under `root`, see [`may_quote_below`].
pub(crate) fn hide_linked_source_below(
err: toml::de::Error,
source: &str,
path: &Path,
root: &Path,
) -> eyre::Report {
if may_quote_below(path, root) {
return err.into();
}
let (line, column) = position(source, err.span().map_or(0, |r| r.start));
// Not the parser's message either: a value error quotes the value
// (`invalid type: string "…"`).
eyre::eyre!("TOML parse error at line {line}, column {column}: {LINKED_DETAILS_HIDDEN}")
}
/// What a parse error about a linked file says in place of the parser's
/// message, which can quote the file's text.
const LINKED_DETAILS_HIDDEN: &str =
"details hidden because the file is reached through a link out of the project";
/// Create an eyre error from a toml::de::Error with rich source context.
pub(crate) fn toml_parse_error(err: &toml::de::Error, source: &str, path: &Path) -> eyre::Report {
let message = err.message().to_string();
@@ -106,6 +246,14 @@ pub(crate) fn toml_parse_error(err: &toml::de::Error, source: &str, path: &Path)
.map(|r| SourceSpan::from((r.start, r.end.saturating_sub(r.start))))
.unwrap_or_else(|| SourceSpan::from((0, 0)));
if !may_quote(path) {
let (line, column) = position(source, span.offset());
return eyre::eyre!(
"Invalid TOML in config file {}:{line}:{column}: {LINKED_DETAILS_HIDDEN}",
display_path(path)
);
}
let help = backslash_help(source, &span, &message);
let diagnostic = TomlParseError {
src: NamedSource::new(display_path(path), source.to_string()),
@@ -116,3 +264,124 @@ pub(crate) fn toml_parse_error(err: &toml::de::Error, source: &str, path: &Path)
eyre::Report::new(MiseDiagnostic::new(diagnostic))
}
#[cfg(test)]
#[cfg(unix)]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
const BODY: &str = "[env]\ntoken s3cr3t\n";
/// Everything the two reports would print about `BODY` read through `path`.
fn reports(path: &Path) -> String {
let err = toml::from_str::<toml::Value>(BODY).unwrap_err();
let rich = toml_parse_error(&err, BODY, path);
let rich = match rich.downcast_ref::<MiseDiagnostic>() {
Some(diagnostic) => diagnostic.render().to_string(),
None => format!("{rich:?}"),
};
format!("{rich}\n{:?}", hide_linked_source(err, BODY, path))
}
/// A value error quotes the value, so a linked file's parser message is
/// withheld too, not only its source excerpt.
#[test]
fn hides_the_value_a_linked_file_s_error_would_quote() {
const VALUE: &str = "silent = \"s3cr3t\"\n";
let tmp = tempfile::tempdir().unwrap();
let tmp = tmp.path().canonicalize().unwrap();
let (home, project) = (tmp.join("home"), tmp.join("project"));
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&project).unwrap();
std::fs::write(home.join("private.toml"), VALUE).unwrap();
symlink(home.join("private.toml"), project.join("mise.toml")).unwrap();
let path = project.join("mise.toml");
let err = toml::from_str::<std::collections::HashMap<String, bool>>(VALUE).unwrap_err();
assert!(err.message().contains("s3cr3t"), "{}", err.message());
let rich = format!("{:?}", toml_parse_error(&err, VALUE, &path));
let plain = format!("{:?}", hide_linked_source(err, VALUE, &path));
for report in [rich, plain] {
assert!(!report.contains("s3cr3t"), "{report}");
assert!(report.contains(LINKED_DETAILS_HIDDEN), "{report}");
}
}
#[test]
fn quotes_a_file_but_not_what_a_link_leads_to() {
let tmp = tempfile::tempdir().unwrap();
// resolved, as the working directory mise starts from is: macOS keeps
// its temp dirs behind a link
let tmp = tmp.path().canonicalize().unwrap();
let tmp = tmp.as_path();
let (home, project) = (tmp.join("home"), tmp.join("project"));
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&project).unwrap();
std::fs::write(home.join("mise.toml"), BODY).unwrap();
assert_eq!(
reports(&home.join("mise.toml")).matches("s3cr3t").count(),
2
);
symlink(home.join("mise.toml"), project.join("mise.toml")).unwrap();
let linked_file = reports(&project.join("mise.toml"));
assert!(!linked_file.contains("s3cr3t"), "{linked_file}");
assert!(linked_file.contains("mise.toml:2:7: "), "{linked_file}");
assert!(linked_file.contains("line 2, column 7: "), "{linked_file}");
symlink(&home, project.join(".config")).unwrap();
let linked_dir = reports(&project.join(".config/mise.toml"));
assert!(!linked_dir.contains("s3cr3t"), "{linked_dir}");
// The directory discovery starts from, and those above it, are not
// checked: a project reached through a link of the user's own is
// still quoted.
let via = tmp.join("via");
symlink(&home, &via).unwrap();
assert!(!reached_through_link(&via.join("mise.toml"), &via));
// Below a known root every directory counts, whatever its name, but the
// root itself may sit behind a link.
let tasks = project.join("mise-tasks");
std::fs::create_dir_all(&tasks).unwrap();
symlink(&home, tasks.join("group")).unwrap();
assert!(!may_quote_below(&tasks.join("group/mise.toml"), &project));
// A configured filename can name a path below the discovery directory
// (`custom/private.toml`), whose directories count too.
symlink(&home, project.join("custom")).unwrap();
let custom = project.join("custom/mise.toml");
assert!(reached_through_link(&custom, &project));
// Even when the caller only knows the file's own directory, the check
// reaches back to the directory the file shares with the working one.
assert!(!may_quote_below(&custom, &project.join("custom")));
assert!(!may_quote(&custom));
}
#[test]
fn a_version_file_linked_to_another_name_is_not_read() {
let tmp = tempfile::tempdir().unwrap();
let tmp = tmp.path();
std::fs::write(tmp.join("netrc"), "password s3cr3t\n").unwrap();
std::fs::create_dir(tmp.join("shared")).unwrap();
std::fs::write(tmp.join("shared/.tool-versions"), "node 22\n").unwrap();
let project = tmp.join("project");
std::fs::create_dir(&project).unwrap();
std::fs::write(project.join(".node-version"), "22\n").unwrap();
symlink(tmp.join("netrc"), project.join(".tool-versions")).unwrap();
symlink(tmp.join("shared/.tool-versions"), project.join("ok")).unwrap();
std::fs::create_dir(project.join("sub")).unwrap();
symlink(
tmp.join("shared/.tool-versions"),
project.join("sub/.tool-versions"),
)
.unwrap();
assert!(links_to_another_name(&project.join(".tool-versions")));
assert!(links_to_another_name(&project.join("ok")));
assert!(!links_to_another_name(&project.join("sub/.tool-versions")));
assert!(!links_to_another_name(&project.join(".node-version")));
}
}
@@ -36,6 +36,11 @@ impl IdiomaticVersionFile {
if is_global_config(path) {
return false;
}
// Before any plugin reads it whole: each reads the file unbounded.
// `parse` refuses the oversized file once trust is settled.
if file::ensure_config_file_size(path).is_err() {
return true;
}
for plugin in plugins {
if let Ok(versions) = plugin.parse_idiomatic_file_with_options(path).await
&& versions
@@ -51,6 +56,11 @@ impl IdiomaticVersionFile {
pub(crate) async fn parse(path: PathBuf, plugins: BackendList) -> Result<Self> {
let source = ToolSource::IdiomaticVersionFile(path.clone());
let mut tools = ToolRequestSet::new();
file::ensure_config_file_size(&path)?;
if super::diagnostic::links_to_another_name(&path) {
super::diagnostic::warn_linked_version_file(&path);
return Ok(Self { tools, path });
}
for plugin in plugins {
match plugin.parse_idiomatic_file_with_options(&path).await {
@@ -173,7 +183,7 @@ impl ConfigFile for IdiomaticVersionFile {
#[cfg_attr(coverage_nightly, coverage(off))]
fn dump(&self) -> Result<String> {
file::read_to_string(&self.path)
file::read_config_to_string(&self.path)
}
fn source(&self) -> ToolSource {
@@ -213,6 +223,7 @@ mod tests {
ba: Arc<BackendArg>,
fail: bool,
version: Option<String>,
read: std::sync::atomic::AtomicBool,
}
impl MockBackend {
@@ -228,6 +239,7 @@ mod tests {
ba: Arc::new(ba),
fail,
version,
read: Default::default(),
}
}
}
@@ -251,6 +263,7 @@ mod tests {
}
async fn parse_idiomatic_file(&self, _path: &Path) -> Result<Vec<String>> {
self.read.store(true, std::sync::atomic::Ordering::SeqCst);
if self.fail {
eyre::bail!("mock error");
}
@@ -262,6 +275,26 @@ mod tests {
}
}
/// The trust check runs before `parse`, and plugins read the whole file,
/// so an oversized one must not reach them there either.
#[tokio::test]
async fn test_trust_check_does_not_read_an_oversized_file() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(".node-version");
std::fs::File::create(&path)
.unwrap()
.set_len(file::CONFIG_FILE_LIMIT + 1)
.unwrap();
let backend = Arc::new(MockBackend::new("node", false, Some("20".to_string())));
let plugins: BackendList = vec![backend.clone()];
assert!(IdiomaticVersionFile::path_requires_trust(&path, &plugins).await);
assert!(
!backend.read.load(std::sync::atomic::Ordering::SeqCst),
"a plugin read the oversized file"
);
}
#[tokio::test]
async fn test_idiomatic_parse_error_propagation() {
let _config = Config::get().await.unwrap();
@@ -56,7 +56,7 @@ where
impl PackageJsonData {
fn parse(path: &Path) -> Result<Self> {
let contents = file::read_to_string(path)?;
let contents = file::read_config_to_string(path)?;
// serde_json rejects a leading byte-order mark outright, which would fail the whole file
// rather than just the version it declares.
let pkg: PackageJsonData = serde_json::from_str(file::strip_utf8_bom(&contents))?;
+52 -5
View File
@@ -500,7 +500,7 @@ impl MiseToml {
let mut context = BASE_CONTEXT.clone();
context.insert(
"config_root",
config_root::config_root(path).to_str().unwrap(),
&config_root::config_root(path).to_string_lossy(),
);
context.insert("config_source", &config_root::config_source(path));
let mut rf = Self {
@@ -513,7 +513,7 @@ impl MiseToml {
}
pub fn from_file(path: &Path) -> eyre::Result<Self> {
let body = file::read_to_string(path)?;
let body = file::read_config_to_string(path)?;
Self::from_str(&body, path)
}
@@ -717,7 +717,7 @@ impl MiseToml {
rf.context = BASE_CONTEXT.clone();
rf.context.insert(
"config_root",
config_root::config_root(path).to_str().unwrap(),
&config_root::config_root(path).to_string_lossy(),
);
rf.context
.insert("config_source", &config_root::config_source(path));
@@ -749,7 +749,7 @@ impl MiseToml {
/// [`Self::is_trust_exempt`]). Returns false for unreadable files and for
/// non-mise.toml files (e.g. `.tool-versions`), which have their own flow.
pub(crate) fn path_is_trust_exempt(path: &Path) -> bool {
file::read_to_string(path).is_ok_and(|body| Self::is_trust_exempt(&body, path))
file::read_config_to_string(path).is_ok_and(|body| Self::is_trust_exempt(&body, path))
}
/// Whether this config body can be loaded without trusting the file.
@@ -779,7 +779,22 @@ impl MiseToml {
.lock()
.unwrap()
.get_or_try_init(|| {
let body = file::read_to_string(&self.path).unwrap_or_default();
// A missing file is a new config. Any other failure, such as the
// file having grown past the size limit, must not turn into an
// empty document that a later `save` would write over the file.
let body = match file::read_config_to_string(&self.path) {
Ok(body) => body,
Err(err)
if err.chain().any(|cause| {
cause
.downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound)
}) =>
{
String::new()
}
Err(err) => return Err(err),
};
Ok(body.parse()?)
})
.cloned()
@@ -2906,6 +2921,38 @@ mod tests {
});
}
/// The editable document is read lazily. If the file can no longer be read
/// by then (here it has grown past the size limit), an edit must fail
/// rather than start from an empty document that `save` writes back.
#[tokio::test]
async fn test_edit_fails_when_the_file_can_no_longer_be_read() {
let _config = Config::get().await.unwrap();
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("mise.toml");
file::write(&p, "[tools]\nnode = \"22\"\n").unwrap();
let mut cf = MiseToml::from_file(&p).unwrap();
std::fs::OpenOptions::new()
.write(true)
.open(&p)
.unwrap()
.set_len(file::CONFIG_FILE_LIMIT + 1)
.unwrap();
let err = cf
.set_backend_alias(&"node".into(), "npm:node")
.unwrap_err();
assert!(format!("{err:#}").contains("larger than 16 MiB"), "{err:#}");
assert!(cf.save().is_err());
assert_eq!(
std::fs::metadata(&p).unwrap().len(),
file::CONFIG_FILE_LIMIT + 1
);
// A config that does not exist yet still starts from an empty document.
let missing = MiseToml::init(&tmp.path().join("new.toml"));
assert_eq!(missing.dump().unwrap(), "");
}
#[tokio::test]
async fn test_env_directive_written_order() {
// Directives inside `[env]._` are emitted in the order they are written,
+1 -1
View File
@@ -1353,7 +1353,7 @@ fn hashed_path_filename(path: &Path) -> String {
let canonicalized_path = path.canonicalize().unwrap();
let hash = hash_to_str(&canonicalized_path);
let trunc_str = |s: &OsStr| {
let mut s = s.to_str().unwrap().to_string();
let mut s = s.to_string_lossy().to_string();
s = s.chars().take(20).collect();
s
};
+19 -3
View File
@@ -29,6 +29,8 @@ pub(crate) struct ToolVersions {
pre: String,
plugins: Mutex<IndexMap<BackendArg, ToolVersionPlugin>>,
tools: Mutex<ToolRequestSet>,
/// Skipped because it links to a file of another name; never written back.
unread: bool,
}
#[derive(Debug, Clone)]
@@ -41,7 +43,7 @@ struct ToolVersionPlugin {
impl ToolVersions {
pub(crate) fn init(filename: &Path) -> ToolVersions {
let mut context = BASE_CONTEXT.clone();
context.insert("config_root", filename.parent().unwrap().to_str().unwrap());
context.insert("config_root", &filename.parent().unwrap().to_string_lossy());
context.insert(
"config_source",
&crate::config::config_file::config_root::config_source(filename),
@@ -56,14 +58,21 @@ impl ToolVersions {
pub(crate) fn from_file(path: &Path) -> Result<Self> {
trace!("parsing tool-versions: {}", path.display());
let body = file::read_to_string(path)?;
if super::diagnostic::links_to_another_name(path) {
super::diagnostic::warn_linked_version_file(path);
return Ok(Self {
unread: true,
..Self::init(path)
});
}
let body = file::read_config_to_string(path)?;
// The first whitespace-separated token on a line is the tool name, so a leading
// byte-order mark becomes part of it and the entry silently resolves to nothing.
Self::parse_str(file::strip_utf8_bom(&body), path.to_path_buf())
}
pub(crate) fn path_requires_trust(path: &Path) -> bool {
match file::read_to_string(path) {
match file::read_config_to_string(path) {
Ok(body) => body_requires_trust(file::strip_utf8_bom(&body)),
Err(_) => true,
}
@@ -191,6 +200,12 @@ impl ConfigFile for ToolVersions {
}
fn save(&self) -> Result<()> {
if self.unread {
eyre::bail!(
"not writing {}: it is a symlink to a file of another name, which mise did not read",
display_path(&self.path)
);
}
let s = self.dump()?;
file::write_atomic(&self.path, s)
}
@@ -248,6 +263,7 @@ impl Clone for ToolVersions {
pre: self.pre.clone(),
plugins: Mutex::new(self.plugins.lock().unwrap().clone()),
tools: Mutex::new(self.tools.lock().unwrap().clone()),
unread: self.unread,
}
}
}
+2 -2
View File
@@ -289,8 +289,8 @@ impl EnvResults {
// &source
// );
let config_root = crate::config::config_file::config_root::config_root(&source);
ctx.insert("cwd", &*dirs::CWD);
ctx.insert("config_root", &config_root);
ctx.insert("cwd", &dirs::CWD.as_ref().map(|p| p.to_string_lossy()));
ctx.insert("config_root", &config_root.to_string_lossy());
ctx.insert(
"config_source",
&crate::config::config_file::config_root::config_source(&source),
+9 -8
View File
@@ -191,19 +191,19 @@ fn render_miserc_template(
} else {
context.insert("env", &EnvMap::new());
}
context.insert("config_root", config_root);
context.insert("config_root", &config_root.to_string_lossy());
// Use the invocation directory, not the current one: the global ignore list
// is rendered lazily, possibly after `-C` changed directory, and must match
// what startup rendered.
match invocation_cwd() {
Some(dir) => context.insert("cwd", dir),
Some(dir) => context.insert("cwd", &dir.to_string_lossy()),
None => debug!("miserc template: could not determine cwd, `cwd` will be unavailable"),
};
context.insert("xdg_cache_home", &*env::XDG_CACHE_HOME);
context.insert("xdg_config_home", &*env::XDG_CONFIG_HOME);
context.insert("xdg_data_home", &*env::XDG_DATA_HOME);
context.insert("xdg_state_home", &*env::XDG_STATE_HOME);
context.insert("mise_config_dir", &*env::MISE_CONFIG_DIR);
context.insert("xdg_cache_home", &env::XDG_CACHE_HOME.to_string_lossy());
context.insert("xdg_config_home", &env::XDG_CONFIG_HOME.to_string_lossy());
context.insert("xdg_data_home", &env::XDG_DATA_HOME.to_string_lossy());
context.insert("xdg_state_home", &env::XDG_STATE_HOME.to_string_lossy());
context.insert("mise_config_dir", &env::MISE_CONFIG_DIR.to_string_lossy());
context.insert("system_brew_prefix", &env::system_brew_prefix());
let rendered = if global {
render_str_with_env(tera, content, &context)
@@ -434,7 +434,8 @@ fn load_miserc_files(files: Vec<PathBuf>) -> Result<MisercSettings> {
let mut tera: Option<TeraEngine> = None;
for path in files.into_iter().rev() {
if let Ok(content) = file::read_to_string(&path) {
file::ensure_config_file_size(&path)?;
if let Ok(content) = file::read_config_to_string(&path) {
let config_root = path.parent().unwrap_or(Path::new("."));
let content = render_miserc_template(&mut tera, &content, config_root);
let mut settings = toml::from_str::<MisercSettings>(&content)
+122 -55
View File
@@ -2125,7 +2125,7 @@ pub fn monorepo_lockfile_dir(root: &Path) -> PathBuf {
// Prefer the base config that declares the root (read statically, nothing
// evaluated); otherwise the first base config.
let declares_root = |path: &PathBuf| {
file::read_to_string(path)
file::read_config_to_string(path)
.ok()
.and_then(|body| MiseToml::for_monorepo_inspection(&body, path).ok())
.is_some_and(|cf| cf.monorepo_root() == Some(true))
@@ -2202,7 +2202,7 @@ pub(crate) fn monorepo_lockfile_root_from_dir(dir: &Path) -> Option<PathBuf> {
// is decoded without a trust check: nothing in it is evaluated,
// and a stub run must not prompt for or record trust. An
// unreadable config just cannot declare a root.
let Ok(body) = file::read_to_string(&path) else {
let Ok(body) = file::read_config_to_string(&path) else {
continue;
};
if let Ok(cf) = MiseToml::for_monorepo_inspection(&body, &path) {
@@ -2353,7 +2353,7 @@ async fn load_bootstrap_config_maps(config: &Config) -> Result<Vec<BootstrapConf
);
let mut tera_ctx = config.tera_ctx.clone();
tera_ctx.insert("vars", &vars);
tera_ctx.insert("config_root", &root);
tera_ctx.insert("config_root", &root.to_string_lossy());
maps.push(BootstrapConfigMap {
config_files,
tera_ctx,
@@ -2471,13 +2471,15 @@ fn idiomatic_version_file_disabled(
/// `Settings::get()` is a process-wide snapshot rooted at the invocation directory. Resolving
/// from the monorepo config root keeps normal environment, hierarchy, global/system, safe-mode,
/// and `--no-config` behavior without maintaining a second precedence implementation.
/// Like the main settings load, it skips the `[settings]` of untrusted project configs, so
/// a subproject cannot turn on idiomatic version files for itself before it is trusted.
async fn idiomatic_filenames_for_root(
root: &Path,
default_idiomatic_filenames: &BTreeMap<String, Vec<String>>,
) -> Result<BTreeMap<String, Vec<String>>> {
let rooted = settings::IdiomaticVersionFileSettings::resolve_from(
root,
settings::SettingsLoadPolicy::HIERARCHY,
settings::SettingsLoadPolicy::TRUSTED_HIERARCHY,
)?;
if rooted == settings::IdiomaticVersionFileSettings::current() {
return Ok(default_idiomatic_filenames.clone());
@@ -2488,43 +2490,61 @@ async fn idiomatic_filenames_for_root(
/// Shared (non-`.local`) config filenames for one directory, lowest precedence first.
/// `DEFAULT_CONFIG_FILENAMES` places the environment files and then the `.local`
/// overrides above these.
static SHARED_CONFIG_FILENAMES: Lazy<IndexSet<&'static str>> = Lazy::new(|| {
let mut paths: IndexSet<&'static str> = IndexSet::new();
if let Some(o) = &*env::MISE_OVERRIDE_TOOL_VERSIONS_FILENAMES {
paths.extend(o.iter().map(|s| s.as_str()));
static SHARED_CONFIG_FILENAMES: Lazy<IndexSet<String>> = Lazy::new(|| {
shared_config_filenames(
env::MISE_OVERRIDE_TOOL_VERSIONS_FILENAMES.as_ref(),
&env::MISE_OVERRIDE_CONFIG_FILENAMES,
)
});
/// `SHARED_CONFIG_FILENAMES` with the override lists injected, so tests can
/// cover them without touching global state.
///
/// Every entry is expanded as a glob pattern in each directory on the way up to
/// the root. The configured names are filenames, not patterns, and a project
/// `.miserc.toml` sets the overrides before anything is trusted: unescaped, `**`
/// would walk the whole filesystem and `*` would load every file in every
/// ancestor directory as a config.
fn shared_config_filenames(
override_tool_versions_filenames: Option<&IndexSet<String>>,
override_config_filenames: &IndexSet<String>,
) -> IndexSet<String> {
let escape = |s: &String| glob::Pattern::escape(s);
let mut paths: IndexSet<String> = IndexSet::new();
if let Some(o) = override_tool_versions_filenames {
paths.extend(o.iter().map(escape));
} else {
paths.extend([
".tool-versions",
&*env::MISE_DEFAULT_TOOL_VERSIONS_FILENAME, // .tool-versions
".tool-versions".to_string(),
escape(&env::MISE_DEFAULT_TOOL_VERSIONS_FILENAME), // .tool-versions
]);
}
if !env::MISE_OVERRIDE_CONFIG_FILENAMES.is_empty() {
paths.extend(
env::MISE_OVERRIDE_CONFIG_FILENAMES
.iter()
.map(|s| s.as_str()),
)
if !override_config_filenames.is_empty() {
paths.extend(override_config_filenames.iter().map(escape))
} else {
paths.extend([
".config/mise/conf.d/*.toml",
".config/mise/conf.d/*/mise.toml",
".config/mise/config.toml",
".config/mise/mise.toml",
".config/mise.toml",
".mise/conf.d/*.toml",
".mise/conf.d/*/mise.toml",
".mise/config.toml",
"mise/conf.d/*.toml",
"mise/conf.d/*/mise.toml",
"mise/config.toml",
"mise.toml",
&*env::MISE_DEFAULT_CONFIG_FILENAME, // mise.toml
".mise.toml",
]);
paths.extend(
[
".config/mise/conf.d/*.toml",
".config/mise/conf.d/*/mise.toml",
".config/mise/config.toml",
".config/mise/mise.toml",
".config/mise.toml",
".mise/conf.d/*.toml",
".mise/conf.d/*/mise.toml",
".mise/config.toml",
"mise/conf.d/*.toml",
"mise/conf.d/*/mise.toml",
"mise/config.toml",
"mise.toml",
&escape(&env::MISE_DEFAULT_CONFIG_FILENAME), // mise.toml
".mise.toml",
]
.map(String::from),
);
}
paths
});
}
/// `.local` override filenames for one directory, lowest precedence first. They
/// outrank every shared and environment file in the same directory, except the
/// environment-specific `.local` files. `MISE_OVERRIDE_CONFIG_FILENAMES` replaces
@@ -2574,7 +2594,7 @@ fn env_config_patterns_with_conf_d(env: &str, local: bool, env_conf_d: bool) ->
pub static DEFAULT_CONFIG_FILENAMES: Lazy<Vec<String>> = Lazy::new(|| {
layer_config_filenames(
SHARED_CONFIG_FILENAMES.iter().copied(),
SHARED_CONFIG_FILENAMES.iter().map(String::as_str),
LOCAL_OVERRIDE_CONFIG_FILENAMES.iter().copied(),
&env::MISE_ENV_WITH_AUTO,
)
@@ -2673,7 +2693,9 @@ pub(crate) fn glob(dir: &Path, pattern: &str) -> Result<Vec<PathBuf>> {
if let Some(glob) = results.get(&key) {
return Ok(glob.clone());
}
let full_pattern = dir.join(pattern);
// `dir` is a literal directory, so escape any glob metacharacters in it:
// only `pattern` is meant to match.
let full_pattern = Path::new(&glob::Pattern::escape(&dir.to_string_lossy())).join(pattern);
let full_pattern = full_pattern.to_string_lossy();
let paths = if glob_parent_exists(dir, pattern) {
glob::glob(full_pattern.as_ref())?
@@ -2716,6 +2738,11 @@ fn config_glob(dir: &Path, pattern: &str) -> Vec<PathBuf> {
glob(dir, pattern)
.unwrap_or_default()
.into_iter()
// Only regular files are config. A checkout can ship a config name
// that links to a device or is a pipe, and reading one of those whole
// never ends. `is_file` follows symlinks, so a link to a real file
// still loads.
.filter(|path| path.is_file())
.filter(|path| {
if path.parent().is_some_and(is_conf_d_file) {
return is_conf_d_folder_file(path);
@@ -2856,7 +2883,8 @@ pub fn config_paths_in_dir_with_filenames(dir: &Path, filenames: &[String]) -> V
load_config_glob(dir, f).into_iter().rev().collect()
} else {
let path = dir.join(f);
if path.exists() { vec![path] } else { vec![] }
// Same regular-file rule as `config_glob`.
if path.is_file() { vec![path] } else { vec![] }
}
})
.collect();
@@ -3492,7 +3520,14 @@ fn resolved_task_file(task: &Task) -> Option<PathBuf> {
// may belong to a different config hierarchy and produce a false
// source match.
let mut tera_ctx = ::tera::Context::new();
tera_ctx.insert("config_root", &task.config_root.clone().unwrap_or_default());
tera_ctx.insert(
"config_root",
&task
.config_root
.clone()
.unwrap_or_default()
.to_string_lossy(),
);
match render_str(&mut tera, &file_str, &tera_ctx) {
Ok(rendered) => rendered,
Err(err) => {
@@ -4448,7 +4483,10 @@ pub fn command_wrapper_source(config_files: &ConfigMap, name: &str) -> Result<Op
/// Adds the template variables of a value declared in the config file at `path`, as its other
/// values get them: `config_root` and `config_source`.
pub fn insert_config_file_template_vars(ctx: &mut tera::Context, path: &Path) {
ctx.insert("config_root", &config_file::config_root::config_root(path));
ctx.insert(
"config_root",
&config_file::config_root::config_root(path).to_string_lossy(),
);
ctx.insert(
"config_source",
&config_file::config_root::config_source(path),
@@ -4644,7 +4682,7 @@ fn bootstrap_dry_run_var(
context.insert("vars", vars);
context.insert(
"config_root",
&config_file::config_root::config_root(source),
&config_file::config_root::config_root(source).to_string_lossy(),
);
context.insert(
"config_source",
@@ -5106,7 +5144,7 @@ fn render_task_input_entries(
tera_ctx: &tera::Context,
) -> Result<Vec<String>> {
let mut tera_ctx = tera_ctx.clone();
tera_ctx.insert("config_root", config_root);
tera_ctx.insert("config_root", &config_root.to_string_lossy());
let mut tera = crate::tera::get_tera(Some(config_root));
entries
.iter()
@@ -6059,7 +6097,7 @@ fn has_config_file_with_filenames(dir: &Path, filenames: &[String]) -> bool {
if is_glob_pattern(f) {
!load_config_glob(dir, f).is_empty()
} else {
dir.join(f).exists()
dir.join(f).is_file()
}
})
}
@@ -6816,8 +6854,22 @@ pub(crate) fn is_task_path_excluded(path: &Path, excludes: &[PathBuf]) -> bool {
.any(|exclude| file::path_starts_with_resolved(path, exclude))
}
/// Most entries one task directory walk may visit. Links are followed, and links
/// that keep leading forward into the same directories (or one into `/sys`)
/// give a small checkout an exponential number of paths, which walkdir's loop
/// check never sees because no link leads back to its own parent.
const TASK_DIR_ENTRY_LIMIT: usize = 100_000;
fn collect_task_files(root: &Path, excludes: &[PathBuf]) -> Result<Vec<PathBuf>> {
WalkDir::new(root)
collect_task_files_with_limit(root, excludes, TASK_DIR_ENTRY_LIMIT)
}
fn collect_task_files_with_limit(
root: &Path,
excludes: &[PathBuf],
limit: usize,
) -> Result<Vec<PathBuf>> {
let walk = WalkDir::new(root)
.follow_links(true)
.into_iter()
// skip hidden directories (if the root is hidden that's ok)
@@ -6827,17 +6879,24 @@ fn collect_task_files(root: &Path, excludes: &[PathBuf]) -> Result<Vec<PathBuf>>
&& !excludes
.iter()
.any(|exclude| file::path_starts_with_resolved(entry.path(), exclude)))
})
.filter_map(|entry| match entry {
Ok(entry) if entry.file_type().is_file() => Some(Ok(entry.path().to_path_buf())),
Ok(_) => None,
});
let mut files = vec![];
for (visited, entry) in walk.enumerate() {
if visited >= limit {
bail!(
"task directory {} has more than {limit} entries; check it for symlinks that reach the same directories many times",
display_path(root)
);
}
match entry {
Ok(entry) if entry.file_type().is_file() => files.push(entry.path().to_path_buf()),
Ok(_) => {}
Err(err)
if err
.io_error()
.is_some_and(|err| err.kind() == std::io::ErrorKind::NotFound) =>
{
debug!("skipping missing task entry: {err}");
None
}
// Losing nothing by skipping: walkdir reports this only when a link resolves to a
// directory already on the walk stack, so everything under it has been visited
@@ -6847,12 +6906,11 @@ fn collect_task_files(root: &Path, excludes: &[PathBuf]) -> Result<Vec<PathBuf>>
// involve no file at all.
Err(err) if err.loop_ancestor().is_some() => {
debug!("skipping task path that links back into itself: {err}");
None
}
Err(err) => Some(Err(err)),
})
.try_collect::<_, Vec<PathBuf>, _>()
.map_err(Into::into)
Err(err) => return Err(err.into()),
}
}
Ok(files)
}
async fn load_tasks_includes(
@@ -7878,6 +7936,11 @@ async fn load_task_sources_from_configs(
}
fn trust_check_task_include(path: &Path, require_trust: bool) -> Result<()> {
// A TOML task file over the size limit is refused by the loader anyway;
// refuse it here, before the trust check below reads it whole.
if path.extension().is_some_and(|e| e == "toml") {
file::ensure_config_file_size(path)?;
}
if require_trust && !is_global_task_include_path(path) && task_include_requires_trust(path) {
trust_check(path)?;
}
@@ -7900,7 +7963,7 @@ fn remote_task_file_requires_trust(path: &Path) -> bool {
if Settings::try_get().is_ok_and(|settings| settings.paranoid.config()) {
return true;
}
let Ok(body) = file::read_to_string(path) else {
let Ok(body) = file::read_config_to_string(path) else {
return true;
};
crate::task::header_has_decoded_template(&body) || crate::task::header_declares_secrets(&body)
@@ -7919,7 +7982,8 @@ fn task_include_requires_trust(path: &Path) -> bool {
{
return true;
}
let Ok(body) = file::read_to_string(path) else {
// Bounded: a file too large to scan needs trust like one that cannot be read.
let Ok(body) = file::read_config_to_string(path) else {
// can't read it — fall back to requiring trust
return true;
};
@@ -7939,13 +8003,16 @@ async fn load_task_file(
monorepo_cf: Option<&Arc<dyn ConfigFile>>,
mut rendered_file_tasks: Option<&mut RenderedTaskCache>,
) -> Result<Vec<Task>> {
let raw = file::read_to_string_async(path).await?;
let raw = file::read_config_to_string_async(path).await?;
if crate::secrets::may_name_secrets(&raw)
&& let Ok(table) = toml::from_str::<toml::Table>(&raw)
{
crate::secrets::check_toml_locations(crate::secrets::TomlShape::TaskInclude, &table, path)?;
}
let mut tasks = toml::from_str::<Tasks>(&raw)
.map_err(|err| {
config_file::diagnostic::hide_linked_source_below(err, &raw, path, config_root)
})
.wrap_err_with(|| format!("Error parsing task file: {}", display_path(path)))?
.0;
for (name, task) in &mut tasks {
+11 -13
View File
@@ -54,10 +54,6 @@ impl SettingsLoadPolicy {
source: SettingsSourcePolicy::EnvironmentOnly,
trust: SettingsTrustPolicy::AsDiscovered,
};
pub(crate) const HIERARCHY: Self = Self {
source: SettingsSourcePolicy::Hierarchy,
trust: SettingsTrustPolicy::AsDiscovered,
};
pub(crate) const TRUSTED_HIERARCHY: Self = Self {
source: SettingsSourcePolicy::Hierarchy,
trust: SettingsTrustPolicy::TrustedOnly,
@@ -756,19 +752,19 @@ fn settings_path_resolver<'a>(
let mut tera = crate::tera::get_miserc_tera();
let mut context = tera::Context::new();
context.insert("env", &*env::PRISTINE_ENV);
context.insert("config_root", &config_root);
context.insert("config_root", &config_root.to_string_lossy());
context.insert(
"config_source",
&crate::config::config_file::config_root::config_source(path),
);
if let Ok(cwd) = std::env::current_dir() {
context.insert("cwd", &cwd);
context.insert("cwd", &cwd.to_string_lossy());
}
context.insert("xdg_cache_home", &*env::XDG_CACHE_HOME);
context.insert("xdg_config_home", &*env::XDG_CONFIG_HOME);
context.insert("xdg_data_home", &*env::XDG_DATA_HOME);
context.insert("xdg_state_home", &*env::XDG_STATE_HOME);
context.insert("mise_config_dir", &*env::MISE_CONFIG_DIR);
context.insert("xdg_cache_home", &env::XDG_CACHE_HOME.to_string_lossy());
context.insert("xdg_config_home", &env::XDG_CONFIG_HOME.to_string_lossy());
context.insert("xdg_data_home", &env::XDG_DATA_HOME.to_string_lossy());
context.insert("xdg_state_home", &env::XDG_STATE_HOME.to_string_lossy());
context.insert("mise_config_dir", &env::MISE_CONFIG_DIR.to_string_lossy());
context.insert("system_brew_prefix", &env::system_brew_prefix());
move |label: &str, target: &mut toml::Value| -> Result<()> {
@@ -1157,8 +1153,10 @@ impl SettingsExt for Settings {
}
fn parse_settings_file(path: &Path) -> Result<SettingsPartial> {
let raw = file::read_to_string(path)?;
let mut raw: toml::Value = toml::from_str(&raw)?;
let raw = file::read_config_to_string(path)?;
let mut raw: toml::Value = toml::from_str(&raw).map_err(|err| {
crate::config::config_file::diagnostic::hide_linked_source(err, &raw, path)
})?;
let tera_v1_from_env = tera_v1_from_env_config(&raw);
if let Some(settings) = raw.get_mut("settings").and_then(toml::Value::as_table_mut) {
strip_local_only_settings(settings, path, crate::config::is_global_config(path));
+113
View File
@@ -166,6 +166,43 @@ fn test_collect_task_files_skips_links_back_into_the_walk() -> Result<()> {
Ok(())
}
/// Links that only ever lead forward are not loops to walkdir, but each level of
/// four links to the next one multiplies the paths to the last level by four.
/// The walk stops at its entry limit instead of following every route.
#[test]
fn test_collect_task_files_stops_at_the_entry_limit() -> Result<()> {
fn maze(base: &Path, links_per_level: usize) -> Result<PathBuf> {
let root = base.join("mise-tasks");
fs::create_dir_all(&root)?;
let levels = 8;
for n in 0..=levels {
fs::create_dir_all(base.join(format!("maze/{n}")))?;
}
std::os::unix::fs::symlink(base.join("maze/0"), root.join("all"))?;
for n in 0..levels {
for l in 0..links_per_level {
std::os::unix::fs::symlink(
base.join(format!("maze/{}", n + 1)),
base.join(format!("maze/{n}/{l}")),
)?;
}
}
fs::write(base.join(format!("maze/{levels}/task")), "echo ok")?;
Ok(root)
}
let control = TempDir::new()?;
let root = maze(control.path(), 1)?;
assert_eq!(collect_task_files_with_limit(&root, &[], 1_000)?.len(), 1);
let hostile = TempDir::new()?;
let root = maze(hostile.path(), 4)?;
let err = collect_task_files_with_limit(&root, &[], 1_000).unwrap_err();
assert!(err.to_string().contains("more than 1000 entries"), "{err}");
Ok(())
}
#[test]
fn test_collect_task_files_preserves_unresolvable_symlink_errors() -> Result<()> {
// Renamed from `..._preserves_symlink_loop_errors`: what it pins is a link that cannot be
@@ -454,6 +491,38 @@ fn test_project_visible_mise_conf_d_precedence() -> Result<()> {
Ok(())
}
/// A checkout can ship a config name that links to a device, which would be
/// read without end. Discovery only keeps regular files, through a link or not.
#[cfg(unix)]
#[test]
fn test_config_discovery_skips_non_regular_files() -> Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path();
std::os::unix::fs::symlink("/dev/zero", dir.join("mise.toml"))?;
std::os::unix::fs::symlink("/dev/zero", dir.join(".tool-versions"))?;
fs::create_dir_all(dir.join(".mise/conf.d"))?;
std::os::unix::fs::symlink("/dev/zero", dir.join(".mise/conf.d/a.toml"))?;
fs::write(dir.join("real.toml"), "[env]\n")?;
std::os::unix::fs::symlink(dir.join("real.toml"), dir.join(".mise.toml"))?;
let filenames = vec![
".tool-versions".to_string(),
".mise/conf.d/*.toml".to_string(),
"mise.toml".to_string(),
".mise.toml".to_string(),
];
assert_eq!(
config_paths_in_dir_with_filenames(dir, &filenames),
vec![dir.join(".mise.toml")]
);
assert!(load_config_glob(dir, "mise.toml").is_empty());
assert!(!has_config_file_with_filenames(
dir,
&["mise.toml".to_string()]
));
Ok(())
}
#[test]
fn test_project_conf_d_dotted_fragments_remain_unconditional() -> Result<()> {
let tmp = TempDir::new()?;
@@ -1607,6 +1676,50 @@ fn test_env_config_patterns_escape_glob_characters() -> Result<()> {
Ok(())
}
#[test]
fn test_glob_treats_directory_as_literal() -> Result<()> {
let tmp = TempDir::new()?;
for dir in ["[ab]", "a", "**"] {
let confd = tmp.path().join(dir).join(".mise/conf.d");
fs::create_dir_all(&confd)?;
fs::write(confd.join("tools.toml"), "")?;
}
// `[ab]` is the directory of that name, not a character class matching `a`.
assert_eq!(
glob(&tmp.path().join("[ab]"), ".mise/conf.d/*.toml")?,
vec![tmp.path().join("[ab]/.mise/conf.d/tools.toml")]
);
// `**` is the directory of that name, not a recursive walk of `tmp`.
assert_eq!(
glob(&tmp.path().join("**"), ".mise/conf.d/*.toml")?,
vec![tmp.path().join("**/.mise/conf.d/tools.toml")]
);
Ok(())
}
/// Override filenames come from a project `.miserc.toml` that loads without
/// trust, and are looked up in every ancestor directory: they are names, not
/// patterns.
#[test]
fn test_shared_config_filenames_escape_glob_characters() -> Result<()> {
let tmp = TempDir::new()?;
fs::write(tmp.path().join("**"), "[env]\n")?;
fs::write(tmp.path().join("mise.toml"), "[env]\n")?;
fs::write(tmp.path().join("tools.toml"), "[env]\n")?;
let overrides: IndexSet<String> = ["**".to_string()].into();
let filenames = shared_config_filenames(Some(&overrides), &overrides)
.into_iter()
.collect_vec();
assert_eq!(filenames, vec!["[*][*]".to_string()]);
assert_eq!(
config_paths_in_dir_with_filenames(tmp.path(), &filenames),
vec![tmp.path().join("**")]
);
Ok(())
}
#[test]
fn test_env_config_patterns_with_non_star_glob_characters() -> Result<()> {
for env_name in ["qa?", "qa[1]", "qa]"] {
+181 -9
View File
@@ -7,7 +7,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use std::{collections::BTreeSet, sync::Arc};
use base64::prelude::*;
use eyre::Result;
use eyre::{Result, bail};
use flate2::Compression;
use flate2::write::{ZlibDecoder, ZlibEncoder};
use indexmap::IndexSet;
@@ -197,10 +197,10 @@ impl From<&Path> for WatchFilePattern {
impl From<PathBuf> for WatchFilePattern {
fn from(path: PathBuf) -> Self {
Self {
patterns: vec![path.to_string_lossy().to_string()],
root: Some(path),
}
// A path is a literal file to watch, never a glob: a `root` would make
// `get_watch_files` expand it, and a config dir name like `**` (from
// `conf.d/*/mise.toml` in an untrusted checkout) then walks forever.
path.as_path().into()
}
}
@@ -503,7 +503,9 @@ pub struct HookEnvSession {
#[serde(default = "legacy_env_state_version")]
pub v: u32,
pub loaded_tools: IndexSet<String>,
#[serde(with = "session_paths")]
pub loaded_configs: IndexSet<PathBuf>,
#[serde(with = "session_paths")]
pub config_paths: IndexSet<PathBuf>,
/// Env var name to [`hash_env_value`] digest (plaintext in a version 1 session). Values
/// are only compared, so they are not stored.
@@ -512,12 +514,13 @@ pub struct HookEnvSession {
pub aliases: indexmap::IndexMap<String, String>,
/// Files accessed by tera template functions (read_file, hash_file, etc.)
/// that should be watched for changes.
#[serde(default)]
#[serde(default, with = "session_paths")]
pub tera_files: Vec<PathBuf>,
/// Resolved file paths from [[watch_files]] config patterns and env plugin watch_files.
/// Stored so the fast-path can detect changes without loading config.
#[serde(default)]
#[serde(default, with = "session_paths")]
pub watch_files: Vec<PathBuf>,
#[serde(default, with = "session_paths::option")]
dir: Option<PathBuf>,
env_var_hash: String,
latest_update: u128,
@@ -528,6 +531,81 @@ pub struct HookEnvSession {
hook_env_shortcuts_unset: bool,
}
/// A path in `__MISE_SESSION`. serde refuses a path that is not valid UTF-8,
/// which a file name on unix need not be. It is kept as raw bytes rather than
/// lossily because the next run has to find the same file again.
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum SessionPath {
Utf8(String),
Bytes(Vec<u8>),
}
impl From<&PathBuf> for SessionPath {
fn from(path: &PathBuf) -> Self {
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
match path.to_str() {
Some(path) => Self::Utf8(path.to_string()),
#[cfg(unix)]
None => Self::Bytes(path.as_os_str().as_bytes().to_vec()),
#[cfg(not(unix))]
None => Self::Utf8(path.to_string_lossy().to_string()),
}
}
}
impl From<SessionPath> for PathBuf {
fn from(path: SessionPath) -> Self {
#[cfg(unix)]
use std::os::unix::ffi::OsStringExt;
match path {
SessionPath::Utf8(path) => path.into(),
#[cfg(unix)]
SessionPath::Bytes(path) => std::ffi::OsString::from_vec(path).into(),
#[cfg(not(unix))]
SessionPath::Bytes(path) => String::from_utf8_lossy(&path).to_string().into(),
}
}
}
mod session_paths {
use super::*;
use serde::{Deserializer, Serializer};
pub(crate) fn serialize<'a, S: Serializer>(
paths: impl IntoIterator<Item = &'a PathBuf>,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
serializer.collect_seq(paths.into_iter().map(SessionPath::from))
}
pub(crate) fn deserialize<'de, D: Deserializer<'de>, T: FromIterator<PathBuf>>(
deserializer: D,
) -> std::result::Result<T, D::Error> {
let paths = Vec::<SessionPath>::deserialize(deserializer)?;
Ok(paths.into_iter().map(PathBuf::from).collect())
}
pub(crate) mod option {
use super::*;
pub(crate) fn serialize<S: Serializer>(
path: &Option<PathBuf>,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
path.as_ref().map(SessionPath::from).serialize(serializer)
}
pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Option<PathBuf>, D::Error> {
let path = Option::<SessionPath>::deserialize(deserializer)?;
Ok(path.map(PathBuf::from))
}
}
}
pub fn serialize<T: serde::Serialize>(obj: &T) -> Result<String> {
let mut gz = ZlibEncoder::new(Vec::new(), Compression::fast());
gz.write_all(&rmp_serde::to_vec_named(obj)?)?;
@@ -559,8 +637,10 @@ fn config_search_dir_mtimes() -> Vec<SystemTime> {
for subdir in &config_subdirs {
// `conf.d/*` names the folder fragments: a file added to one
// changes that folder's mtime, not conf.d's.
let check_dirs = if subdir.contains('*') {
glob::glob(&dir.join(subdir).to_string_lossy())
let check_dirs = if subdir.contains(['*', '?', '[']) {
// `dir` is a literal directory: escape its glob metacharacters.
let escaped_dir = glob::Pattern::escape(&dir.to_string_lossy());
glob::glob(&Path::new(&escaped_dir).join(subdir).to_string_lossy())
.map(|paths| paths.flatten().collect())
.unwrap_or_default()
} else if subdir.is_empty() {
@@ -840,6 +920,30 @@ fn compute_deactivated_path() -> String {
.unwrap_or(pristine_path)
}
/// Linux refuses every `execve` while one `NAME=value` string of the
/// environment, with its terminating NUL, is longer than this
/// (`MAX_ARG_STRLEN`). Other systems cap arguments and environment together
/// (1 MiB on macOS), which a variable this large eats much of.
const MAX_ENV_STRING_LEN: usize = 128 * 1024;
/// Refuse patches that would leave the shell unable to start any program,
/// including the next `mise hook-env` that could replace the variable. Files
/// that load without trust decide the size of `__MISE_SESSION` (one entry per
/// config file) and of `PATH` (one entry per requested tool version).
pub fn ensure_env_fits(patches: &EnvDiffPatches) -> Result<()> {
for patch in patches {
if let EnvDiffOperation::Add(k, v) | EnvDiffOperation::Change(k, v) = patch
&& k.len() + v.len() + 2 > MAX_ENV_STRING_LEN
{
bail!(
"not applying the environment for this directory: {k} would be {} bytes, and no program can be started with an environment variable of {MAX_ENV_STRING_LEN} bytes or more",
v.len()
);
}
}
Ok(())
}
pub fn build_env_commands(shell: &dyn Shell, patches: &EnvDiffPatches) -> String {
let mut output = String::new();
@@ -902,6 +1006,45 @@ mod tests {
values.iter().map(|value| value.to_string()).collect()
}
#[cfg(unix)]
#[test]
fn session_keeps_paths_that_are_not_utf8() {
use super::{HookEnvSession, deserialize, serialize};
use std::os::unix::ffi::OsStrExt;
use std::path::PathBuf;
let dir = PathBuf::from(std::ffi::OsStr::from_bytes(b"/tmp/caf\xe9"));
let session = HookEnvSession {
loaded_configs: [dir.join("mise.toml"), "/tmp/mise.toml".into()].into(),
config_paths: [dir.join("bin")].into(),
tera_files: vec![dir.join("file")],
watch_files: vec![dir.join("watched")],
dir: Some(dir),
..Default::default()
};
let back: HookEnvSession = deserialize(serialize(&session).unwrap()).unwrap();
assert_eq!(back.loaded_configs, session.loaded_configs);
assert_eq!(back.config_paths, session.config_paths);
assert_eq!(back.tera_files, session.tera_files);
assert_eq!(back.watch_files, session.watch_files);
assert_eq!(back.dir, session.dir);
}
#[test]
fn ensure_env_fits_rejects_a_variable_execve_would_refuse() {
use super::{MAX_ENV_STRING_LEN, ensure_env_fits};
use crate::env_diff::EnvDiffOperation::{Add, Change, Remove};
// `K=` and the terminating NUL count towards the limit
let fits = "v".repeat(MAX_ENV_STRING_LEN - 3);
let too_long = "v".repeat(MAX_ENV_STRING_LEN - 2);
let k = || "K".to_string();
let ok = vec![Add(k(), fits.clone()), Change(k(), fits), Remove(k())];
assert!(ensure_env_fits(&ok).is_ok());
assert!(ensure_env_fits(&vec![Add(k(), too_long.clone())]).is_err());
assert!(ensure_env_fits(&vec![Change(k(), too_long)]).is_err());
}
#[test]
fn session_version_decides_how_env_is_compared() {
use super::{HookEnvSession, deserialize, serialize};
@@ -941,6 +1084,35 @@ mod tests {
assert!(!env_value_matches(current.v, &current.env["K"], "other"));
}
#[cfg(unix)]
#[test]
fn watched_paths_are_not_expanded_as_globs() {
use super::{WatchFilePattern, get_watch_files};
// `**` follows the link back into the directory, so expanding the path
// as a glob finds the lockfile again below it
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("**");
std::fs::create_dir(&dir).unwrap();
std::os::unix::fs::symlink(".", dir.join("a")).unwrap();
let lockfile = dir.join("mise.lock");
std::fs::write(&lockfile, "").unwrap();
assert_eq!(
WatchFilePattern::from(lockfile.clone()),
WatchFilePattern::from(lockfile.as_path())
);
let project_pattern = WatchFilePattern {
root: Some(dir.clone()),
patterns: vec!["*.lock".to_string()],
};
for pattern in [lockfile.clone().into(), project_pattern] {
let watches = get_watch_files([pattern]).unwrap();
let below_tmp = watches.iter().filter(|p| p.starts_with(tmp.path()));
assert_eq!(below_tmp.collect::<Vec<_>>(), vec![&lockfile]);
}
}
#[test]
fn force_full_run_records_the_decision_and_reports_not_exiting_early() {
let prev = FAST_PATH_FORCED_FULL_RUN.swap(false, Ordering::Relaxed);
+13 -5
View File
@@ -1400,7 +1400,7 @@ fn existing_lockfile_doc_url(content: &str) -> Option<String> {
}
fn existing_lockfile_doc_url_from_path(path: &Path) -> Option<String> {
file::read_to_string(path)
file::read_config_to_string(path)
.ok()
.and_then(|content| existing_lockfile_doc_url(&content))
}
@@ -1527,7 +1527,9 @@ impl Lockfile {
pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
if !path.exists() {
// A lockfile name that is a pipe or links to a device is not a
// lockfile, and reading one whole would never end.
if !path.is_file() {
return Ok(Lockfile::default());
}
trace!("reading lockfile {}", path.display_user());
@@ -1537,10 +1539,14 @@ impl Lockfile {
.is_symlink()
.then(|| fs::canonicalize(path))
.transpose()?;
let link = path;
let path = target.as_deref().unwrap_or(path);
let content = file::read_to_string(path)?;
let content = file::read_config_to_string(path)?;
let generated_header_url = existing_lockfile_doc_url(&content);
let mut table: toml::Table = toml::from_str(&content)?;
// Named by the link, not its target: the link decides whether to quote.
let mut table: toml::Table = toml::from_str(&content).map_err(|err| {
crate::config::config_file::diagnostic::hide_linked_source(err, &content, link)
})?;
if table.is_empty() {
return Ok(Lockfile {
generated_header_url,
@@ -1860,7 +1866,9 @@ impl Lockfile {
.unwrap_or_else(|| DEFAULT_LOCKFILE_DOC_URL.to_string());
let content = format!("{LOCKFILE_HEADER_PREFIX}{doc_url}\n\n{content}");
sidecars.collect_garbage();
if fs::read(path).ok().as_deref() == Some(content.as_bytes()) {
// Bounded and non-blocking: a pipe or oversized file at the lockfile's
// path reads as different, so it is replaced rather than read.
if file::read_config_to_string(path).ok().as_deref() == Some(content.as_str()) {
if !sidecars.has_changes() {
return Ok(None);
}
+40
View File
@@ -4967,3 +4967,43 @@ fn test_lock_options_mask_unparseable_credentials_to_the_end() {
);
}
}
/// Only a regular file is read as a lockfile: a pipe or a link to a device
/// under that name would be read without end.
#[test]
fn test_read_ignores_a_lockfile_that_is_not_a_regular_file() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("mise.lock");
std::fs::create_dir(&path).unwrap();
let lockfile = Lockfile::read(&path).unwrap();
assert!(lockfile.tools.is_empty());
}
/// A pipe at the lockfile's path must not hang the write that compares the
/// new content with what is there.
#[cfg(unix)]
#[test]
fn test_prepare_write_does_not_block_on_a_pipe() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("mise.lock");
nix::unistd::mkfifo(
&path,
nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
)
.unwrap();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(
Lockfile::default()
.prepare_write(&path)
.map(|w| w.is_some()),
);
});
let prepared = rx
.recv_timeout(std::time::Duration::from_millis(500))
.expect("preparing the lockfile write blocked on a pipe");
assert!(
prepared.unwrap(),
"a pipe was taken for an up-to-date lockfile"
);
}
+46 -4
View File
@@ -219,6 +219,9 @@ fn is_gemfile_path(path: &Path) -> bool {
}
/// Relative paths from a Gemfile `ruby file:` option, for hook-env watches.
///
/// hook-env expands watches as globs and the Gemfile is read without trust, so
/// the name is escaped to match only the one file Bundler would read.
pub(crate) fn gemfile_watch_patterns(gemfile_path: &Path) -> Vec<String> {
if gemfile_path
.file_name()
@@ -226,14 +229,14 @@ pub(crate) fn gemfile_watch_patterns(gemfile_path: &Path) -> Vec<String> {
{
return vec![];
}
let Ok(body) = file::read_to_string(gemfile_path) else {
let Ok(body) = file::read_config_to_string(gemfile_path) else {
return vec![];
};
match gemfile_ruby_directive(&body).and_then(|line| ruby_file_option(&line)) {
Some(filename)
if is_safe_relative_ruby_file(&filename) && !is_gemfile_path(Path::new(&filename)) =>
{
vec![filename]
vec![glob::Pattern::escape(&filename)]
}
_ => vec![],
}
@@ -244,10 +247,11 @@ fn read_ruby_file_option(gemfile_path: &Path, filename: &str) -> Option<String>
return None;
}
let path = gemfile_path.parent()?.join(filename);
if is_gemfile_path(&path) {
// A pipe or a link to a device under that name would be read without end.
if is_gemfile_path(&path) || !path.is_file() {
return None;
}
let version = parse_bundler_ruby_version_file(&file::read_to_string(&path).ok()?);
let version = parse_bundler_ruby_version_file(&file::read_config_to_string(&path).ok()?);
if is_ruby_version_string(&version) {
Some(version)
} else {
@@ -433,6 +437,22 @@ mod tests {
assert_eq!(parse_gemfile(&gemfile).unwrap(), "");
}
/// The Gemfile is read without trust, so the file its `ruby file:` names
/// is read only up to the config size limit, like any version file.
#[test]
fn parse_gemfile_file_option_refuses_an_oversized_file() {
let dir = tempfile::tempdir().unwrap();
let gemfile = dir.path().join("Gemfile");
let ruby_version = dir.path().join(".ruby-version");
// A version line Bundler accepts, padded past the limit with blank lines.
let mut body = "ruby 3.3.6\n".to_string();
body.push_str(&"\n".repeat(file::CONFIG_FILE_LIMIT as usize));
file::write(&ruby_version, body).unwrap();
file::write(&gemfile, "ruby file: \".ruby-version\"\n").unwrap();
assert_eq!(parse_gemfile(&gemfile).unwrap(), "");
assert!(gemfile_watch_patterns(&gemfile).len() == 1);
}
#[test]
fn parse_gemfile_file_option_applies_engine_options() {
let dir = tempfile::tempdir().unwrap();
@@ -480,6 +500,28 @@ mod tests {
assert!(gemfile_watch_patterns(&dir.path().join(".ruby-version")).is_empty());
}
#[test]
fn gemfile_watch_patterns_escape_glob_characters() {
let dir = tempfile::tempdir().unwrap();
let gemfile = dir.path().join("Gemfile");
file::write(dir.path().join("other"), "3.3.5\n").unwrap();
file::write(&gemfile, "ruby file: \"**/*\"\n").unwrap();
let patterns = gemfile_watch_patterns(&gemfile);
assert_eq!(patterns, vec!["[*][*]/[*]".to_string()]);
assert!(
crate::watch_files::glob(dir.path(), &patterns)
.unwrap()
.is_empty()
);
file::write(dir.path().join("ruby[1]"), "3.3.6\n").unwrap();
file::write(&gemfile, "ruby file: \"ruby[1]\"\n").unwrap();
assert_eq!(
crate::watch_files::glob(dir.path(), &gemfile_watch_patterns(&gemfile)).unwrap(),
vec![dir.path().join("ruby[1]")]
);
}
#[test]
fn gemfile_watch_patterns_skip_self_and_nested_gemfile() {
let dir = tempfile::tempdir().unwrap();
+70 -26
View File
@@ -1098,38 +1098,60 @@ fn string_array(values: &[String]) -> toml::Value {
)
}
/// Reads `[toolchain].<key>` as a string; a value of another TOML type is an error,
/// not a panic, since the file comes from the (possibly untrusted) project.
fn toolchain_str(toolchain: &toml::Value, key: &str) -> Result<Option<String>> {
toolchain
.get(key)
.map(|value| {
value
.as_str()
.map(str::to_string)
.ok_or_else(|| eyre::eyre!("rust-toolchain.toml: toolchain.{key} must be a string"))
})
.transpose()
}
/// Reads `[toolchain].<key>` as an array of strings, see [`toolchain_str`].
fn toolchain_str_array(toolchain: &toml::Value, key: &str) -> Result<Option<Vec<String>>> {
toolchain
.get(key)
.map(|value| {
value
.as_array()
.and_then(|values| {
values
.iter()
.map(|v| v.as_str().map(str::to_string))
.collect::<Option<Vec<_>>>()
})
.ok_or_else(|| {
eyre::eyre!("rust-toolchain.toml: toolchain.{key} must be an array of strings")
})
})
.transpose()
}
fn parse_idiomatic_file(path: &Path) -> Result<RustToolchain> {
let content = file::read_to_string(path)?;
let toml: toml::Value = toml::de::from_str(&content)?;
let toml: toml::Value = toml::de::from_str(&content).map_err(|err| {
crate::config::config_file::diagnostic::hide_linked_source(err, &content, path)
})?;
let mut rt = RustToolchain::default();
if let Some(toolchain) = toml.get("toolchain") {
if let Some(channel) = toolchain.get("channel") {
rt.channel = channel.as_str().unwrap().to_string();
if let Some(channel) = toolchain_str(toolchain, "channel")? {
rt.channel = channel;
}
if let Some(profile) = toolchain.get("profile") {
rt.profile = Some(profile.as_str().unwrap().to_string());
rt.profile = toolchain_str(toolchain, "profile")?;
if let Some(components) = toolchain_str_array(toolchain, "components")?
&& !components.is_empty()
{
rt.components = Some(components);
}
if let Some(components) = toolchain.get("components") {
let components = components
.as_array()
.unwrap()
.iter()
.map(|c| c.as_str().unwrap().to_string())
.collect::<Vec<_>>();
if !components.is_empty() {
rt.components = Some(components);
}
}
if let Some(targets) = toolchain.get("targets") {
let targets = targets
.as_array()
.unwrap()
.iter()
.map(|c| c.as_str().unwrap().to_string())
.collect::<Vec<_>>();
if !targets.is_empty() {
rt.targets = Some(targets);
}
if let Some(targets) = toolchain_str_array(toolchain, "targets")?
&& !targets.is_empty()
{
rt.targets = Some(targets);
}
}
Ok(rt)
@@ -2307,6 +2329,28 @@ targets = ["wasm32-wasip1", " wasm32-wasip1 "]
Ok(())
}
#[test]
fn rust_idiomatic_file_rejects_wrongly_typed_fields() -> Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("rust-toolchain.toml");
for toolchain in [
"channel = 1",
"channel = \"1.85.0\"\nprofile = false",
"channel = \"1.85.0\"\ncomponents = \"clippy\"",
"channel = \"1.85.0\"\ncomponents = [1]",
"channel = \"1.85.0\"\ntargets = { a = 1 }",
] {
std::fs::write(&path, format!("[toolchain]\n{toolchain}\n"))?;
let err = parse_idiomatic_file(&path).unwrap_err();
assert!(
err.to_string()
.starts_with("rust-toolchain.toml: toolchain."),
"{toolchain}: {err}"
);
}
Ok(())
}
#[test]
fn rust_lockfile_options_include_install_args() {
let mut opts = opts_with("profile", "minimal");
+41 -6
View File
@@ -867,7 +867,10 @@ fn parse_task_script_usage_with_env(
env: Option<&EnvMap>,
preamble: &str,
) -> usage::Result<usage::Spec> {
let script = std::fs::read_to_string(file)?;
// `file` comes from config that may not be trusted yet, so it is read like
// a config file: only a regular file, and only up to the size limit.
let script = crate::file::read_config_to_string(file)
.map_err(|err| std::io::Error::other(format!("{err:#}")))?;
// Same reason as the `#MISE` header scan: `#USAGE` on line 1 is invisible behind a mark.
let mut raw = extract_usage_from_comments(crate::file::strip_utf8_bom(&script));
if !preamble.trim().is_empty() {
@@ -1270,7 +1273,7 @@ impl Task {
// Stripped before scanning: the header patterns anchor at the start of a line, so a
// byte-order mark would hide a `#MISE` written on line 1 -- which is where a task with
// an executable extension rather than a shebang puts it.
let body = file::read_to_string(path)?;
let body = file::read_config_to_string(path)?;
let info = parse_mise_header_toml(file::strip_utf8_bom(&body))?
.into_iter()
.filter_map(|toml| toml.as_table().cloned())
@@ -2304,7 +2307,10 @@ impl Task {
let mut tera_ctx = config.tera_ctx.clone();
tera_ctx.insert("env", &EnvMap::new());
tera_ctx.insert("vars", &IndexMap::<String, String>::new());
tera_ctx.insert("config_root", &self.config_root);
tera_ctx.insert(
"config_root",
&self.config_root.as_ref().map(|p| p.to_string_lossy()),
);
tera_ctx
}
@@ -2331,7 +2337,10 @@ impl Task {
// Re-insert with task-level vars merged in so callers see the final combined map,
// with task-level values taking precedence over config-level ones.
tera_ctx.insert("vars", &vars);
tera_ctx.insert("config_root", &self.config_root);
tera_ctx.insert(
"config_root",
&self.config_root.as_ref().map(|p| p.to_string_lossy()),
);
Ok(tera_ctx)
}
@@ -2429,7 +2438,7 @@ impl Task {
.collect();
let mut env: EnvMap = env::PRISTINE_ENV.clone();
let mut resolve_ctx = tera_ctx.clone();
resolve_ctx.insert("config_root", &task_project_root);
resolve_ctx.insert("config_root", &task_project_root.to_string_lossy());
let results = EnvResults::resolve(
config,
resolve_ctx,
@@ -3033,7 +3042,7 @@ impl Task {
let (mut env, mut env_remove, mut mise_keys) =
ts.full_env_with_removals_and_keys(config).await?;
if let Some(root) = &config.project_root {
tera_ctx.insert("config_root", &root);
tera_ctx.insert("config_root", &root.to_string_lossy());
}
// Convert task env directives to (EnvDirective, PathBuf) pairs
@@ -4877,6 +4886,32 @@ exec shapeme "$@"
}
}
/// A task `file` from config may name a device or a pipe, which would be
/// read without end, so only regular files are read for a usage spec.
#[cfg(unix)]
#[test]
fn test_parse_task_script_usage_rejects_a_device() {
let err = super::parse_task_script_usage(Path::new("/dev/null")).unwrap_err();
assert!(err.to_string().contains("not a regular file"), "{err}");
}
/// A task `file` from untrusted config is read for its usage spec by
/// `mise tasks ls` and completion, so it stops at the config size limit.
#[test]
fn test_parse_task_script_usage_refuses_an_oversized_file() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "#!/bin/sh\n#USAGE flag \"--verbose\"\n").unwrap();
// Sparse padding past the limit after a valid header.
std::fs::OpenOptions::new()
.write(true)
.open(tmp.path())
.unwrap()
.set_len(crate::file::CONFIG_FILE_LIMIT + 1)
.unwrap();
let err = super::parse_task_script_usage(tmp.path()).unwrap_err();
assert!(err.to_string().contains("larger than 16 MiB"), "{err}");
}
#[test]
fn test_parse_task_script_usage_resolves_relative_includes() {
let dir = tempfile::tempdir().unwrap();
+1 -1
View File
@@ -564,7 +564,7 @@ impl TaskContextBuilder {
) -> Result<(tera::Context, Option<IndexMap<String, String>>)> {
let mut tera_ctx = ts.tera_ctx(config).await?.clone();
if let Some(root) = task_cf.project_root() {
tera_ctx.insert("config_root", &root);
tera_ctx.insert("config_root", &root.to_string_lossy());
}
let mut resolved_vars = None;
// If we have task-specific config files, resolve vars from them
+3
View File
@@ -370,6 +370,9 @@ pub(crate) fn glob(root: &Path, patterns: &[String]) -> Result<Vec<PathBuf>> {
require_literal_separator: true,
..Default::default()
};
// only the patterns are globs; the root is a literal directory
let escaped_root = glob::Pattern::escape(&root.to_string_lossy());
let root = Path::new(&escaped_root);
Ok(patterns
.iter()
.map(|pattern| root.join(pattern).to_string_lossy().to_string())