diff --git a/.github/workflows/apply-den-1462-mise-export.yml b/.github/workflows/apply-den-1462-mise-export.yml new file mode 100644 index 00000000..19b4c5a6 --- /dev/null +++ b/.github/workflows/apply-den-1462-mise-export.yml @@ -0,0 +1,79 @@ +name: materialize DEN-1462 mise export + +on: + push: + branches: + - feat/den-1462-mise-export + paths: + - .github/workflows/apply-den-1462-mise-export.yml + - scripts/apply_den1462_mise_export.py + - scripts/harden_den1462_export.py + - scripts/fix_den1462_flags_contract.py + - scripts/fix_den1462_selector_paths.py + - src/mise_export.rs + - tests/mise_export_cli.rs + - docs/mise-export.md + - .github/workflows/mise-export.yml + +permissions: + contents: write + +concurrency: + group: materialize-den-1462-mise-export + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + materialize: + if: github.actor != 'github-actions[bot]' + runs-on: macos-15 + timeout-minutes: 40 + steps: + - name: Check out implementation branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: feat/den-1462-mise-export + fetch-depth: 0 + persist-credentials: true + show-progress: false + + - name: Install Rust toolchain + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + + - name: Apply typed integration, semantic hardening, and validate + run: | + set -euo pipefail + python3 scripts/apply_den1462_mise_export.py + python3 scripts/harden_den1462_export.py + python3 scripts/fix_den1462_flags_contract.py + python3 scripts/fix_den1462_selector_paths.py + cargo fmt --all + cargo fmt --all --check + git diff --check + cargo test --locked mise_export + cargo test --locked --test mise_export_cli + cargo test --locked cli::tests::environment_export_is_typed_and_rejects_ambiguous_write_modes + cargo test --locked cli::tests::cli_flags_toml_is_in_sync_with_clap + cargo clippy --locked --all-targets -- -D warnings + + - name: Commit ordinary source and remove materializers + run: | + set -euo pipefail + rm -f \ + .github/workflows/apply-den-1462-mise-export.yml \ + scripts/apply_den1462_mise_export.py \ + scripts/harden_den1462_export.py \ + scripts/fix_den1462_flags_contract.py \ + scripts/fix_den1462_selector_paths.py + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git diff --cached --check + git commit -m 'feat(DEN-1462): add deterministic conflict-safe mise export' + git push origin HEAD:feat/den-1462-mise-export diff --git a/.github/workflows/mise-export.yml b/.github/workflows/mise-export.yml new file mode 100644 index 00000000..9aa91ab4 --- /dev/null +++ b/.github/workflows/mise-export.yml @@ -0,0 +1,98 @@ +name: deterministic mise export + +on: + pull_request: + paths: + - .cli-flags.toml + - src/cli.rs + - src/lib.rs + - src/main.rs + - src/mise_export.rs + - tests/mise_export_cli.rs + - docs/mise-export.md + - .github/workflows/mise-export.yml + push: + branches: [main] + paths: + - .cli-flags.toml + - src/cli.rs + - src/lib.rs + - src/main.rs + - src/mise_export.rs + - tests/mise_export_cli.rs + - docs/mise-export.md + - .github/workflows/mise-export.yml + +permissions: + contents: read + +concurrency: + group: mise-export-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: "1" + +jobs: + export: + name: export / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-24.04 + - macos-15 + - windows-2025 + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + show-progress: false + + - name: Install declared Rust toolchain + shell: bash + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + rustc --version --verbose + cargo --version --verbose + + - name: Check formatting and flags contract + run: | + cargo fmt --all --check + cargo test --locked cli::tests::environment_export_is_typed_and_rejects_ambiguous_write_modes + cargo test --locked cli::tests::cli_flags_toml_is_in_sync_with_clap + + - name: Run module and real CLI export tests + run: | + cargo test --locked mise_export + cargo test --locked --test mise_export_cli + + - name: Lint every target + run: cargo clippy --locked --all-targets -- -D warnings + + - name: Re-run deterministic and conflict canaries + run: | + cargo test --locked mise_export::tests::deterministic_export_preserves_multi_version_and_command_order + cargo test --locked mise_export::tests::hand_edits_and_unowned_outputs_are_never_overwritten + + gate: + name: All mise export platforms passed + if: always() + needs: export + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Enforce aggregate result + env: + MATRIX_RESULT: ${{ needs.export.result }} + run: | + set -euo pipefail + printf 'mise-export=%s\n' "$MATRIX_RESULT" + test "$MATRIX_RESULT" = success diff --git a/docs/mise-export.md b/docs/mise-export.md new file mode 100644 index 00000000..688f82da --- /dev/null +++ b/docs/mise-export.md @@ -0,0 +1,119 @@ +# 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`, `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, 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. 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 use the same renderer. 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/scripts/apply_den1462_mise_export.py b/scripts/apply_den1462_mise_export.py new file mode 100644 index 00000000..9c6c0825 --- /dev/null +++ b/scripts/apply_den1462_mise_export.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +"""Temporary idempotent integration for deterministic mise export.""" + +from pathlib import Path + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + if new in text: + return text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one anchor, found {count}") + return text.replace(old, new, 1) + + +# Public module export. +path = Path("src/lib.rs") +text = path.read_text(encoding="utf-8") +text = replace_once( + text, + "pub mod manifestless;\n", + "pub mod manifestless;\npub mod mise_export;\n", + "library module export", +) +path.write_text(text, encoding="utf-8") + +# Typed clap surface and its direct parser regression. +path = Path("src/cli.rs") +text = path.read_text(encoding="utf-8") +text = replace_once( + text, + " /// Import or verify project-local mise configuration.\n Mise,\n", + " /// Import, verify, or export project-local mise configuration.\n Mise,\n", + "environment manager help", +) + +verify_anchor = """ /// Verify manager config/lock coverage and the normalized plan digest. + Verify { +""" +export_variant = """ /// 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", conflicts_with = "write")] + check: bool, + /// Transactionally create/update a Zed-owned manager view. + #[arg(long, env = "ZED_PKG_ENV_WRITE", conflicts_with = "check")] + 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 { +""" +text = replace_once(text, verify_anchor, export_variant, "EnvCmd::Export") + +old_test = """ #[test] + fn completion_shells_are_typed_positionals() { +""" +new_test = """ #[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!(Cli::try_parse_from([ + "zed", + "env", + "export", + "mise", + "--plan", + "zed-env.toml", + "--check", + "--write", + ]) + .is_err()); + } + + #[test] + fn completion_shells_are_typed_positionals() { +""" +text = replace_once(text, old_test, new_test, "typed export CLI test") +path.write_text(text, encoding="utf-8") + +# Binary dispatcher. +path = Path("src/main.rs") +text = path.read_text(encoding="utf-8") +text = replace_once( + text, + "use zed_cli::managed_install;\n", + "use zed_cli::managed_install;\nuse zed_cli::mise_export::{self, MiseExportMode};\n", + "main export import", +) + +verify_arm = """ EnvCmd::Verify { + manager: _, + config, + lock, + frozen, + json, + } => { +""" +export_arm = """ EnvCmd::Export { + manager: _, + plan, + output, + check, + write, + json, + } => { + 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::Verify { + manager: _, + config, + lock, + frozen, + json, + } => { +""" +text = replace_once(text, verify_arm, export_arm, "main export dispatch") +path.write_text(text, encoding="utf-8") + +# flags-2-env registry and command documentation. +path = Path(".cli-flags.toml") +text = path.read_text(encoding="utf-8") +env_json = """[flags.env_json] +env = "ZED_PKG_ENV_JSON" +aliases = ["json"] +type = "bool" +default = "false" +help = "Emit environment interoperability output as JSON." +""" +env_flags = env_json + """ +[flags.env_plan] +env = "ZED_PKG_ENV_PLAN" +aliases = ["plan"] +type = "string" +help = "Project-local schema-v2 environment plan path." + +[flags.env_output] +env = "ZED_PKG_ENV_OUTPUT" +aliases = ["output"] +type = "string" +default = ".mise.toml" +help = "Project-local manager export path." + +[flags.env_check] +env = "ZED_PKG_ENV_CHECK" +aliases = ["check"] +type = "bool" +default = "false" +help = "Verify the deterministic manager projection without writing." + +[flags.env_write] +env = "ZED_PKG_ENV_WRITE" +aliases = ["write"] +type = "bool" +default = "false" +help = "Transactionally write a Zed-owned manager projection." +""" +text = replace_once(text, env_json, env_flags, "flags-2-env export flags") +text = replace_once( + text, + "[commands.env]\nhelp = \"Import and verify project-local developer environments.\"\n", + "[commands.env]\nhelp = \"Import, verify, and export project-local developer environments.\"\n", + "env command help", +) +verify_command = """[commands.env.commands.verify] +help = "Verify config/lock coverage and normalized environment identity." +""" +export_command = verify_command + """ + +[commands.env.commands.export] +help = "Project a schema-v2 EnvironmentPlan into deterministic manager configuration." +""" +text = replace_once(text, verify_command, export_command, "export command registry") +path.write_text(text, encoding="utf-8") + +# Remove a no-longer-used import and harden both output and state paths. +path = Path("src/mise_export.rs") +text = path.read_text(encoding="utf-8") +text = text.replace( + "use std::collections::{BTreeMap, BTreeSet};\n", + "use std::collections::BTreeMap;\n", + 1, +) +text = replace_once( + text, + """ let state_path = root.join(EXPORT_STATE_PATH); + let mut state = load_state(&state_path)?; +""", + """ 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)?; +""", + "export-state symlink boundary", +) +old_function = """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::>(); + 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(); + 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 || !leaf { + bail!("{kind} does not exist: {}", current.display()); + } + } + Err(error) => { + return Err(error) + .with_context(|| format!("failed to inspect {kind} {}", current.display())); + } + } + } + Ok(()) +} +""" +new_function = """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(()) +} +""" +text = replace_once(text, old_function, new_function, "missing-path/symlink validation") +old_test_tail = """ assert!(export_mise( + temp.path(), + &plan_path, + Path::new("linked/mise.toml"), + MiseExportMode::Write, + ) + .unwrap_err() + .to_string() + .contains("symlink")); + } + } +} +""" +new_test_tail = """ 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")); + } + } +} +""" +text = replace_once(text, old_test_tail, new_test_tail, "state symlink regression") +path.write_text(text, encoding="utf-8") diff --git a/scripts/fix_den1462_bool_conflicts.py b/scripts/fix_den1462_bool_conflicts.py new file mode 100644 index 00000000..6ece54a4 --- /dev/null +++ b/scripts/fix_den1462_bool_conflicts.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Avoid Clap presence-conflicts caused by flags-2-env boolean defaults. + +flags-2-env exports explicit false defaults for both mode flags. Clap correctly +parses those values as false, but `conflicts_with` reasons about argument +presence rather than the parsed boolean value. Validate the two true booleans +at the dispatcher boundary instead, where CLI and environment inputs have +already been normalized. +""" + +from pathlib import Path + + +def replace_once(path: Path, old: str, new: str, label: str) -> None: + text = path.read_text(encoding="utf-8") + if new in text: + return + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one anchor, found {count}") + path.write_text(text.replace(old, new, 1), encoding="utf-8") + + +cli = Path("src/cli.rs") +replace_once( + cli, + '#[arg(long, env = "ZED_PKG_ENV_CHECK", conflicts_with = "write")]', + '#[arg(long, env = "ZED_PKG_ENV_CHECK")]', + "check flag presence conflict", +) +replace_once( + cli, + '#[arg(long, env = "ZED_PKG_ENV_WRITE", conflicts_with = "check")]', + '#[arg(long, env = "ZED_PKG_ENV_WRITE")]', + "write flag presence conflict", +) +replace_once( + cli, + " use super::{AuthCmd, Cli, Cmd};\n", + " use super::{AuthCmd, Cli, Cmd, EnvCmd};\n", + "export command test import", +) +replace_once( + cli, + ''' assert!(Cli::try_parse_from([ + "zed", + "env", + "export", + "mise", + "--plan", + "zed-env.toml", + "--check", + "--write", + ]) + .is_err()); +''', + ''' 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, + .. + } + } + )); +''', + "typed export ambiguity parser regression", +) + +main = Path("src/main.rs") +replace_once( + main, + ''' } => { + let mode = if check { +''', + ''' } => { + if check && write { + anyhow::bail!("the arguments '--check' and '--write' cannot be used together"); + } + let mode = if check { +''', + "runtime export mode conflict", +) diff --git a/scripts/fix_den1462_flags_contract.py b/scripts/fix_den1462_flags_contract.py new file mode 100644 index 00000000..cacf9e80 --- /dev/null +++ b/scripts/fix_den1462_flags_contract.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Move mise-export flags into the nested flags2env command scope.""" + +from pathlib import Path + +path = Path(".cli-flags.toml") +text = path.read_text(encoding="utf-8") + +root_blocks = """ +[flags.env_plan] +env = "ZED_PKG_ENV_PLAN" +aliases = ["plan"] +type = "string" +help = "Project-local schema-v2 environment plan path." + +[flags.env_output] +env = "ZED_PKG_ENV_OUTPUT" +aliases = ["output"] +type = "string" +default = ".mise.toml" +help = "Project-local manager export path." + +[flags.env_check] +env = "ZED_PKG_ENV_CHECK" +aliases = ["check"] +type = "bool" +default = "false" +help = "Verify the deterministic manager projection without writing." + +[flags.env_write] +env = "ZED_PKG_ENV_WRITE" +aliases = ["write"] +type = "bool" +default = "false" +help = "Transactionally write a Zed-owned manager projection." +""" +if root_blocks in text: + text = text.replace(root_blocks, "", 1) + +command = """[commands.env.commands.export] +help = "Project a schema-v2 EnvironmentPlan into deterministic manager configuration." +""" +scoped = command + """ + +[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." +""" +if scoped not in text: + if text.count(command) != 1: + raise SystemExit("env export command scope not found exactly once") + text = text.replace(command, scoped, 1) + +path.write_text(text, encoding="utf-8") diff --git a/scripts/fix_den1462_selector_paths.py b/scripts/fix_den1462_selector_paths.py new file mode 100644 index 00000000..ca26ee61 --- /dev/null +++ b/scripts/fix_den1462_selector_paths.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Validate every tool selector before scalar/table export selection.""" + +from pathlib import Path + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + if new in text: + return text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one anchor, found {count}") + return text.replace(old, new, 1) + + +path = Path("src/mise_export.rs") +text = path.read_text(encoding="utf-8") + +text = replace_once( + text, + """ ensure!( + version.extensions.is_empty(), + "unsupported `{field}.extensions`: no certified mise mapping exists" + ); + + if version.options.is_empty() && requirement.platforms.is_empty() { +""", + """ 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() { +""", + "selector validation before scalar fast path", +) + +anchor = """fn insert_selector(table: &mut toml::Table, requirement: &str, field: &str) -> Result<()> { +""" +helper = """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<()> { +""" +text = replace_once(text, anchor, helper, "selector validation helper") + +old_test_anchor = """ #[test] + fn task_invocations_and_shell_argument_vectors_fail_closed() { +""" +new_test = r''' #[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() { +''' +text = replace_once(text, old_test_anchor, new_test, "path selector regressions") + +path.write_text(text, encoding="utf-8") + +docs = Path("docs/mise-export.md") +text = docs.read_text(encoding="utf-8") +text = text.replace( + "- `version`, `path`, `prefix`, and `ref` selectors;\n", + "- `version`, portable project-relative `path`, `prefix`, and `ref` selectors;\n", + 1, +) +text = text.replace( + "- project-relative/drive/UNC/parent/symlink path hardening;\n", + "- project-relative/drive/UNC/home/parent/symlink path hardening for outputs and `path:` selectors;\n", + 1, +) +docs.write_text(text, encoding="utf-8") diff --git a/scripts/harden_den1462_export.py b/scripts/harden_den1462_export.py new file mode 100644 index 00000000..2e68ba46 --- /dev/null +++ b/scripts/harden_den1462_export.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Temporary idempotent semantic hardening for deterministic mise export.""" + +from pathlib import Path + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + if new in text: + return text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one anchor, found {count}") + return text.replace(old, new, 1) + + +path = Path("src/mise_export.rs") +text = path.read_text(encoding="utf-8") + +text = replace_once( + text, + "use crate::transaction::ProjectTransaction;\n", + "use crate::transaction::{ProjectTransaction, STAGING_DIR};\n", + "transaction staging import", +) + +old_paths = """ let (output_path, output_relative) = + resolve_project_path(&root, output_arg, "mise output", false)?; + + let plan = read_plan(&plan_path)?; +""" +new_paths = """ 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)?; +""" +text = replace_once(text, old_paths, new_paths, "export path relationship validation") + +read_plan_anchor = """fn read_plan(path: &Path) -> Result { +""" +relationship_helper = """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 { +""" +text = replace_once(text, read_plan_anchor, relationship_helper, "path relationship helper") + +old_state_load = """ let mut state = load_state(&state_path)?; + let current = read_regular_file(output_path, "mise output")?; +""" +new_state_load = """ 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")?; +""" +text = replace_once(text, old_state_load, new_state_load, "portable state-key collision") + +render_anchor = """fn render_mise_config(plan: &EnvironmentPlanV2) -> Result { + ensure!( +""" +render_normalized = """fn render_mise_config(plan: &EnvironmentPlanV2) -> Result { + let plan = plan.normalized(); + ensure!( +""" +text = replace_once(text, render_anchor, render_normalized, "semantic renderer normalization") + +text = replace_once( + text, + "export_value_map(values, field, true, false)?", + "export_value_map(values, field, true, true)?", + "recursive sensitive-key validation", +) + +old_deterministic_tail = """ assert_eq!(commands[0].as_str(), Some("zed install --frozen")); + assert_eq!(commands[1].as_str(), Some("cargo check")); + } + + #[test] + fn write_check_and_unchanged_state_are_conflict_safe() { +""" +new_deterministic_tail = """ 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() { +""" +text = replace_once( + text, + old_deterministic_tail, + new_deterministic_tail, + "semantic presentation-order regression", +) + +old_unchanged_tail = """ assert_eq!(unchanged.action, MiseExportAction::Unchanged); + } + + #[test] + fn hand_edits_and_unowned_outputs_are_never_overwritten() { +""" +new_unchanged_tail = """ 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 hand_edits_and_unowned_outputs_are_never_overwritten() { +""" +text = replace_once(text, old_unchanged_tail, new_unchanged_tail, "case-folded state collision") + +old_sensitive_tail = """ assert!(render_mise_config(&plan) + .unwrap_err() + .to_string() + .contains("complex environment value")); + } +""" +new_sensitive_tail = """ 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")); + } +""" +text = replace_once(text, old_sensitive_tail, new_sensitive_tail, "nested secret regression") + +old_paths_loop = """ for output in ["../mise.toml", "C:\\\\mise.toml", "\\\\\\\\server\\\\share\\\\mise.toml"] { +""" +new_paths_loop = """ 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", + ] { +""" +text = replace_once(text, old_paths_loop, new_paths_loop, "reserved-path regressions") + +path.write_text(text, encoding="utf-8") + +docs = Path("docs/mise-export.md") +text = docs.read_text(encoding="utf-8") +text = text.replace( + "- project-relative/drive/UNC/parent/symlink path hardening; and\n", + "- project-relative/drive/UNC/parent/symlink path hardening;\n- refusal to target the source plan, export sidecar, or transaction staging; and\n", + 1, +) +text = text.replace( + "look credential-bearing, including password, secret, token, private/access key,\nAPI key, credential, and authorization names.", + "look credential-bearing at any nesting depth, including password, secret, token,\nprivate/access key, API key, credential, and authorization names.", + 1, +) +text = text.replace( + "Output and state changes share `ProjectTransaction`, including crash recovery\nand rollback.", + "Output and state changes share `ProjectTransaction`, including crash recovery\nand rollback. Plan, output, state, and staging identities are compared with\nportable ASCII case-folding so a Linux-generated ownership file cannot become\nambiguous on Windows or macOS.", + 1, +) +text = text.replace( + "Print,\ncheck, and write modes all use the same renderer.", + "Print,\ncheck, and write modes all render the normalized semantic plan, so set-like\npresentation order cannot diverge under one plan digest.", + 1, +) +docs.write_text(text, encoding="utf-8") diff --git a/src/mise_export.rs b/src/mise_export.rs new file mode 100644 index 00000000..7d40d176 --- /dev/null +++ b/src/mise_export.rs @@ -0,0 +1,1166 @@ +//! 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, BTreeSet}; +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; + +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)?; + + 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( + &root, + &plan_path, + &plan_relative, + &output_path, + &output_relative, + &candidate, + &plan_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(()) +} + +fn write_export( + root: &Path, + _plan_path: &Path, + plan_relative: &str, + output_path: &Path, + output_relative: &str, + candidate: &str, + plan_sha256: &str, + output_sha256: &str, +) -> Result { + let state_path = root.join(EXPORT_STATE_PATH); + let mut state = load_state(&state_path)?; + 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(current) = current.as_deref() + && !unchanged + { + let current_sha256 = digest_bytes(current); + let record = state.outputs.get(output_relative).with_context(|| { + format!( + "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" + ) + })?; + ensure!( + record.plan == plan_relative, + "refusing to overwrite `{output_relative}` from `{plan_relative}` because it is owned by plan `{}`", + record.plan + ); + ensure!( + record.output_sha256 == current_sha256, + "refusing to overwrite edited `{output_relative}`: export state records sha256 {}, current file is {current_sha256}", + record.output_sha256 + ); + } + + 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 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 { + 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" + ); + + 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 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, false)?) + } + }) +} + +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} 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::>(); + 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(); + 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 || !leaf { + bail!("{kind} does not exist: {}", current.display()); + } + } + 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 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); + } + + #[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")); + } + + #[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"] { + 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")); + } + } +} diff --git a/tests/mise_export_cli.rs b/tests/mise_export_cli.rs new file mode 100644 index 00000000..1d31cb4e --- /dev/null +++ b/tests/mise_export_cli.rs @@ -0,0 +1,307 @@ +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(); + Command::new(zed_bin()) + .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) + .env_remove("ZED_PKG_ENV_PLAN") + .env_remove("ZED_PKG_ENV_OUTPUT") + .env_remove("ZED_PKG_ENV_JSON") + .env_remove("ZED_PKG_ENV_CHECK") + .env_remove("ZED_PKG_ENV_WRITE") + .env_remove("ZED_PKG_UPDATE_CHECK") + .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")); +} \ No newline at end of file