diff --git a/.cli-flags.toml b/.cli-flags.toml index bf733915..c7acc3a4 100644 --- a/.cli-flags.toml +++ b/.cli-flags.toml @@ -293,7 +293,7 @@ aliases = ["un"] help = "Remove materialized dependencies while retaining the lockfile." [commands.env] -help = "Import and verify project-local developer environments." +help = "Import, verify, and export project-local developer environments." [commands.env.commands.import] help = "Import manager-native state as a normalized EnvironmentPlan." @@ -301,6 +301,36 @@ help = "Import manager-native state as a normalized EnvironmentPlan." [commands.env.commands.verify] help = "Verify config/lock coverage and normalized environment identity." +[commands.env.commands.export] +help = "Project a schema-v2 EnvironmentPlan into deterministic manager configuration." + +[commands.env.commands.export.flags.env_plan] +env = "ZED_PKG_ENV_PLAN" +aliases = ["plan"] +type = "string" +help = "Project-local schema-v2 environment plan path." + +[commands.env.commands.export.flags.env_output] +env = "ZED_PKG_ENV_OUTPUT" +aliases = ["output"] +type = "string" +default = ".mise.toml" +help = "Project-local manager export path." + +[commands.env.commands.export.flags.env_check] +env = "ZED_PKG_ENV_CHECK" +aliases = ["check"] +type = "bool" +default = "false" +help = "Verify the deterministic manager projection without writing." + +[commands.env.commands.export.flags.env_write] +env = "ZED_PKG_ENV_WRITE" +aliases = ["write"] +type = "bool" +default = "false" +help = "Transactionally write a Zed-owned manager projection." + [commands.task] help = "List, inspect, graph, or execute native schema-v2 project tasks." diff --git a/README.md b/README.md index 87d4050e..c28f9986 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ registry hosts both on S3/Cloudflare R2. | `zed uninstall [/ ...]` (`zed un`) | Transactionally remove all or selected materialized packages while retaining the manifest and lockfile for a frozen reinstall | | `zed env import mise [--config PATH] [--lock PATH] [--frozen] [--json]` | Import the supported project-local mise tool/lock subset as the shared normalized `EnvironmentPlan`; never loads parent/global config or executes hooks | | `zed env verify mise [--config PATH] [--lock PATH] --frozen [--json]` | Fail closed on missing lock coverage, drift, malformed checksums, unsupported semantics, or non-portable frozen state and report the stable plan digest | +| `zed env export mise --plan PATH [--output .mise.toml] [--check|--write] [--json]` | Deterministically project a schema-v2 environment plan into conflict-safe project-local mise TOML without invoking mise or executing project code | | `zed env import asdf [--config .tool-versions] [--lock .zed/asdf.lock.toml] [--frozen] [--json]` | Import project-local asdf selections and optional immutable plugin/artifact provenance without invoking asdf or plugin code | | `zed env verify asdf [--config .tool-versions] [--lock .zed/asdf.lock.toml] --frozen [--json]` | Verify exact asdf tool, plugin revision, artifact SHA-256, platform, and normalized plan identity without reading parent/global configuration | | `zed task list\|info\|graph\|run ...` | Use the shared schema-v2 runtime to discover, inspect, graph, dry-run, execute, confirm, parallelize, and content-cache project tasks; `zed-task` remains a compatibility binary | diff --git a/docs/mise-export.md b/docs/mise-export.md new file mode 100644 index 00000000..80e63216 --- /dev/null +++ b/docs/mise-export.md @@ -0,0 +1,122 @@ +# Deterministic mise export + +`zed env export mise` projects a schema-v2 Zed environment plan into a +project-local mise configuration without invoking mise, loading global state, +or executing project code. + +```sh +# Print deterministic TOML without writing files. +zed env export mise --plan zed-env.toml + +# Verify that the checked-in manager view is current. +zed env export mise --plan zed-env.toml --output .mise.toml --check + +# Create or update a Zed-owned manager view transactionally. +zed env export mise --plan zed-env.toml --output .mise.toml --write +``` + +`--check` and `--write` are mutually exclusive. Without either flag, the +command prints the deterministic TOML document to standard output. + +## Supported projection + +The first write-capable slice exports only state with an exact, tested mise +representation: + +- ordered single- and multi-version tools; +- backend-qualified tool keys and core-tool aliases; +- `version`, portable project-relative `path`, `prefix`, and `ref` selectors; +- typed backend options and tool OS constraints; +- scalar environment values; +- typed project and task variables; +- task descriptions, aliases, ordered command arrays, dependencies, + post-dependencies, readiness dependencies, environment, variables, + task-local tools, directories, sources, outputs, one shell string, usage, + confirmation, cache, timeout, and output flags; and +- lockfile platform settings. + +The exporter preserves multi-version and command order. Set-like platform +lists are sorted and deduplicated by the normalized environment contract. + +## Fail-closed boundaries + +The command rejects fields rather than silently omitting them when no certified +mapping exists. Current explicit boundaries include: + +- activation hooks other than `activation = "none"`; +- environment-plan system packages; +- imported manager provenance and lock identity; +- extension maps; +- resolved versions, immutable sources, and artifact checksums that belong in + `mise.lock` rather than `mise.toml`; +- complex top-level or task environment tables/arrays, which mise may interpret + as directives rather than literal values; +- grouped and structured task invocations until the current mise run-entry wire + form is certified; and +- shell program-plus-argument vectors, because the current mise task field is + one shell string. + +Diagnostics include the exact environment-plan path that cannot be represented. +Complete current `mise.lock` export is tracked independently so config export +cannot accidentally erase provenance. + +## Secrets + +The plan contract does not yet expose a portable secret-reference type. Export +therefore rejects literal environment, variable, and tool-option names that +look credential-bearing at any nesting depth, including password, secret, token, +private/access key, API key, credential, and authorization names. The generated file, sidecar, +logs, and diagnostics never print rejected values. + +## Ownership and conflicts + +Write mode records deterministic ownership in: + +```text +.zed/mise-export-state.json +``` + +The sidecar contains only schema version, project-relative plan/output paths, +and SHA-256 identities. It contains no timestamps, random identifiers, +absolute paths, credentials, or environment values. + +The exporter follows these rules: + +1. A missing output can be created. +2. An existing file whose bytes already equal the deterministic projection can + be adopted safely. +3. A differing existing file with no matching Zed ownership record is treated + as hand-authored and is never overwritten. +4. A Zed-owned file whose current digest differs from the recorded digest is a + user edit and is never overwritten. +5. A Zed-owned unchanged file may be replaced only by the same project-relative + plan recorded in the sidecar. + +Output and state changes share `ProjectTransaction`, including crash recovery +and rollback. Plan, output, state, and staging identities are compared with +portable ASCII case-folding so a Linux-generated ownership file cannot become +ambiguous on Windows or macOS. Project, output, and state paths reject absolute/home/drive/UNC, +parent traversal, and symlink escape. + +## Determinism + +The plan identity is: + +```text +SHA256("zed-pkg:mise-export-plan:v1\0" || canonical-environment-plan-json) +``` + +The output identity is SHA-256 over the exact generated TOML bytes. Print, +check, and write modes all render the normalized semantic plan, so set-like +presentation order cannot diverge under one plan digest. Repeated generation of the +same normalized plan is byte-identical across supported platforms. + +## Next gates + +- Bind the complete current `mise.lock` contract to export and verification. +- Translate portable manager identities into native `EnvironmentLock`. +- Certify structured task run entries and sandbox capabilities. +- Add conflict-aware import → export → import semantic identity tests on Linux, + macOS, and Windows. + +Tracking: DEN-1462, DEN-1461, DEN-1481. diff --git a/src/cli.rs b/src/cli.rs index 264a0017..28c07731 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -162,7 +162,7 @@ impl From for clap_complete::Shell { #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum EnvironmentManagerArg { - /// Import or verify project-local mise configuration. + /// Import, verify, or export project-local mise configuration. Mise, /// Import or verify project-local asdf configuration and Zed-owned provenance. Asdf, @@ -443,6 +443,26 @@ pub enum EnvCmd { #[arg(long, env = "ZED_PKG_ENV_JSON")] json: bool, }, + /// Export a schema-v2 EnvironmentPlan as deterministic mise TOML. + Export { + #[arg(value_enum)] + manager: EnvironmentManagerArg, + /// Project-local schema-v2 EnvironmentPlan (.toml or .json). + #[arg(long, env = "ZED_PKG_ENV_PLAN")] + plan: PathBuf, + /// Project-local mise output path. + #[arg(long, env = "ZED_PKG_ENV_OUTPUT", default_value = ".mise.toml")] + output: PathBuf, + /// Verify that the output already equals the deterministic projection. + #[arg(long, env = "ZED_PKG_ENV_CHECK")] + check: bool, + /// Transactionally create/update a Zed-owned manager view. + #[arg(long, env = "ZED_PKG_ENV_WRITE")] + write: bool, + /// Emit a machine-readable export result. + #[arg(long, env = "ZED_PKG_ENV_JSON")] + json: bool, + }, /// Verify manager config/lock coverage and the normalized plan digest. Verify { #[arg(value_enum)] @@ -597,7 +617,7 @@ mod tests { use clap::{CommandFactory, Parser}; - use super::{AuthCmd, Cli, Cmd}; + use super::{AuthCmd, Cli, Cmd, EnvCmd}; #[test] fn flat_and_nested_auth_spellings_dispatch_identically() { @@ -742,6 +762,46 @@ mod tests { } } + #[test] + fn environment_export_is_typed_and_rejects_ambiguous_write_modes() { + let cli = Cli::try_parse_from([ + "zed", + "env", + "export", + "mise", + "--plan", + "zed-env.toml", + "--output", + ".mise.toml", + "--check", + "--json", + ]) + .unwrap(); + assert!(matches!(cli.cmd, Cmd::Env { .. })); + + assert!(matches!( + Cli::try_parse_from([ + "zed", + "env", + "export", + "mise", + "--plan", + "zed-env.toml", + "--check", + "--write", + ]) + .unwrap() + .cmd, + Cmd::Env { + cmd: EnvCmd::Export { + check: true, + write: true, + .. + } + } + )); + } + #[test] fn task_commands_are_typed_and_reject_zero_concurrency() { for args in [ diff --git a/src/lib.rs b/src/lib.rs index 4d13b4b9..ab4f12ce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,85 @@ pub mod interactive; pub mod lock_waiter; pub mod managed_install; pub mod manifestless; +#[path = "mise_export.rs"] +mod mise_export_impl; +pub mod mise_export { + use std::path::{Component, Path}; + + use anyhow::{Context, Result, ensure}; + + pub use super::mise_export_impl::{ + MiseExportAction, MiseExportMode, MiseExportReport, print_export, + }; + + const EXPORT_STATE_PATH: &str = ".zed/mise-export-state.json"; + + /// Render, verify, or write one project-local mise projection. + /// + /// The ownership sidecar is never accepted as an input plan, including + /// through portable case aliases or an in-project symlink. Mutating writes + /// are serialized with every other checkout operation so transaction + /// recovery cannot observe another writer's active staging directory. + pub fn export_mise( + cwd: &Path, + plan_arg: &Path, + output_arg: &Path, + mode: MiseExportMode, + ) -> Result { + reject_reserved_state_plan(cwd, plan_arg)?; + if mode == MiseExportMode::Write { + return super::project_lock::with_lock(cwd, "export mise environment", || { + super::mise_export_impl::export_mise(cwd, plan_arg, output_arg, mode) + }); + } + super::mise_export_impl::export_mise(cwd, plan_arg, output_arg, mode) + } + + fn reject_reserved_state_plan(cwd: &Path, plan_arg: &Path) -> Result<()> { + let root = cwd + .canonicalize() + .with_context(|| format!("failed to resolve project root {}", cwd.display()))?; + + let lexical_relative = if plan_arg.is_absolute() { + plan_arg.strip_prefix(&root).ok() + } else { + Some(plan_arg) + }; + if let Some(relative) = lexical_relative { + ensure_not_reserved(relative)?; + } + + let candidate = if plan_arg.is_absolute() { + plan_arg.to_path_buf() + } else { + root.join(plan_arg) + }; + if let Ok(canonical) = candidate.canonicalize() + && let Ok(relative) = canonical.strip_prefix(&root) + { + ensure_not_reserved(relative)?; + } + Ok(()) + } + + fn ensure_not_reserved(relative: &Path) -> Result<()> { + let mut parts = Vec::new(); + for component in relative.components() { + match component { + Component::CurDir => {} + Component::Normal(part) => parts.push(part.to_string_lossy()), + Component::ParentDir => parts.push("..".into()), + Component::RootDir | Component::Prefix(_) => return Ok(()), + } + } + let folded = parts.join("/").to_ascii_lowercase(); + ensure!( + folded != EXPORT_STATE_PATH, + "environment plan cannot target reserved export state `{EXPORT_STATE_PATH}`" + ); + Ok(()) + } +} pub mod mise_lock; pub mod nix_bundle_write; pub mod nix_export_bundle; diff --git a/src/main.rs b/src/main.rs index a343a782..22127ce6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ use zed_cli::fetch; use zed_cli::git_submodules as submodules; use zed_cli::global; use zed_cli::managed_install; +use zed_cli::mise_export::{self, MiseExportMode}; use zed_cli::nix_bundle_write; use zed_cli::nix_export_plan; use zed_cli::ops; @@ -267,6 +268,31 @@ fn run(cli: Cli) -> anyhow::Result<()> { asdf_environment::print_import(&imported, json) } }, + EnvCmd::Export { + manager: EnvironmentManagerArg::Mise, + plan, + output, + check, + write, + json, + } => { + if check && write { + anyhow::bail!("the arguments '--check' and '--write' cannot be used together"); + } + let mode = if check { + MiseExportMode::Check + } else if write { + MiseExportMode::Write + } else { + MiseExportMode::Print + }; + let exported = mise_export::export_mise(&cwd, &plan, &output, mode)?; + mise_export::print_export(&exported, json) + } + EnvCmd::Export { + manager: EnvironmentManagerArg::Asdf, + .. + } => anyhow::bail!("asdf export is not implemented; use `zed env export mise`"), EnvCmd::Verify { manager, config, diff --git a/src/mise_export.rs b/src/mise_export.rs new file mode 100644 index 00000000..4392d8ed --- /dev/null +++ b/src/mise_export.rs @@ -0,0 +1,1392 @@ +//! Deterministic, conflict-safe export from a schema-v2 Zed environment plan +//! to project-local mise configuration. +//! +//! Export is intentionally a projection, not an excuse to discard state. Any +//! plan field that cannot be represented faithfully by the certified mise +//! subset fails with an exact path. `--write` uses the project transaction +//! journal and a deterministic sidecar so hand-edited manager files are never +//! overwritten silently. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use anyhow::{Context, Result, bail, ensure}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use zed_interfaces::environment::{ActivationPolicy, EnvironmentValidationMode, ToolRequirement}; +use zed_interfaces::environment_v2::{ + EnvironmentPlanV2, EnvironmentValue, TaskConfirmation, TaskSpec, TaskStep, ToolSpec, + ToolVersion, +}; + +use crate::transaction::{ProjectTransaction, STAGING_DIR}; + +const EXPORT_STATE_PATH: &str = ".zed/mise-export-state.json"; +const EXPORT_STATE_SCHEMA: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MiseExportMode { + Print, + Check, + Write, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MiseExportAction { + Printed, + Verified, + Written, + Unchanged, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MiseExportReport { + pub manager: &'static str, + pub plan: String, + pub output: String, + pub plan_sha256: String, + pub output_sha256: String, + pub action: MiseExportAction, + #[serde(skip_serializing_if = "Option::is_none")] + pub document: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct MiseExportState { + schema: u32, + #[serde(default)] + outputs: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct MiseExportRecord { + plan: String, + plan_sha256: String, + output_sha256: String, +} + +/// Render, verify, or write one project-local mise projection. +pub fn export_mise( + cwd: &Path, + plan_arg: &Path, + output_arg: &Path, + mode: MiseExportMode, +) -> Result { + let root = cwd + .canonicalize() + .with_context(|| format!("failed to resolve project root {}", cwd.display()))?; + let (plan_path, plan_relative) = + resolve_project_path(&root, plan_arg, "environment plan", true)?; + let (output_path, output_relative) = + resolve_project_path(&root, output_arg, "mise output", false)?; + validate_export_path_relationships(&plan_path, &plan_relative, &output_path, &output_relative)?; + + let plan = read_plan(&plan_path)?; + plan.validate(EnvironmentValidationMode::Authoring) + .context("environment plan is not valid for authoring export")?; + let candidate = render_mise_config(&plan)?; + let plan_sha256 = digest_plan(&plan)?; + let output_sha256 = digest_bytes(candidate.as_bytes()); + + match mode { + MiseExportMode::Print => Ok(MiseExportReport { + manager: "mise", + plan: plan_relative, + output: output_relative, + plan_sha256, + output_sha256, + action: MiseExportAction::Printed, + document: Some(candidate), + }), + MiseExportMode::Check => { + let current = read_regular_file(&output_path, "mise output")?.with_context(|| { + format!( + "mise export is missing `{output_relative}`; run `zed env export mise --plan {plan_relative} --output {output_relative} --write`" + ) + })?; + let current_sha256 = digest_bytes(¤t); + ensure!( + current == candidate.as_bytes(), + "mise export drift for `{output_relative}`: current sha256 {current_sha256}, expected {output_sha256}; inspect the diff and run export with --write only when Zed still owns the file" + ); + Ok(MiseExportReport { + manager: "mise", + plan: plan_relative, + output: output_relative, + plan_sha256, + output_sha256, + action: MiseExportAction::Verified, + document: None, + }) + } + MiseExportMode::Write => write_export(MiseWriteRequest { + root: &root, + plan_relative: &plan_relative, + output_path: &output_path, + output_relative: &output_relative, + candidate: &candidate, + plan_sha256: &plan_sha256, + output_sha256: &output_sha256, + }), + } +} + +pub fn print_export(report: &MiseExportReport, json: bool) -> Result<()> { + if json { + println!("{}", serde_json::to_string_pretty(report)?); + return Ok(()); + } + match report.action { + MiseExportAction::Printed => { + print!("{}", report.document.as_deref().unwrap_or_default()); + } + MiseExportAction::Verified => println!( + "verified mise export {} (sha256 {})", + report.output, report.output_sha256 + ), + MiseExportAction::Written => println!( + "wrote mise export {} from {} (sha256 {})", + report.output, report.plan, report.output_sha256 + ), + MiseExportAction::Unchanged => println!( + "mise export unchanged: {} (sha256 {})", + report.output, report.output_sha256 + ), + } + Ok(()) +} + +struct MiseWriteRequest<'a> { + root: &'a Path, + plan_relative: &'a str, + output_path: &'a Path, + output_relative: &'a str, + candidate: &'a str, + plan_sha256: &'a str, + output_sha256: &'a str, +} + +fn write_export(request: MiseWriteRequest<'_>) -> Result { + let MiseWriteRequest { + root, + plan_relative, + output_path, + output_relative, + candidate, + plan_sha256, + output_sha256, + } = request; + let state_path = root.join(EXPORT_STATE_PATH); + ensure_no_symlink_components( + root, + Path::new(EXPORT_STATE_PATH), + "mise export state", + false, + )?; + let mut state = load_state(&state_path)?; + if let Some(existing) = state.outputs.keys().find(|existing| { + existing.eq_ignore_ascii_case(output_relative) && existing.as_str() != output_relative + }) { + bail!( + "portable mise export path collision: `{output_relative}` conflicts with existing state key `{existing}`" + ); + } + let current = read_regular_file(output_path, "mise output")?; + let unchanged = current + .as_deref() + .is_some_and(|bytes| bytes == candidate.as_bytes()); + + if let Some(record) = state.outputs.get(output_relative) { + ensure!( + record.plan == plan_relative, + "refusing to change ownership of `{output_relative}` from plan `{}` to `{plan_relative}`", + record.plan + ); + if let Some(current) = current.as_deref() { + let current_sha256 = digest_bytes(current); + ensure!( + record.output_sha256 == current_sha256, + "refusing to overwrite edited `{output_relative}`: export state records sha256 {}, current file is {current_sha256}", + record.output_sha256 + ); + } + } + + if current.is_some() && !unchanged && !state.outputs.contains_key(output_relative) { + bail!( + "refusing to overwrite hand-authored `{output_relative}`: no Zed export state exists; move it aside, choose another --output, or make its contents match the deterministic projection before adopting it" + ); + } + + state.outputs.insert( + output_relative.to_string(), + MiseExportRecord { + plan: plan_relative.to_string(), + plan_sha256: plan_sha256.to_string(), + output_sha256: output_sha256.to_string(), + }, + ); + state.schema = EXPORT_STATE_SCHEMA; + let mut encoded_state = serde_json::to_vec_pretty(&state)?; + encoded_state.push(b'\n'); + + let mut transaction = ProjectTransaction::begin(root)?; + if !unchanged { + transaction.backup(output_path)?; + } + transaction.backup(&state_path)?; + + if !unchanged { + fs::create_dir_all( + output_path + .parent() + .context("mise output must have a project-local parent")?, + )?; + fs::write(output_path, candidate.as_bytes()) + .with_context(|| format!("writing mise export {}", output_path.display()))?; + } + fs::create_dir_all( + state_path + .parent() + .context("mise export state must have a parent")?, + )?; + fs::write(&state_path, encoded_state) + .with_context(|| format!("writing mise export state {}", state_path.display()))?; + transaction.commit()?; + + Ok(MiseExportReport { + manager: "mise", + plan: plan_relative.to_string(), + output: output_relative.to_string(), + plan_sha256: plan_sha256.to_string(), + output_sha256: output_sha256.to_string(), + action: if unchanged { + MiseExportAction::Unchanged + } else { + MiseExportAction::Written + }, + document: None, + }) +} + +fn validate_export_path_relationships( + plan_path: &Path, + plan_relative: &str, + output_path: &Path, + output_relative: &str, +) -> Result<()> { + let plan_folded = plan_relative.to_ascii_lowercase(); + let output_folded = output_relative.to_ascii_lowercase(); + ensure!( + output_folded != plan_folded, + "mise output `{output_relative}` cannot overwrite its source environment plan" + ); + ensure!( + output_folded != EXPORT_STATE_PATH.to_ascii_lowercase(), + "mise output cannot target reserved export state `{EXPORT_STATE_PATH}`" + ); + let staging_folded = STAGING_DIR.to_ascii_lowercase(); + let staging_prefix = format!("{staging_folded}/"); + for (kind, relative, folded) in [ + ("environment plan", plan_relative, plan_folded.as_str()), + ("mise output", output_relative, output_folded.as_str()), + ] { + ensure!( + folded != staging_folded && !folded.starts_with(&staging_prefix), + "{kind} cannot target reserved transaction staging `{STAGING_DIR}`: `{relative}`" + ); + } + + if output_path.exists() { + let canonical_output = output_path + .canonicalize() + .with_context(|| format!("failed to resolve mise output {}", output_path.display()))?; + let canonical_plan = plan_path.canonicalize().with_context(|| { + format!("failed to resolve environment plan {}", plan_path.display()) + })?; + ensure!( + canonical_output != canonical_plan, + "mise output resolves to its source environment plan: {}", + output_path.display() + ); + } + Ok(()) +} + +fn read_plan(path: &Path) -> Result { + let input = fs::read_to_string(path) + .with_context(|| format!("failed to read environment plan {}", path.display()))?; + match path.extension().and_then(|extension| extension.to_str()) { + Some("json") => EnvironmentPlanV2::parse_json(&input) + .with_context(|| format!("failed to parse JSON environment plan {}", path.display())), + Some("toml") => EnvironmentPlanV2::parse_toml(&input) + .with_context(|| format!("failed to parse TOML environment plan {}", path.display())), + other => bail!( + "environment plan {} must use a .toml or .json extension, got {}", + path.display(), + other.unwrap_or("") + ), + } +} + +fn render_mise_config(plan: &EnvironmentPlanV2) -> Result { + let plan = plan.normalized(); + ensure!( + plan.activation == ActivationPolicy::None, + "unsupported environment plan field `activation`: mise hook export is intentionally disabled; use activation = \"none\" until explicit trust-bound hook export is certified" + ); + ensure!( + plan.system_packages.is_empty(), + "unsupported environment plan field `system-packages`: package-provider translation is not yet lossless" + ); + ensure!( + plan.sources.is_empty(), + "unsupported environment plan field `sources`: manager provenance belongs in the export state/lock integration and cannot be written into mise.toml losslessly yet" + ); + ensure!( + plan.extensions.is_empty(), + "unsupported environment plan field `extensions`: manager extension fields require an explicit certified mapping" + ); + + let mut root = toml::Table::new(); + if !plan.tools.is_empty() { + root.insert( + "tools".to_string(), + toml::Value::Table(export_tool_map(&plan.tools, "tools")?), + ); + } + if !plan.env.is_empty() { + root.insert( + "env".to_string(), + toml::Value::Table(export_value_map(&plan.env, "env", false, true)?), + ); + } + if !plan.vars.is_empty() { + root.insert( + "vars".to_string(), + toml::Value::Table(export_value_map(&plan.vars, "vars", true, true)?), + ); + } + if !plan.tasks.is_empty() { + let mut tasks = toml::Table::new(); + for (name, task) in &plan.tasks { + tasks.insert(name.clone(), toml::Value::Table(export_task(name, task)?)); + } + root.insert("tasks".to_string(), toml::Value::Table(tasks)); + } + if !plan.platforms.is_empty() { + let mut platforms = plan.platforms.clone(); + platforms.sort(); + platforms.dedup(); + let mut settings = toml::Table::new(); + settings.insert("lockfile".to_string(), toml::Value::Boolean(true)); + settings.insert( + "lockfile_platforms".to_string(), + toml::Value::Array(platforms.into_iter().map(toml::Value::String).collect()), + ); + root.insert("settings".to_string(), toml::Value::Table(settings)); + } + + ensure!( + !root.is_empty(), + "environment plan has no mise-exportable tools, env, vars, tasks, or platforms" + ); + let mut output = toml::to_string_pretty(&toml::Value::Table(root))?; + if !output.ends_with('\n') { + output.push('\n'); + } + Ok(output) +} + +fn export_tool_map(tools: &BTreeMap, field: &str) -> Result { + let mut output = toml::Table::new(); + for (logical_name, spec) in tools { + let versions = spec.versions(); + ensure!( + !versions.is_empty(), + "unsupported empty tool list `{field}.{logical_name}`" + ); + let mut key: Option = None; + let mut values = Vec::with_capacity(versions.len()); + for (index, version) in versions.iter().enumerate() { + let version_field = format!("{field}.{logical_name}.versions[{index}]"); + let candidate_key = mise_tool_key(logical_name, &version.requirement, &version_field)?; + if let Some(expected) = &key { + ensure!( + expected == &candidate_key, + "`{field}.{logical_name}` uses multiple backend identities (`{expected}` and `{candidate_key}`) that cannot share one mise tool key" + ); + } else { + key = Some(candidate_key); + } + values.push(export_tool_version(version, &version_field)?); + } + let key = key.context("tool key was not derived")?; + ensure!( + !output.contains_key(&key), + "mise tool-key collision: environment plan tool `{logical_name}` maps to already exported key `{key}`" + ); + let value = if values.len() == 1 { + values.remove(0) + } else { + toml::Value::Array(values) + }; + output.insert(key, value); + } + Ok(output) +} + +fn mise_tool_key(name: &str, requirement: &ToolRequirement, field: &str) -> Result { + if let Some(provider) = &requirement.provider { + ensure_clean(provider, &format!("{field}.provider"))?; + } + let key = match &requirement.backend { + None => { + ensure!( + requirement.provider.is_none(), + "`{field}.provider` cannot be exported without an exact backend identity" + ); + name.to_string() + } + Some(backend) => { + ensure_clean(backend, &format!("{field}.backend"))?; + if let Some(provider) = &requirement.provider { + let actual = backend.split_once(':').map(|(prefix, _)| prefix); + ensure!( + actual == Some(provider.as_str()), + "`{field}.provider` `{provider}` disagrees with backend `{backend}`" + ); + } + if backend == &format!("core:{name}") { + name.to_string() + } else { + backend.clone() + } + } + }; + ensure_clean(&key, field)?; + Ok(key) +} + +fn export_tool_version(version: &ToolVersion, field: &str) -> Result { + let requirement = &version.requirement; + ensure_clean(&requirement.requirement, &format!("{field}.requirement"))?; + ensure!( + requirement.resolved.is_none(), + "unsupported `{field}.resolved`: exact manager results belong in mise.lock export, not mise.toml" + ); + ensure!( + requirement.source.is_none(), + "unsupported `{field}.source`: immutable source identity belongs in lock export" + ); + ensure!( + requirement.checksums.is_empty(), + "unsupported `{field}.checksums`: artifact checksums belong in lock export" + ); + ensure!( + version.extensions.is_empty(), + "unsupported `{field}.extensions`: no certified mise mapping exists" + ); + validate_selector_requirement(&requirement.requirement, field)?; + + if version.options.is_empty() && requirement.platforms.is_empty() { + return Ok(toml::Value::String(requirement.requirement.clone())); + } + + let mut table = toml::Table::new(); + insert_selector(&mut table, &requirement.requirement, field)?; + for (name, value) in &version.options { + ensure_clean(name, &format!("{field}.options key"))?; + ensure!( + !is_sensitive_key(name), + "refusing to serialize sensitive tool option `{field}.options.{name}`" + ); + ensure!( + !table.contains_key(name), + "`{field}.options.{name}` collides with a mise selector field" + ); + table.insert( + name.clone(), + environment_value_to_toml(value, &format!("{field}.options.{name}"), true)?, + ); + } + if !requirement.platforms.is_empty() { + ensure!( + !table.contains_key("os"), + "`{field}.options.os` conflicts with `{field}.platforms`" + ); + let mut platforms = requirement.platforms.clone(); + platforms.sort(); + platforms.dedup(); + table.insert( + "os".to_string(), + toml::Value::Array(platforms.into_iter().map(toml::Value::String).collect()), + ); + } + Ok(toml::Value::Table(table)) +} + +fn validate_selector_requirement(requirement: &str, field: &str) -> Result<()> { + for selector in ["path", "prefix", "ref", "env"] { + let prefix = format!("{selector}:"); + if let Some(value) = requirement.strip_prefix(&prefix) { + let selector_field = format!("{field}.{selector}"); + ensure_clean(value, &selector_field)?; + if selector == "path" { + validate_relative_argument(Path::new(value), &selector_field)?; + } + return Ok(()); + } + } + Ok(()) +} + +fn insert_selector(table: &mut toml::Table, requirement: &str, field: &str) -> Result<()> { + for selector in ["path", "prefix", "ref"] { + let prefix = format!("{selector}:"); + if let Some(value) = requirement.strip_prefix(&prefix) { + ensure_clean(value, &format!("{field}.{selector}"))?; + table.insert(selector.to_string(), toml::Value::String(value.to_string())); + return Ok(()); + } + } + ensure!( + !requirement.starts_with("env:"), + "`{field}.requirement` uses `env:` with table options; current mise table selectors cannot represent that combination losslessly" + ); + table.insert( + "version".to_string(), + toml::Value::String(requirement.to_string()), + ); + Ok(()) +} + +fn export_task(name: &str, task: &TaskSpec) -> Result { + let field = format!("tasks.{name}"); + ensure!( + task.extensions.is_empty(), + "unsupported `{field}.extensions`: no certified mise mapping exists" + ); + let mut output = toml::Table::new(); + insert_optional_string(&mut output, "description", task.description.as_deref()); + insert_strings(&mut output, "alias", &task.aliases); + insert_task_steps(&mut output, "run", &task.run, &field)?; + insert_task_steps(&mut output, "run_windows", &task.run_windows, &field)?; + insert_strings(&mut output, "depends", &task.depends); + insert_strings(&mut output, "depends_post", &task.depends_post); + insert_strings(&mut output, "wait_for", &task.wait_for); + if !task.env.is_empty() { + output.insert( + "env".to_string(), + toml::Value::Table(export_value_map( + &task.env, + &format!("{field}.env"), + false, + true, + )?), + ); + } + if !task.vars.is_empty() { + output.insert( + "vars".to_string(), + toml::Value::Table(export_value_map( + &task.vars, + &format!("{field}.vars"), + true, + true, + )?), + ); + } + if !task.tools.is_empty() { + output.insert( + "tools".to_string(), + toml::Value::Table(export_tool_map(&task.tools, &format!("{field}.tools"))?), + ); + } + insert_optional_string(&mut output, "dir", task.dir.as_deref()); + insert_strings(&mut output, "sources", &task.sources); + insert_strings(&mut output, "outputs", &task.outputs); + match task.shell.as_slice() { + [] => {} + [shell] => { + output.insert("shell".to_string(), toml::Value::String(shell.clone())); + } + _ => bail!( + "unsupported `{field}.shell`: current mise task TOML accepts one shell string, but the plan contains a program-plus-arguments vector" + ), + } + insert_optional_string(&mut output, "usage", task.usage.as_deref()); + if let Some(confirm) = &task.confirm { + output.insert( + "confirm".to_string(), + match confirm { + TaskConfirmation::Enabled(value) => toml::Value::Boolean(*value), + TaskConfirmation::Prompt(value) => toml::Value::String(value.clone()), + }, + ); + } + if let Some(cache) = task.cache { + output.insert("cache".to_string(), toml::Value::Boolean(cache)); + } + insert_optional_string(&mut output, "timeout", task.timeout.as_deref()); + insert_true(&mut output, "hide", task.hide); + insert_true(&mut output, "quiet", task.quiet); + insert_true(&mut output, "silent", task.silent); + insert_true(&mut output, "raw", task.raw); + Ok(output) +} + +fn insert_task_steps( + output: &mut toml::Table, + key: &str, + steps: &[TaskStep], + field: &str, +) -> Result<()> { + if steps.is_empty() { + return Ok(()); + } + let commands = steps + .iter() + .enumerate() + .map(|(index, step)| match step { + TaskStep::Command(command) => Ok(command.clone()), + TaskStep::Task(invocation) => bail!( + "unsupported `{field}.{key}[{index}]`: task invocation `{}` requires a certified mise run-entry mapping", + invocation.task + ), + TaskStep::Tasks(_) => bail!( + "unsupported `{field}.{key}[{index}]`: grouped task invocation requires a certified mise run-entry mapping" + ), + }) + .collect::>>()?; + output.insert( + key.to_string(), + if commands.len() == 1 { + toml::Value::String(commands[0].clone()) + } else { + toml::Value::Array(commands.into_iter().map(toml::Value::String).collect()) + }, + ); + Ok(()) +} + +fn export_value_map( + values: &BTreeMap, + field: &str, + allow_complex: bool, + reject_sensitive: bool, +) -> Result { + let mut output = toml::Table::new(); + for (name, value) in values { + ensure_clean(name, &format!("{field} key"))?; + if reject_sensitive { + ensure!( + !is_sensitive_key(name), + "refusing to serialize literal sensitive field `{field}.{name}`; use a future secret-provider reference instead of committed plaintext" + ); + } + output.insert( + name.clone(), + environment_value_to_toml(value, &format!("{field}.{name}"), allow_complex)?, + ); + } + Ok(output) +} + +fn environment_value_to_toml( + value: &EnvironmentValue, + field: &str, + allow_complex: bool, +) -> Result { + Ok(match value { + EnvironmentValue::String(value) => toml::Value::String(value.clone()), + EnvironmentValue::Integer(value) => toml::Value::Integer(*value), + EnvironmentValue::Float(value) => { + ensure!(value.is_finite(), "`{field}` contains a non-finite float"); + toml::Value::Float(*value) + } + EnvironmentValue::Boolean(value) => toml::Value::Boolean(*value), + EnvironmentValue::Array(values) => { + ensure!( + allow_complex, + "unsupported complex environment value `{field}`: arrays/tables are reserved for manager directives and cannot be emitted as literal env values safely" + ); + toml::Value::Array( + values + .iter() + .enumerate() + .map(|(index, value)| { + environment_value_to_toml(value, &format!("{field}[{index}]"), true) + }) + .collect::>>()?, + ) + } + EnvironmentValue::Table(values) => { + ensure!( + allow_complex, + "unsupported complex environment value `{field}`: arrays/tables are reserved for manager directives and cannot be emitted as literal env values safely" + ); + toml::Value::Table(export_value_map(values, field, true, true)?) + } + }) +} + +fn insert_optional_string(output: &mut toml::Table, key: &str, value: Option<&str>) { + if let Some(value) = value { + output.insert(key.to_string(), toml::Value::String(value.to_string())); + } +} + +fn insert_strings(output: &mut toml::Table, key: &str, values: &[String]) { + if !values.is_empty() { + output.insert( + key.to_string(), + toml::Value::Array(values.iter().cloned().map(toml::Value::String).collect()), + ); + } +} + +fn insert_true(output: &mut toml::Table, key: &str, value: bool) { + if value { + output.insert(key.to_string(), toml::Value::Boolean(true)); + } +} + +fn is_sensitive_key(name: &str) -> bool { + let normalized = name.to_ascii_uppercase(); + [ + "PASSWORD", + "PASSWD", + "SECRET", + "TOKEN", + "PRIVATE_KEY", + "ACCESS_KEY", + "API_KEY", + "CREDENTIAL", + "AUTHORIZATION", + ] + .iter() + .any(|needle| normalized.contains(needle)) +} + +fn ensure_clean(value: &str, field: &str) -> Result<()> { + ensure!( + value == value.trim() && !value.is_empty(), + "`{field}` must be non-empty and trimmed" + ); + ensure!( + !value.chars().any(char::is_control), + "`{field}` must contain no control characters" + ); + Ok(()) +} + +fn digest_plan(plan: &EnvironmentPlanV2) -> Result { + let mut hasher = Sha256::new(); + hasher.update(b"zed-pkg:mise-export-plan:v1\0"); + hasher.update(plan.canonical_json_bytes()?); + Ok(hex::encode(hasher.finalize())) +} + +fn digest_bytes(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn load_state(path: &Path) -> Result { + let Some(bytes) = read_regular_file(path, "mise export state")? else { + return Ok(MiseExportState { + schema: EXPORT_STATE_SCHEMA, + outputs: BTreeMap::new(), + }); + }; + let state: MiseExportState = serde_json::from_slice(&bytes) + .with_context(|| format!("invalid mise export state {}", path.display()))?; + ensure!( + state.schema == EXPORT_STATE_SCHEMA, + "unsupported mise export state schema {} in {}; supported schema is {}", + state.schema, + path.display(), + EXPORT_STATE_SCHEMA + ); + for (output, record) in &state.outputs { + validate_state_path(output, "state output")?; + validate_state_path(&record.plan, "state plan")?; + validate_sha256(&record.plan_sha256, "state plan_sha256")?; + validate_sha256(&record.output_sha256, "state output_sha256")?; + } + Ok(state) +} + +fn validate_sha256(value: &str, field: &str) -> Result<()> { + ensure!( + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()), + "`{field}` must be a 64-character hexadecimal SHA-256" + ); + Ok(()) +} + +fn read_regular_file(path: &Path, kind: &str) -> Result>> { + match fs::symlink_metadata(path) { + Ok(metadata) => { + ensure!( + metadata.file_type().is_file() && !metadata.file_type().is_symlink(), + "{kind} must be a regular non-symlink file: {}", + path.display() + ); + fs::read(path) + .map(Some) + .with_context(|| format!("failed to read {kind} {}", path.display())) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => { + Err(error).with_context(|| format!("failed to inspect {kind} {}", path.display())) + } + } +} + +fn resolve_project_path( + root: &Path, + requested: &Path, + kind: &str, + must_exist: bool, +) -> Result<(PathBuf, String)> { + let relative = validate_relative_argument(requested, kind)?; + ensure_no_symlink_components(root, &relative, kind, must_exist)?; + let path = root.join(&relative); + if must_exist { + let metadata = fs::symlink_metadata(&path) + .with_context(|| format!("{kind} does not exist: {}", path.display()))?; + ensure!( + metadata.file_type().is_file() && !metadata.file_type().is_symlink(), + "{kind} must be a regular non-symlink file: {}", + path.display() + ); + } else if let Ok(metadata) = fs::symlink_metadata(&path) { + ensure!( + metadata.file_type().is_file() && !metadata.file_type().is_symlink(), + "{kind} must be a regular non-symlink file: {}", + path.display() + ); + } + let portable = relative + .components() + .map(|component| match component { + Component::Normal(value) => value + .to_str() + .context("export paths must be valid UTF-8") + .map(ToOwned::to_owned), + _ => bail!("{kind} must be normalized and project-relative"), + }) + .collect::>>()? + .join("/"); + Ok((path, portable)) +} + +fn validate_relative_argument(path: &Path, kind: &str) -> Result { + let raw = path.to_string_lossy(); + let windows_drive = raw.as_bytes().get(1).is_some_and(|byte| *byte == b':') + && raw.as_bytes().first().is_some_and(u8::is_ascii_alphabetic); + ensure!( + !path.is_absolute() + && !windows_drive + && !raw.starts_with("//") + && !raw.starts_with("\\\\") + && !raw.starts_with('~') + && !raw.starts_with("$HOME") + && !raw.starts_with("${HOME}") + && !raw.starts_with("%USERPROFILE%"), + "{kind} must be project-relative on every supported platform: {}", + path.display() + ); + ensure!( + !raw.split(['/', '\\']).any(|part| part == ".."), + "{kind} must be project-relative and cannot escape the project root: {}", + path.display() + ); + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(value) => normalized.push(value), + _ => bail!( + "{kind} must contain only normalized project-relative components: {}", + path.display() + ), + } + } + ensure!( + !normalized.as_os_str().is_empty(), + "{kind} must name a project file" + ); + Ok(normalized) +} + +fn validate_state_path(path: &str, field: &str) -> Result<()> { + validate_relative_argument(Path::new(path), field).map(|_| ()) +} + +fn ensure_no_symlink_components( + root: &Path, + relative: &Path, + kind: &str, + require_leaf: bool, +) -> Result<()> { + let mut current = root.to_path_buf(); + let components = relative.components().collect::>(); + let mut missing_suffix = false; + for (index, component) in components.iter().enumerate() { + let Component::Normal(value) = component else { + bail!("{kind} must be normalized and project-relative"); + }; + current.push(value); + let leaf = index + 1 == components.len(); + if missing_suffix { + continue; + } + match fs::symlink_metadata(¤t) { + Ok(metadata) => { + ensure!( + !metadata.file_type().is_symlink(), + "{kind} crosses a symlink at {}", + current.display() + ); + if !leaf { + ensure!( + metadata.is_dir(), + "{kind} parent is not a directory: {}", + current.display() + ); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if require_leaf { + bail!("{kind} does not exist: {}", current.display()); + } + missing_suffix = true; + } + Err(error) => { + return Err(error) + .with_context(|| format!("failed to inspect {kind} {}", current.display())); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use zed_interfaces::environment::{EnvironmentManager, EnvironmentSource}; + use zed_interfaces::environment_v2::{TaskGroup, TaskInvocation, ToolVersion}; + + fn write_plan(root: &Path, plan: &EnvironmentPlanV2) -> PathBuf { + let path = root.join("zed-env.toml"); + fs::write(&path, plan.to_toml_string().unwrap()).unwrap(); + PathBuf::from("zed-env.toml") + } + + fn simple_plan() -> EnvironmentPlanV2 { + let mut plan = EnvironmentPlanV2::default(); + plan.tools.insert( + "node".to_string(), + ToolSpec::Many(vec![ + ToolVersion::new(ToolRequirement { + requirement: "22.4.0".to_string(), + resolved: None, + provider: None, + backend: None, + source: None, + checksums: Vec::new(), + platforms: Vec::new(), + }), + ToolVersion::new(ToolRequirement { + requirement: "20.15.1".to_string(), + resolved: None, + provider: None, + backend: None, + source: None, + checksums: Vec::new(), + platforms: Vec::new(), + }), + ]), + ); + plan.env.insert( + "APP_ENV".to_string(), + EnvironmentValue::String("test".to_string()), + ); + plan.vars.insert( + "release".to_string(), + EnvironmentValue::Table(BTreeMap::from([ + ( + "channel".to_string(), + EnvironmentValue::String("stable".to_string()), + ), + ("retries".to_string(), EnvironmentValue::Integer(3)), + ])), + ); + plan.tasks.insert( + "setup".to_string(), + TaskSpec { + description: Some("Restore dependencies".to_string()), + aliases: vec!["bootstrap".to_string()], + run: vec![ + TaskStep::Command("zed install --frozen".to_string()), + TaskStep::Command("cargo check".to_string()), + ], + depends: vec!["prepare".to_string()], + env: BTreeMap::from([("RUST_BACKTRACE".to_string(), EnvironmentValue::Integer(1))]), + ..TaskSpec::default() + }, + ); + plan.tasks.insert( + "prepare".to_string(), + TaskSpec { + run: vec![TaskStep::Command("echo prepare".to_string())], + ..TaskSpec::default() + }, + ); + plan.platforms = vec!["linux-x64".to_string(), "macos-arm64".to_string()]; + plan + } + + #[test] + fn deterministic_export_preserves_multi_version_and_command_order() { + let plan = simple_plan(); + let first = render_mise_config(&plan).unwrap(); + let second = render_mise_config(&plan).unwrap(); + assert_eq!(first, second); + let value: toml::Value = toml::from_str(&first).unwrap(); + let versions = value["tools"]["node"].as_array().unwrap(); + assert_eq!(versions[0].as_str(), Some("22.4.0")); + assert_eq!(versions[1].as_str(), Some("20.15.1")); + let commands = value["tasks"]["setup"]["run"].as_array().unwrap(); + assert_eq!(commands[0].as_str(), Some("zed install --frozen")); + assert_eq!(commands[1].as_str(), Some("cargo check")); + } + + #[test] + fn set_like_presentation_order_does_not_change_semantic_output() { + let mut first = simple_plan(); + first.platforms = vec![ + "macos-arm64".to_string(), + "linux-x64".to_string(), + "macos-arm64".to_string(), + ]; + first.tasks.get_mut("setup").unwrap().aliases = vec![ + "z-bootstrap".to_string(), + "a-bootstrap".to_string(), + "z-bootstrap".to_string(), + ]; + first.tasks.get_mut("setup").unwrap().depends = + vec!["prepare".to_string(), "prepare".to_string()]; + + let mut second = first.clone(); + second.platforms.reverse(); + second.tasks.get_mut("setup").unwrap().aliases.reverse(); + second.tasks.get_mut("setup").unwrap().depends.reverse(); + + assert_eq!( + render_mise_config(&first).unwrap(), + render_mise_config(&second).unwrap() + ); + assert_eq!(digest_plan(&first).unwrap(), digest_plan(&second).unwrap()); + } + + #[test] + fn write_check_and_unchanged_state_are_conflict_safe() { + let temp = tempfile::tempdir().unwrap(); + let plan_path = write_plan(temp.path(), &simple_plan()); + let output = Path::new(".mise.toml"); + let written = export_mise(temp.path(), &plan_path, output, MiseExportMode::Write).unwrap(); + assert_eq!(written.action, MiseExportAction::Written); + assert!(temp.path().join(EXPORT_STATE_PATH).is_file()); + + let verified = export_mise(temp.path(), &plan_path, output, MiseExportMode::Check).unwrap(); + assert_eq!(verified.action, MiseExportAction::Verified); + + let unchanged = + export_mise(temp.path(), &plan_path, output, MiseExportMode::Write).unwrap(); + assert_eq!(unchanged.action, MiseExportAction::Unchanged); + + let collision = export_mise( + temp.path(), + &plan_path, + Path::new(".MISE.TOML"), + MiseExportMode::Write, + ) + .unwrap_err(); + assert!( + collision + .to_string() + .contains("portable mise export path collision") + ); + } + + #[test] + fn ownership_cannot_transfer_or_repair_state_silently() { + let temp = tempfile::tempdir().unwrap(); + let plan = simple_plan(); + let plan_path = write_plan(temp.path(), &plan); + let output = Path::new(".mise.toml"); + export_mise(temp.path(), &plan_path, output, MiseExportMode::Write).unwrap(); + + let alternate_plan = temp.path().join("alternate-env.toml"); + fs::write(&alternate_plan, plan.to_toml_string().unwrap()).unwrap(); + let output_before = fs::read(temp.path().join(output)).unwrap(); + let state_path = temp.path().join(EXPORT_STATE_PATH); + let state_before = fs::read(&state_path).unwrap(); + let transfer = export_mise( + temp.path(), + Path::new("alternate-env.toml"), + output, + MiseExportMode::Write, + ) + .unwrap_err(); + assert!(transfer.to_string().contains("change ownership")); + assert_eq!(fs::read(temp.path().join(output)).unwrap(), output_before); + assert_eq!(fs::read(&state_path).unwrap(), state_before); + + let mut state: MiseExportState = serde_json::from_slice(&state_before).unwrap(); + state.outputs.get_mut(".mise.toml").unwrap().output_sha256 = "0".repeat(64); + let mut corrupted = serde_json::to_vec_pretty(&state).unwrap(); + corrupted.push(10); + fs::write(&state_path, &corrupted).unwrap(); + + let repair = + export_mise(temp.path(), &plan_path, output, MiseExportMode::Write).unwrap_err(); + assert!(repair.to_string().contains("export state records sha256")); + assert_eq!(fs::read(temp.path().join(output)).unwrap(), output_before); + assert_eq!(fs::read(&state_path).unwrap(), corrupted); + } + + #[test] + fn hand_edits_and_unowned_outputs_are_never_overwritten() { + let temp = tempfile::tempdir().unwrap(); + let plan_path = write_plan(temp.path(), &simple_plan()); + let output = temp.path().join(".mise.toml"); + fs::write(&output, "[tools]\nnode = \"18\"\n").unwrap(); + let error = export_mise( + temp.path(), + &plan_path, + Path::new(".mise.toml"), + MiseExportMode::Write, + ) + .unwrap_err(); + assert!(error.to_string().contains("hand-authored")); + + fs::remove_file(&output).unwrap(); + export_mise( + temp.path(), + &plan_path, + Path::new(".mise.toml"), + MiseExportMode::Write, + ) + .unwrap(); + fs::write(&output, "# manual edit\n").unwrap(); + let error = export_mise( + temp.path(), + &plan_path, + Path::new(".mise.toml"), + MiseExportMode::Write, + ) + .unwrap_err(); + assert!(error.to_string().contains("edited")); + } + + #[test] + fn lock_only_and_manager_extension_state_fail_with_exact_paths() { + let mut plan = simple_plan(); + plan.tools.get_mut("node").unwrap().versions_mut()[0] + .requirement + .resolved = Some("22.4.0".to_string()); + let error = render_mise_config(&plan).unwrap_err(); + assert!( + error + .to_string() + .contains("tools.node.versions[0].resolved") + ); + + let mut plan = simple_plan(); + plan.sources.push(EnvironmentSource { + manager: EnvironmentManager::Mise, + path: "mise.toml".to_string(), + lock_path: None, + digest: None, + }); + let error = render_mise_config(&plan).unwrap_err(); + assert!(error.to_string().contains("`sources`")); + } + + #[test] + fn sensitive_literals_and_complex_env_directives_fail_closed() { + let mut plan = simple_plan(); + plan.env.insert( + "API_TOKEN".to_string(), + EnvironmentValue::String("plaintext".to_string()), + ); + assert!( + render_mise_config(&plan) + .unwrap_err() + .to_string() + .contains("sensitive") + ); + + let mut plan = simple_plan(); + plan.env.insert( + "PATH_PARTS".to_string(), + EnvironmentValue::Array(vec![EnvironmentValue::String("bin".to_string())]), + ); + assert!( + render_mise_config(&plan) + .unwrap_err() + .to_string() + .contains("complex environment value") + ); + + let mut plan = simple_plan(); + plan.vars.insert( + "release".to_string(), + EnvironmentValue::Table(BTreeMap::from([( + "api_token".to_string(), + EnvironmentValue::String("plaintext".to_string()), + )])), + ); + assert!( + render_mise_config(&plan) + .unwrap_err() + .to_string() + .contains("vars.release.api_token") + ); + } + + #[test] + fn path_selectors_are_portable_even_on_the_scalar_fast_path() { + for requirement in [ + "path:../tool", + "path:/opt/tool", + r"path:C:\\tool", + r"path:\\\\server\\share\\tool", + "path:~/tool", + "path:$HOME/tool", + "path:${HOME}/tool", + "path:%USERPROFILE%/tool", + ] { + let mut plan = simple_plan(); + plan.tools.get_mut("node").unwrap().versions_mut()[0] + .requirement + .requirement = requirement.to_string(); + let error = render_mise_config(&plan).unwrap_err().to_string(); + assert!( + error.contains("project-relative") || error.contains("cannot escape"), + "unexpected error for {requirement}: {error}" + ); + } + + let mut plan = simple_plan(); + plan.tools.get_mut("node").unwrap().versions_mut()[0] + .requirement + .requirement = "path:vendor/node".to_string(); + let rendered = render_mise_config(&plan).unwrap(); + let value: toml::Value = toml::from_str(&rendered).unwrap(); + let versions = value["tools"]["node"].as_array().unwrap(); + assert_eq!(versions[0].as_str(), Some("path:vendor/node")); + assert_eq!(versions[1].as_str(), Some("20.15.1")); + } + + #[test] + fn task_invocations_and_shell_argument_vectors_fail_closed() { + let mut plan = simple_plan(); + plan.tasks.get_mut("setup").unwrap().run = vec![TaskStep::Task(TaskInvocation { + task: "prepare".to_string(), + args: Vec::new(), + env: BTreeMap::new(), + })]; + assert!( + render_mise_config(&plan) + .unwrap_err() + .to_string() + .contains("task invocation") + ); + + let mut plan = simple_plan(); + plan.tasks.get_mut("setup").unwrap().run = vec![TaskStep::Tasks(TaskGroup { + tasks: vec!["prepare".to_string()], + parallel: true, + })]; + assert!( + render_mise_config(&plan) + .unwrap_err() + .to_string() + .contains("grouped task invocation") + ); + + let mut plan = simple_plan(); + plan.tasks.get_mut("setup").unwrap().shell = vec!["bash".to_string(), "-c".to_string()]; + assert!( + render_mise_config(&plan) + .unwrap_err() + .to_string() + .contains("program-plus-arguments") + ); + } + + #[test] + fn export_paths_reject_parent_drive_unc_and_symlink_escape() { + let temp = tempfile::tempdir().unwrap(); + let plan_path = write_plan(temp.path(), &simple_plan()); + for output in [ + "../mise.toml", + "C:\\mise.toml", + "\\\\server\\share\\mise.toml", + "zed-env.toml", + "ZED-ENV.TOML", + ".zed/mise-export-state.json", + ".ZED/MISE-EXPORT-STATE.JSON", + ".zpkg-staging/mise.toml", + ".ZPKG-STAGING/mise.toml", + ] { + assert!( + export_mise( + temp.path(), + &plan_path, + Path::new(output), + MiseExportMode::Write, + ) + .is_err() + ); + } + + #[cfg(unix)] + { + std::os::unix::fs::symlink(temp.path(), temp.path().join("linked")).unwrap(); + assert!( + export_mise( + temp.path(), + &plan_path, + Path::new("linked/mise.toml"), + MiseExportMode::Write, + ) + .unwrap_err() + .to_string() + .contains("symlink") + ); + + fs::remove_file(temp.path().join("linked")).unwrap(); + std::os::unix::fs::symlink(temp.path(), temp.path().join(".zed")).unwrap(); + assert!( + export_mise( + temp.path(), + &plan_path, + Path::new("mise.toml"), + MiseExportMode::Write, + ) + .unwrap_err() + .to_string() + .contains("mise export state crosses a symlink") + ); + } + } +} diff --git a/tests/mise_export_api_boundaries.rs b/tests/mise_export_api_boundaries.rs new file mode 100644 index 00000000..2790134d --- /dev/null +++ b/tests/mise_export_api_boundaries.rs @@ -0,0 +1,181 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time::Duration; + +use zed_cli::mise_export::{MiseExportMode, export_mise}; +use zed_cli::project_lock; +use zed_cli::transaction::{ProjectTransaction, STAGING_DIR}; + +fn zed_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_zed")) +} + +fn valid_plan_json() -> &'static str { + r#"{ + "schema": 2, + "tools": {"node": [{"requirement": "22.4.0"}]}, + "env": {}, + "vars": {}, + "tasks": {}, + "platforms": ["linux-x64"], + "activation": "none" +} +"# +} + +fn spawn_export(project: &Path, home: &Path) -> Child { + let empty_path = home.join("empty-path"); + fs::create_dir_all(&empty_path).unwrap(); + let mut command = Command::new(zed_bin()); + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("ZED_PKG_") { + command.env_remove(key); + } + } + command + .args([ + "env", + "export", + "mise", + "--plan", + "plan.json", + "--output", + "mise.toml", + "--write", + "--json", + ]) + .current_dir(project) + .env("HOME", home) + .env("USERPROFILE", home) + .env("XDG_CONFIG_HOME", home.join(".config")) + .env("ZED_PKG_HOME", home.join(".zed-pkg")) + .env("PATH", empty_path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap() +} + +fn assert_child_success(child: Child) { + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "export failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn public_api_rejects_reserved_export_state_as_the_plan() { + let temp = tempfile::tempdir().unwrap(); + let reserved = temp.path().join(".zed/mise-export-state.json"); + fs::create_dir_all(reserved.parent().unwrap()).unwrap(); + fs::write(&reserved, valid_plan_json()).unwrap(); + + let error = export_mise( + temp.path(), + Path::new(".zed/mise-export-state.json"), + Path::new("mise.toml"), + MiseExportMode::Print, + ) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("environment plan cannot target reserved export state"), + "unexpected error: {error:#}" + ); + assert!(!temp.path().join("mise.toml").exists()); + assert_eq!(fs::read_to_string(reserved).unwrap(), valid_plan_json()); +} + +#[cfg(unix)] +#[test] +fn public_api_rejects_a_symlink_to_reserved_export_state() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let reserved = temp.path().join(".zed/mise-export-state.json"); + fs::create_dir_all(reserved.parent().unwrap()).unwrap(); + fs::write(&reserved, valid_plan_json()).unwrap(); + symlink(&reserved, temp.path().join("plan.json")).unwrap(); + + let error = export_mise( + temp.path(), + Path::new("plan.json"), + Path::new("mise.toml"), + MiseExportMode::Print, + ) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("environment plan cannot target reserved export state"), + "unexpected error: {error:#}" + ); + assert!(!temp.path().join("mise.toml").exists()); +} + +#[test] +fn public_write_waits_for_checkout_operation_lock() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().to_path_buf(); + let home = root.join("home"); + fs::create_dir_all(&home).unwrap(); + fs::write(root.join("plan.json"), valid_plan_json()).unwrap(); + + let child = project_lock::with_lock(&root, "hold exporter test lock", || { + let mut child = spawn_export(&root, &home); + thread::sleep(Duration::from_millis(350)); + assert!( + child.try_wait().unwrap().is_none(), + "export completed while another process owned the checkout operation lock" + ); + Ok(child) + }) + .unwrap(); + + assert_child_success(child); + assert!(root.join("mise.toml").is_file()); + assert!(root.join(".zed/mise-export-state.json").is_file()); +} + +#[test] +fn startup_recovery_waits_for_checkout_operation_lock() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().to_path_buf(); + let home = root.join("home"); + fs::create_dir_all(&home).unwrap(); + fs::write(root.join("plan.json"), valid_plan_json()).unwrap(); + + let sentinel = root.join("sentinel.txt"); + fs::write(&sentinel, "before").unwrap(); + + let child = project_lock::with_lock(&root, "hold recovery test lock", || { + let mut transaction = ProjectTransaction::begin(&root).unwrap(); + transaction.backup(&sentinel).unwrap(); + fs::write(&sentinel, "partial").unwrap(); + std::mem::forget(transaction); + + let mut child = spawn_export(&root, &home); + thread::sleep(Duration::from_millis(350)); + assert!( + child.try_wait().unwrap().is_none(), + "startup recovery completed while another process owned the checkout operation lock" + ); + assert_eq!(fs::read_to_string(&sentinel).unwrap(), "partial"); + assert!(root.join(STAGING_DIR).is_dir()); + Ok(child) + }) + .unwrap(); + + assert_child_success(child); + assert_eq!(fs::read_to_string(&sentinel).unwrap(), "before"); + assert!(!root.join(STAGING_DIR).exists()); + assert!(root.join("mise.toml").is_file()); +} diff --git a/tests/mise_export_cli.rs b/tests/mise_export_cli.rs new file mode 100644 index 00000000..cec416de --- /dev/null +++ b/tests/mise_export_cli.rs @@ -0,0 +1,282 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use serde_json::Value; + +fn zed_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_zed")) +} + +fn write_plan(project: &Path) { + fs::create_dir_all(project).unwrap(); + fs::write( + project.join("zed-env.json"), + r#"{ + "schema": 2, + "tools": { + "node": [ + {"requirement": "22.4.0"}, + {"requirement": "20.15.1"} + ] + }, + "env": {"APP_ENV": "test", "RETRIES": 3}, + "vars": {"release": {"channel": "stable"}}, + "tasks": { + "prepare": {"run": ["echo prepare"]}, + "setup": { + "description": "Restore dependencies", + "aliases": ["bootstrap"], + "depends": ["prepare"], + "run": ["zed install --frozen", "cargo check"] + } + }, + "platforms": ["linux-x64", "macos-arm64"], + "activation": "none" +} +"#, + ) + .unwrap(); +} + +fn run_zed(project: &Path, home: &Path, args: &[&str]) -> Output { + let empty_path = home.join("empty-path"); + fs::create_dir_all(&empty_path).unwrap(); + let mut command = Command::new(zed_bin()); + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("ZED_PKG_") { + command.env_remove(key); + } + } + command + .args(args) + .current_dir(project) + .env("HOME", home) + .env("USERPROFILE", home) + .env("XDG_CONFIG_HOME", home.join(".config")) + .env("ZED_PKG_HOME", home.join(".zed-pkg")) + .env("PATH", empty_path) + .output() + .unwrap() +} + +#[test] +fn print_write_check_and_noop_are_deterministic_and_do_not_require_mise() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let project = temp.path().join("project"); + fs::create_dir_all(&home).unwrap(); + write_plan(&project); + + let print = run_zed( + &project, + &home, + &[ + "env", + "export", + "mise", + "--plan", + "zed-env.json", + "--output", + ".mise.toml", + ], + ); + assert!( + print.status.success(), + "stderr: {}", + String::from_utf8_lossy(&print.stderr) + ); + assert!(!project.join(".mise.toml").exists()); + assert!(!project.join(".zed/mise-export-state.json").exists()); + let printed = String::from_utf8(print.stdout).unwrap(); + let value: toml::Value = toml::from_str(&printed).unwrap(); + let versions = value["tools"]["node"].as_array().unwrap(); + assert_eq!(versions[0].as_str(), Some("22.4.0")); + assert_eq!(versions[1].as_str(), Some("20.15.1")); + + let write = run_zed( + &project, + &home, + &[ + "env", + "export", + "mise", + "--plan", + "zed-env.json", + "--output", + ".mise.toml", + "--write", + "--json", + ], + ); + assert!( + write.status.success(), + "stderr: {}", + String::from_utf8_lossy(&write.stderr) + ); + let written: Value = serde_json::from_slice(&write.stdout).unwrap(); + assert_eq!(written["manager"], "mise"); + assert_eq!(written["action"], "written"); + assert_eq!(written["plan"], "zed-env.json"); + assert_eq!(written["output"], ".mise.toml"); + assert_eq!(written["plan_sha256"].as_str().unwrap().len(), 64); + assert_eq!(written["output_sha256"].as_str().unwrap().len(), 64); + assert_eq!( + fs::read_to_string(project.join(".mise.toml")).unwrap(), + printed + ); + assert!(project.join(".zed/mise-export-state.json").is_file()); + + let check = run_zed( + &project, + &home, + &[ + "env", + "export", + "mise", + "--plan", + "zed-env.json", + "--output", + ".mise.toml", + "--check", + "--json", + ], + ); + assert!( + check.status.success(), + "stderr: {}", + String::from_utf8_lossy(&check.stderr) + ); + assert_eq!( + serde_json::from_slice::(&check.stdout).unwrap()["action"], + "verified" + ); + + let noop = run_zed( + &project, + &home, + &[ + "env", + "export", + "mise", + "--plan", + "zed-env.json", + "--output", + ".mise.toml", + "--write", + "--json", + ], + ); + assert!(noop.status.success()); + assert_eq!( + serde_json::from_slice::(&noop.stdout).unwrap()["action"], + "unchanged" + ); +} + +#[test] +fn write_refuses_unowned_or_edited_manager_files() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let project = temp.path().join("project"); + fs::create_dir_all(&home).unwrap(); + write_plan(&project); + fs::write(project.join(".mise.toml"), "[tools]\nnode = \"18\"\n").unwrap(); + + let unowned = run_zed( + &project, + &home, + &["env", "export", "mise", "--plan", "zed-env.json", "--write"], + ); + assert!(!unowned.status.success()); + assert!(String::from_utf8_lossy(&unowned.stderr).contains("hand-authored")); + assert_eq!( + fs::read_to_string(project.join(".mise.toml")).unwrap(), + "[tools]\nnode = \"18\"\n" + ); + + fs::remove_file(project.join(".mise.toml")).unwrap(); + let owned = run_zed( + &project, + &home, + &["env", "export", "mise", "--plan", "zed-env.json", "--write"], + ); + assert!(owned.status.success()); + fs::write(project.join(".mise.toml"), "# user edit\n").unwrap(); + let edited = run_zed( + &project, + &home, + &["env", "export", "mise", "--plan", "zed-env.json", "--write"], + ); + assert!(!edited.status.success()); + assert!(String::from_utf8_lossy(&edited.stderr).contains("edited")); + assert_eq!( + fs::read_to_string(project.join(".mise.toml")).unwrap(), + "# user edit\n" + ); +} + +#[test] +fn check_is_read_only_and_reports_drift_without_leaking_contents() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let project = temp.path().join("project"); + fs::create_dir_all(&home).unwrap(); + write_plan(&project); + fs::write(project.join(".mise.toml"), "[tools]\nnode = \"18\"\n").unwrap(); + let before = fs::read(project.join(".mise.toml")).unwrap(); + + let output = run_zed( + &project, + &home, + &["env", "export", "mise", "--plan", "zed-env.json", "--check"], + ); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("mise export drift")); + assert!(stderr.contains("sha256")); + assert!(!stderr.contains("node = \"18\"")); + assert_eq!(fs::read(project.join(".mise.toml")).unwrap(), before); + assert!(!project.join(".zed/mise-export-state.json").exists()); +} + +#[test] +fn clap_rejects_ambiguous_write_modes_and_project_escape() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let project = temp.path().join("project"); + fs::create_dir_all(&home).unwrap(); + write_plan(&project); + + let ambiguous = run_zed( + &project, + &home, + &[ + "env", + "export", + "mise", + "--plan", + "zed-env.json", + "--check", + "--write", + ], + ); + assert!(!ambiguous.status.success()); + + let escape = run_zed( + &project, + &home, + &[ + "env", + "export", + "mise", + "--plan", + "zed-env.json", + "--output", + "../mise.toml", + "--write", + ], + ); + assert!(!escape.status.success()); + assert!(String::from_utf8_lossy(&escape.stderr).contains("project-relative")); +} diff --git a/tests/mise_export_reserved_paths_cli.rs b/tests/mise_export_reserved_paths_cli.rs new file mode 100644 index 00000000..a3ec66e3 --- /dev/null +++ b/tests/mise_export_reserved_paths_cli.rs @@ -0,0 +1,127 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +fn zed_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_zed")) +} + +fn write_plan(project: &Path) { + fs::create_dir_all(project).unwrap(); + fs::write( + project.join("zed-env.json"), + r#"{ + "schema": 2, + "tools": {"node": [{"requirement": "22.4.0"}]}, + "env": {}, + "vars": {}, + "tasks": {}, + "platforms": ["linux-x64"], + "activation": "none" +} +"#, + ) + .unwrap(); +} + +fn run_zed_with_plan(project: &Path, home: &Path, plan: &str, output: &str) -> Output { + let empty_path = home.join("empty-path"); + fs::create_dir_all(&empty_path).unwrap(); + let mut command = Command::new(zed_bin()); + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("ZED_PKG_") { + command.env_remove(key); + } + } + command + .args([ + "env", "export", "mise", "--plan", plan, "--output", output, "--write", + ]) + .current_dir(project) + .env("HOME", home) + .env("USERPROFILE", home) + .env("XDG_CONFIG_HOME", home.join(".config")) + .env("ZED_PKG_HOME", home.join(".zed-pkg")) + .env("PATH", empty_path) + .output() + .unwrap() +} + +fn run_zed(project: &Path, home: &Path, output: &str) -> Output { + run_zed_with_plan(project, home, "zed-env.json", output) +} + +fn remove_operation_rendezvous(project: &Path) { + let zed_dir = project.join(".zed"); + let operation_lock = zed_dir.join("operation.lock"); + let metadata = fs::symlink_metadata(&operation_lock) + .expect("CLI mutation attempts should retain the operation rendezvous"); + assert!(metadata.is_file()); + assert!(!metadata.file_type().is_symlink()); + + let unexpected = fs::read_dir(&zed_dir) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .filter(|name| name != "operation.lock") + .collect::>(); + assert!( + unexpected.is_empty(), + "reserved-path rejection left native adapter state: {unexpected:?}" + ); + + fs::remove_file(operation_lock).unwrap(); + fs::remove_dir(zed_dir).unwrap(); +} + +#[test] +fn real_cli_refuses_source_state_and_transaction_staging_destinations() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let project = temp.path().join("project"); + fs::create_dir_all(&home).unwrap(); + write_plan(&project); + let original_plan = fs::read(project.join("zed-env.json")).unwrap(); + + for output in [ + "zed-env.json", + "ZED-ENV.JSON", + ".zed/mise-export-state.json", + ".ZED/MISE-EXPORT-STATE.JSON", + ".zpkg-staging/mise.toml", + ".ZPKG-STAGING/mise.toml", + ] { + let result = run_zed(&project, &home, output); + assert!( + !result.status.success(), + "reserved output unexpectedly succeeded: {output}" + ); + let stderr = String::from_utf8_lossy(&result.stderr); + assert!( + stderr.contains("cannot overwrite") + || stderr.contains("reserved export state") + || stderr.contains("reserved transaction staging"), + "unexpected reserved-path error for {output}: {stderr}" + ); + } + + fs::create_dir_all(project.join(".zed")).unwrap(); + fs::write(project.join(".zed/mise-export-state.json"), &original_plan).unwrap(); + let reserved_source = + run_zed_with_plan(&project, &home, ".zed/mise-export-state.json", "mise.toml"); + assert!(!reserved_source.status.success()); + let stderr = String::from_utf8_lossy(&reserved_source.stderr); + assert!( + stderr.contains("environment plan cannot target reserved export state"), + "unexpected reserved-source error: {stderr}" + ); + assert!(!project.join("mise.toml").exists()); + fs::remove_file(project.join(".zed/mise-export-state.json")).unwrap(); + remove_operation_rendezvous(&project); + + assert_eq!( + fs::read(project.join("zed-env.json")).unwrap(), + original_plan + ); + assert!(!project.join(".zed/mise-export-state.json").exists()); + assert!(!project.join(".zpkg-staging").exists()); +}