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:
1 parent
3edcfc7752
commit
b71ae7cfd6
54 files changed
+1875
-231
No files matched your search
@@ -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] = &[
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
@@ -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, ®istry_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, ®istry_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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()),
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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)?);
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
@@ -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
@@ -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, ¤t.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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in new issue
Block a user