From 22af78cd9d6df4f704b7f11b52242efdf29f9817 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Wed, 5 Aug 2026 13:38:27 -0500 Subject: [PATCH 01/35] feat: reconcile deterministic mise export with current main --- .cli-flags.toml | 34 +- README.md | 1 + docs/mise-export.md | 122 ++ src/cli.rs | 64 +- src/lib.rs | 1 + src/main.rs | 26 + src/mise_export.rs | 1354 +++++++++++++++++++++++ tests/mise_export_cli.rs | 282 +++++ tests/mise_export_reserved_paths_cli.rs | 94 ++ 9 files changed, 1975 insertions(+), 3 deletions(-) create mode 100644 docs/mise-export.md create mode 100644 src/mise_export.rs create mode 100644 tests/mise_export_cli.rs create mode 100644 tests/mise_export_reserved_paths_cli.rs diff --git a/.cli-flags.toml b/.cli-flags.toml index 378ed063..0c4d0d8e 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,38 @@ 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.completions] help = "Generate Bash or Zsh completion from the typed CLI model." diff --git a/README.md b/README.md index 179b3f9e..237c049b 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 find ` | Search the registry | 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 c81e00c3..07a74761 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, @@ -432,6 +432,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)] @@ -548,7 +568,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() { @@ -693,6 +713,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 completion_shells_are_typed_positionals() { for shell in ["bash", "zsh"] { diff --git a/src/lib.rs b/src/lib.rs index 20d49cee..822f57b9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,7 @@ pub mod interactive; pub mod lock_waiter; pub mod managed_install; pub mod manifestless; +pub mod mise_export; 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 17d10942..2f242a17 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; @@ -224,6 +225,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..788cc340 --- /dev/null +++ b/src/mise_export.rs @@ -0,0 +1,1354 @@ +//! 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(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 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 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_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..718bacaf --- /dev/null +++ b/tests/mise_export_reserved_paths_cli.rs @@ -0,0 +1,94 @@ +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(project: &Path, home: &Path, 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", + "zed-env.json", + "--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() +} + +#[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}" + ); + } + + 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()); +} From ad06282542ec8672608685cb1c97497ab0b62bf1 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 11:49:50 -0500 Subject: [PATCH 02/35] chore(DEN-1462): stage semantic current-main reconstruction --- .github/workflows/rebase-den-1462-export.yml | 100 +++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 .github/workflows/rebase-den-1462-export.yml diff --git a/.github/workflows/rebase-den-1462-export.yml b/.github/workflows/rebase-den-1462-export.yml new file mode 100644 index 00000000..2dd6c8a2 --- /dev/null +++ b/.github/workflows/rebase-den-1462-export.yml @@ -0,0 +1,100 @@ +name: reconstruct DEN-1462 on current main + +on: + push: + branches: + - agent/den-1462-mise-export-current-main + pull_request: + types: [opened, reopened, synchronize] + +permissions: + contents: write + +concurrency: + group: den-1462-reconstruct-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + reconstruct: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-24.04 + timeout-minutes: 50 + steps: + - name: Check out export branch with complete history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: agent/den-1462-mise-export-current-main + fetch-depth: 0 + persist-credentials: true + show-progress: false + + - name: Install validation toolchain + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + + - name: Reapply the reviewed nine-file delta to current main + shell: bash + env: + FEATURE_HEAD: c345f58d625eb8b5dbdf312f173d39eb281add25 + FEATURE_BRANCH: agent/den-1462-mise-export-current-main + run: | + set -euo pipefail + current_head=$(git rev-parse HEAD) + git fetch origin main + main_head=$(git rev-parse origin/main) + feature_base=$(git merge-base "$FEATURE_HEAD" "$main_head") + + feature_files=( + .cli-flags.toml + README.md + docs/mise-export.md + src/cli.rs + src/lib.rs + src/main.rs + src/mise_export.rs + tests/mise_export_cli.rs + tests/mise_export_reserved_paths_cli.rs + ) + + git diff --binary "$feature_base" "$FEATURE_HEAD" -- "${feature_files[@]}" \ + > "$RUNNER_TEMP/den-1462.patch" + test -s "$RUNNER_TEMP/den-1462.patch" + + cp .github/workflows/rebase-den-1462-export.yml \ + "$RUNNER_TEMP/rebase-den-1462-export.yml" + + git reset --hard "$main_head" + git apply --3way "$RUNNER_TEMP/den-1462.patch" + + unresolved=$(git diff --name-only --diff-filter=U) + if [[ -n "$unresolved" ]]; then + echo 'semantic patch left unresolved files:' >&2 + printf '%s\n' "$unresolved" >&2 + git diff --cc >&2 + exit 1 + fi + + mkdir -p .github/workflows + cp "$RUNNER_TEMP/rebase-den-1462-export.yml" \ + .github/workflows/rebase-den-1462-export.yml + + cargo fmt --all + git diff --check + cargo test --locked mise_export + cargo test --locked --test mise_export_cli + cargo test --locked --test mise_export_reserved_paths_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 test --locked cli::tests::readme_documents_every_command + cargo clippy --locked --all-targets -- -D warnings + + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add "${feature_files[@]}" .github/workflows/rebase-den-1462-export.yml + tree=$(git write-tree) + commit=$(printf '%s\n' 'Merge current main into deterministic mise export' | \ + git commit-tree "$tree" -p "$current_head" -p "$main_head") + git reset --hard "$commit" + git push origin "HEAD:$FEATURE_BRANCH" From 55569993504a865cbc420091b2dd24dc081ac7e4 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 11:55:18 -0500 Subject: [PATCH 03/35] fix(DEN-1462): compose export CLI with current task commands --- .github/workflows/rebase-den-1462-export.yml | 181 ++++++++++++++++++- 1 file changed, 176 insertions(+), 5 deletions(-) diff --git a/.github/workflows/rebase-den-1462-export.yml b/.github/workflows/rebase-den-1462-export.yml index 2dd6c8a2..f0c84e19 100644 --- a/.github/workflows/rebase-den-1462-export.yml +++ b/.github/workflows/rebase-den-1462-export.yml @@ -57,25 +57,196 @@ jobs: tests/mise_export_cli.rs tests/mise_export_reserved_paths_cli.rs ) + clean_patch_files=( + README.md + docs/mise-export.md + src/lib.rs + src/main.rs + src/mise_export.rs + tests/mise_export_cli.rs + tests/mise_export_reserved_paths_cli.rs + ) - git diff --binary "$feature_base" "$FEATURE_HEAD" -- "${feature_files[@]}" \ - > "$RUNNER_TEMP/den-1462.patch" - test -s "$RUNNER_TEMP/den-1462.patch" + git diff --binary "$feature_base" "$FEATURE_HEAD" -- "${clean_patch_files[@]}" \ + > "$RUNNER_TEMP/den-1462-clean.patch" + test -s "$RUNNER_TEMP/den-1462-clean.patch" cp .github/workflows/rebase-den-1462-export.yml \ "$RUNNER_TEMP/rebase-den-1462-export.yml" git reset --hard "$main_head" - git apply --3way "$RUNNER_TEMP/den-1462.patch" + git apply --3way "$RUNNER_TEMP/den-1462-clean.patch" unresolved=$(git diff --name-only --diff-filter=U) if [[ -n "$unresolved" ]]; then - echo 'semantic patch left unresolved files:' >&2 + echo 'clean exporter patch left unresolved files:' >&2 printf '%s\n' "$unresolved" >&2 git diff --cc >&2 exit 1 fi + python3 - <<'PY' + from pathlib import Path + + def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'{label}: expected one anchor, found {count}') + return text.replace(old, new, 1) + + flags = Path('.cli-flags.toml') + text = flags.read_text(encoding='utf-8') + 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', + 'environment command help', + ) + export_contract_anchor = '''[commands.env.commands.verify] + help = "Verify config/lock coverage and normalized environment identity." + + [commands.task] + ''' + export_contract = '''[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] + ''' + text = replace_once( + text, + export_contract_anchor, + export_contract, + 'flags-to-env export command contract', + ) + flags.write_text(text, encoding='utf-8') + + cli = Path('src/cli.rs') + text = cli.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 documentation', + ) + 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")] + 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, + }, + ''' + text = replace_once( + text, + verify_anchor, + export_variant + verify_anchor, + 'typed export subcommand', + ) + text = replace_once( + text, + ' use super::{AuthCmd, Cli, Cmd};\n', + ' use super::{AuthCmd, Cli, Cmd, EnvCmd};\n', + 'test EnvCmd import', + ) + task_test_anchor = ''' #[test] + fn task_commands_are_typed_and_reject_zero_concurrency() { + ''' + export_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!(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, + .. + } + } + )); + } + + ''' + text = replace_once( + text, + task_test_anchor, + export_test + task_test_anchor, + 'typed export parser test', + ) + cli.write_text(text, encoding='utf-8') + PY + mkdir -p .github/workflows cp "$RUNNER_TEMP/rebase-den-1462-export.yml" \ .github/workflows/rebase-den-1462-export.yml From 9fcd902c1e4c34c95f00dd97950d648cc6aba790 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 11:58:31 -0500 Subject: [PATCH 04/35] chore(DEN-1462): remove completed current-main reconstruction workflow --- .github/workflows/rebase-den-1462-export.yml | 271 ------------------- 1 file changed, 271 deletions(-) delete mode 100644 .github/workflows/rebase-den-1462-export.yml diff --git a/.github/workflows/rebase-den-1462-export.yml b/.github/workflows/rebase-den-1462-export.yml deleted file mode 100644 index f0c84e19..00000000 --- a/.github/workflows/rebase-den-1462-export.yml +++ /dev/null @@ -1,271 +0,0 @@ -name: reconstruct DEN-1462 on current main - -on: - push: - branches: - - agent/den-1462-mise-export-current-main - pull_request: - types: [opened, reopened, synchronize] - -permissions: - contents: write - -concurrency: - group: den-1462-reconstruct-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - reconstruct: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-24.04 - timeout-minutes: 50 - steps: - - name: Check out export branch with complete history - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: agent/den-1462-mise-export-current-main - fetch-depth: 0 - persist-credentials: true - show-progress: false - - - name: Install validation toolchain - run: | - set -euo pipefail - rustup toolchain install stable --profile minimal --component rustfmt,clippy - rustup default stable - - - name: Reapply the reviewed nine-file delta to current main - shell: bash - env: - FEATURE_HEAD: c345f58d625eb8b5dbdf312f173d39eb281add25 - FEATURE_BRANCH: agent/den-1462-mise-export-current-main - run: | - set -euo pipefail - current_head=$(git rev-parse HEAD) - git fetch origin main - main_head=$(git rev-parse origin/main) - feature_base=$(git merge-base "$FEATURE_HEAD" "$main_head") - - feature_files=( - .cli-flags.toml - README.md - docs/mise-export.md - src/cli.rs - src/lib.rs - src/main.rs - src/mise_export.rs - tests/mise_export_cli.rs - tests/mise_export_reserved_paths_cli.rs - ) - clean_patch_files=( - README.md - docs/mise-export.md - src/lib.rs - src/main.rs - src/mise_export.rs - tests/mise_export_cli.rs - tests/mise_export_reserved_paths_cli.rs - ) - - git diff --binary "$feature_base" "$FEATURE_HEAD" -- "${clean_patch_files[@]}" \ - > "$RUNNER_TEMP/den-1462-clean.patch" - test -s "$RUNNER_TEMP/den-1462-clean.patch" - - cp .github/workflows/rebase-den-1462-export.yml \ - "$RUNNER_TEMP/rebase-den-1462-export.yml" - - git reset --hard "$main_head" - git apply --3way "$RUNNER_TEMP/den-1462-clean.patch" - - unresolved=$(git diff --name-only --diff-filter=U) - if [[ -n "$unresolved" ]]; then - echo 'clean exporter patch left unresolved files:' >&2 - printf '%s\n' "$unresolved" >&2 - git diff --cc >&2 - exit 1 - fi - - python3 - <<'PY' - from pathlib import Path - - def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise SystemExit(f'{label}: expected one anchor, found {count}') - return text.replace(old, new, 1) - - flags = Path('.cli-flags.toml') - text = flags.read_text(encoding='utf-8') - 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', - 'environment command help', - ) - export_contract_anchor = '''[commands.env.commands.verify] - help = "Verify config/lock coverage and normalized environment identity." - - [commands.task] - ''' - export_contract = '''[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] - ''' - text = replace_once( - text, - export_contract_anchor, - export_contract, - 'flags-to-env export command contract', - ) - flags.write_text(text, encoding='utf-8') - - cli = Path('src/cli.rs') - text = cli.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 documentation', - ) - 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")] - 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, - }, - ''' - text = replace_once( - text, - verify_anchor, - export_variant + verify_anchor, - 'typed export subcommand', - ) - text = replace_once( - text, - ' use super::{AuthCmd, Cli, Cmd};\n', - ' use super::{AuthCmd, Cli, Cmd, EnvCmd};\n', - 'test EnvCmd import', - ) - task_test_anchor = ''' #[test] - fn task_commands_are_typed_and_reject_zero_concurrency() { - ''' - export_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!(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, - .. - } - } - )); - } - - ''' - text = replace_once( - text, - task_test_anchor, - export_test + task_test_anchor, - 'typed export parser test', - ) - cli.write_text(text, encoding='utf-8') - PY - - mkdir -p .github/workflows - cp "$RUNNER_TEMP/rebase-den-1462-export.yml" \ - .github/workflows/rebase-den-1462-export.yml - - cargo fmt --all - git diff --check - cargo test --locked mise_export - cargo test --locked --test mise_export_cli - cargo test --locked --test mise_export_reserved_paths_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 test --locked cli::tests::readme_documents_every_command - cargo clippy --locked --all-targets -- -D warnings - - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add "${feature_files[@]}" .github/workflows/rebase-den-1462-export.yml - tree=$(git write-tree) - commit=$(printf '%s\n' 'Merge current main into deterministic mise export' | \ - git commit-tree "$tree" -p "$current_head" -p "$main_head") - git reset --hard "$commit" - git push origin "HEAD:$FEATURE_BRANCH" From ecbeee99775c02a4ec8f1c1a8ad898af61bb078e Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 12:02:43 -0500 Subject: [PATCH 05/35] chore(DEN-1462): stage ownership-state integrity hardening --- .../apply-den-1462-ownership-integrity.yml | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 .github/workflows/apply-den-1462-ownership-integrity.yml diff --git a/.github/workflows/apply-den-1462-ownership-integrity.yml b/.github/workflows/apply-den-1462-ownership-integrity.yml new file mode 100644 index 00000000..d7e9834b --- /dev/null +++ b/.github/workflows/apply-den-1462-ownership-integrity.yml @@ -0,0 +1,165 @@ +name: apply DEN-1462 ownership-state integrity + +on: + push: + branches: + - agent/den-1462-mise-export-current-main + pull_request: + types: [opened, reopened, synchronize] + +permissions: + contents: write + +concurrency: + group: den-1462-ownership-integrity-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + apply: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-24.04 + timeout-minutes: 35 + steps: + - name: Check out exporter branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: agent/den-1462-mise-export-current-main + fetch-depth: 0 + persist-credentials: true + show-progress: false + + - name: Install validation toolchain + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + + - name: Reject silent ownership transfer and state repair + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + path = Path('src/mise_export.rs') + text = path.read_text(encoding='utf-8') + old = ''' 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 + ); + } + ''' + new = ''' 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" + ); + } + ''' + if text.count(old) != 1: + raise SystemExit(f'expected one ownership block, found {text.count(old)}') + text = text.replace(old, new, 1) + + anchor = ''' #[test] + fn hand_edits_and_unowned_outputs_are_never_overwritten() { + ''' + regression = ''' #[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(b'\n'); + 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); + } + + ''' + if text.count(anchor) != 1: + raise SystemExit('could not locate ownership regression insertion point') + path.write_text(text.replace(anchor, regression + anchor, 1), encoding='utf-8') + PY + + cargo fmt --all + git diff --check + cargo test --locked mise_export::tests::ownership_cannot_transfer_or_repair_state_silently + cargo test --locked mise_export + cargo test --locked --test mise_export_cli + cargo test --locked --test mise_export_reserved_paths_cli + cargo clippy --locked --all-targets -- -D warnings + + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add src/mise_export.rs + git commit -m 'fix(DEN-1462): preserve exporter ownership-state integrity' + git push origin HEAD:agent/den-1462-mise-export-current-main From 1ff0ec3443bbdaf83948f5efd52e38585c41378e Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 12:05:14 -0500 Subject: [PATCH 06/35] fix(DEN-1462): generate valid ownership-state regression --- .github/workflows/apply-den-1462-ownership-integrity.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/apply-den-1462-ownership-integrity.yml b/.github/workflows/apply-den-1462-ownership-integrity.yml index d7e9834b..46b22df8 100644 --- a/.github/workflows/apply-den-1462-ownership-integrity.yml +++ b/.github/workflows/apply-den-1462-ownership-integrity.yml @@ -134,7 +134,7 @@ jobs: .unwrap() .output_sha256 = "0".repeat(64); let mut corrupted = serde_json::to_vec_pretty(&state).unwrap(); - corrupted.push(b'\n'); + corrupted.push(10); fs::write(&state_path, &corrupted).unwrap(); let repair = From 67a4b9e1783dd755aa3e6ba6ea75bda8072bf392 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:08:35 +0000 Subject: [PATCH 07/35] fix(DEN-1462): preserve exporter ownership-state integrity --- src/mise_export.rs | 66 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/src/mise_export.rs b/src/mise_export.rs index 788cc340..4392d8ed 100644 --- a/src/mise_export.rs +++ b/src/mise_export.rs @@ -200,24 +200,25 @@ fn write_export(request: MiseWriteRequest<'_>) -> Result { .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" - ) - })?; + if let Some(record) = state.outputs.get(output_relative) { ensure!( record.plan == plan_relative, - "refusing to overwrite `{output_relative}` from `{plan_relative}` because it is owned by plan `{}`", + "refusing to change ownership of `{output_relative}` from plan `{}` to `{plan_relative}`", 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 + 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" ); } @@ -1124,6 +1125,43 @@ mod tests { ); } + #[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(); From e0bb22ae13577f072565d4b59a87603ead631d54 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 12:11:10 -0500 Subject: [PATCH 08/35] fix(DEN-1462): harden ownership and reserved source state --- .../apply-den-1462-ownership-integrity.yml | 143 +++++++++++++++++- 1 file changed, 139 insertions(+), 4 deletions(-) diff --git a/.github/workflows/apply-den-1462-ownership-integrity.yml b/.github/workflows/apply-den-1462-ownership-integrity.yml index 46b22df8..f010f462 100644 --- a/.github/workflows/apply-den-1462-ownership-integrity.yml +++ b/.github/workflows/apply-den-1462-ownership-integrity.yml @@ -34,7 +34,7 @@ jobs: rustup toolchain install stable --profile minimal --component rustfmt,clippy rustup default stable - - name: Reject silent ownership transfer and state repair + - name: Reject silent ownership transfer, state repair, and reserved state input shell: bash run: | set -euo pipefail @@ -100,6 +100,37 @@ jobs: raise SystemExit(f'expected one ownership block, found {text.count(old)}') text = text.replace(old, new, 1) + old_paths = ''' 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}`" + ); + ''' + new_paths = ''' let plan_folded = plan_relative.to_ascii_lowercase(); + let output_folded = output_relative.to_ascii_lowercase(); + let state_folded = EXPORT_STATE_PATH.to_ascii_lowercase(); + ensure!( + output_folded != plan_folded, + "mise output `{output_relative}` cannot overwrite its source environment plan" + ); + ensure!( + plan_folded != state_folded, + "environment plan cannot target reserved export state `{EXPORT_STATE_PATH}`" + ); + ensure!( + output_folded != state_folded, + "mise output cannot target reserved export state `{EXPORT_STATE_PATH}`" + ); + ''' + if text.count(old_paths) != 1: + raise SystemExit(f'expected one path relationship block, found {text.count(old_paths)}') + text = text.replace(old_paths, new_paths, 1) + anchor = ''' #[test] fn hand_edits_and_unowned_outputs_are_never_overwritten() { ''' @@ -147,12 +178,116 @@ jobs: ''' if text.count(anchor) != 1: raise SystemExit('could not locate ownership regression insertion point') - path.write_text(text.replace(anchor, regression + anchor, 1), encoding='utf-8') + text = text.replace(anchor, regression + anchor, 1) + + path_anchor = ''' #[test] + fn export_paths_reject_parent_drive_unc_and_symlink_escape() { + ''' + path_regression = ''' #[test] + fn reserved_export_state_cannot_be_used_as_plan_input() { + let temp = tempfile::tempdir().unwrap(); + let reserved = temp.path().join(EXPORT_STATE_PATH); + fs::create_dir_all(reserved.parent().unwrap()).unwrap(); + fs::write(&reserved, serde_json::to_vec_pretty(&simple_plan()).unwrap()).unwrap(); + + let error = export_mise( + temp.path(), + Path::new(EXPORT_STATE_PATH), + Path::new("mise.toml"), + MiseExportMode::Print, + ) + .unwrap_err(); + assert!( + error + .to_string() + .contains("environment plan cannot target reserved export state") + ); + assert!(!temp.path().join("mise.toml").exists()); + } + + ''' + if text.count(path_anchor) != 1: + raise SystemExit('could not locate reserved path regression insertion point') + text = text.replace(path_anchor, path_regression + path_anchor, 1) + path.write_text(text, encoding='utf-8') + + cli_path = Path('tests/mise_export_reserved_paths_cli.rs') + cli = cli_path.read_text(encoding='utf-8') + test_anchor = '''#[test] + fn real_cli_refuses_source_state_and_transaction_staging_destinations() { + ''' + helper = '''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() + } + + ''' + if cli.count(test_anchor) != 1: + raise SystemExit('could not locate real CLI reserved path test') + cli = cli.replace(test_anchor, helper + test_anchor, 1) + + final_anchor = ''' assert_eq!( + fs::read(project.join("zed-env.json")).unwrap(), + original_plan + ); + ''' + source_case = ''' 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(); + fs::remove_dir(project.join(".zed")).unwrap(); + + ''' + if cli.count(final_anchor) != 1: + raise SystemExit('could not locate final reserved path assertions') + cli_path.write_text(cli.replace(final_anchor, source_case + final_anchor, 1), encoding='utf-8') PY cargo fmt --all git diff --check cargo test --locked mise_export::tests::ownership_cannot_transfer_or_repair_state_silently + cargo test --locked mise_export::tests::reserved_export_state_cannot_be_used_as_plan_input cargo test --locked mise_export cargo test --locked --test mise_export_cli cargo test --locked --test mise_export_reserved_paths_cli @@ -160,6 +295,6 @@ jobs: git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add src/mise_export.rs - git commit -m 'fix(DEN-1462): preserve exporter ownership-state integrity' + git add src/mise_export.rs tests/mise_export_reserved_paths_cli.rs + git commit -m 'fix(DEN-1462): preserve exporter ownership and state boundaries' git push origin HEAD:agent/den-1462-mise-export-current-main From 4063a65e68d89544ad18c718753c94a3131cf61c Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 12:17:03 -0500 Subject: [PATCH 09/35] fix(DEN-1462): isolate reserved export-state source hardening --- .../apply-den-1462-ownership-integrity.yml | 287 ++++++------------ 1 file changed, 95 insertions(+), 192 deletions(-) diff --git a/.github/workflows/apply-den-1462-ownership-integrity.yml b/.github/workflows/apply-den-1462-ownership-integrity.yml index f010f462..56c6d0a4 100644 --- a/.github/workflows/apply-den-1462-ownership-integrity.yml +++ b/.github/workflows/apply-den-1462-ownership-integrity.yml @@ -1,4 +1,4 @@ -name: apply DEN-1462 ownership-state integrity +name: apply DEN-1462 reserved export-state source on: push: @@ -11,7 +11,7 @@ permissions: contents: write concurrency: - group: den-1462-ownership-integrity-${{ github.event.pull_request.number || github.ref }} + group: den-1462-reserved-source-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: @@ -34,182 +34,78 @@ jobs: rustup toolchain install stable --profile minimal --component rustfmt,clippy rustup default stable - - name: Reject silent ownership transfer, state repair, and reserved state input + - name: Reject reserved export state as an input plan shell: bash run: | set -euo pipefail python3 - <<'PY' from pathlib import Path - path = Path('src/mise_export.rs') - text = path.read_text(encoding='utf-8') - old = ''' 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 - ); - } - ''' - new = ''' 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" - ); - } - ''' - if text.count(old) != 1: - raise SystemExit(f'expected one ownership block, found {text.count(old)}') - text = text.replace(old, new, 1) - + source = Path('src/mise_export.rs') + text = source.read_text(encoding='utf-8') old_paths = ''' 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 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}`" + ); ''' new_paths = ''' let plan_folded = plan_relative.to_ascii_lowercase(); - let output_folded = output_relative.to_ascii_lowercase(); - let state_folded = EXPORT_STATE_PATH.to_ascii_lowercase(); - ensure!( - output_folded != plan_folded, - "mise output `{output_relative}` cannot overwrite its source environment plan" - ); - ensure!( - plan_folded != state_folded, - "environment plan cannot target reserved export state `{EXPORT_STATE_PATH}`" - ); - ensure!( - output_folded != state_folded, - "mise output cannot target reserved export state `{EXPORT_STATE_PATH}`" - ); - ''' - if text.count(old_paths) != 1: - raise SystemExit(f'expected one path relationship block, found {text.count(old_paths)}') - text = text.replace(old_paths, new_paths, 1) - - anchor = ''' #[test] - fn hand_edits_and_unowned_outputs_are_never_overwritten() { + let output_folded = output_relative.to_ascii_lowercase(); + let state_folded = EXPORT_STATE_PATH.to_ascii_lowercase(); + ensure!( + output_folded != plan_folded, + "mise output `{output_relative}` cannot overwrite its source environment plan" + ); + ensure!( + plan_folded != state_folded, + "environment plan cannot target reserved export state `{EXPORT_STATE_PATH}`" + ); + ensure!( + output_folded != state_folded, + "mise output cannot target reserved export state `{EXPORT_STATE_PATH}`" + ); ''' - regression = ''' #[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); - } - - ''' - if text.count(anchor) != 1: - raise SystemExit('could not locate ownership regression insertion point') - text = text.replace(anchor, regression + anchor, 1) + if old_paths in text: + text = text.replace(old_paths, new_paths, 1) + elif new_paths not in text: + raise SystemExit('could not locate either old or hardened path relationship block') path_anchor = ''' #[test] - fn export_paths_reject_parent_drive_unc_and_symlink_escape() { + fn export_paths_reject_parent_drive_unc_and_symlink_escape() { ''' path_regression = ''' #[test] - fn reserved_export_state_cannot_be_used_as_plan_input() { - let temp = tempfile::tempdir().unwrap(); - let reserved = temp.path().join(EXPORT_STATE_PATH); - fs::create_dir_all(reserved.parent().unwrap()).unwrap(); - fs::write(&reserved, serde_json::to_vec_pretty(&simple_plan()).unwrap()).unwrap(); - - let error = export_mise( - temp.path(), - Path::new(EXPORT_STATE_PATH), - Path::new("mise.toml"), - MiseExportMode::Print, - ) - .unwrap_err(); - assert!( - error - .to_string() - .contains("environment plan cannot target reserved export state") - ); - assert!(!temp.path().join("mise.toml").exists()); - } + fn reserved_export_state_cannot_be_used_as_plan_input() { + let temp = tempfile::tempdir().unwrap(); + let reserved = temp.path().join(EXPORT_STATE_PATH); + fs::create_dir_all(reserved.parent().unwrap()).unwrap(); + fs::write(&reserved, serde_json::to_vec_pretty(&simple_plan()).unwrap()).unwrap(); + + let error = export_mise( + temp.path(), + Path::new(EXPORT_STATE_PATH), + Path::new("mise.toml"), + MiseExportMode::Print, + ) + .unwrap_err(); + assert!( + error + .to_string() + .contains("environment plan cannot target reserved export state") + ); + assert!(!temp.path().join("mise.toml").exists()); + } ''' - if text.count(path_anchor) != 1: - raise SystemExit('could not locate reserved path regression insertion point') - text = text.replace(path_anchor, path_regression + path_anchor, 1) - path.write_text(text, encoding='utf-8') + if 'fn reserved_export_state_cannot_be_used_as_plan_input()' not in text: + if text.count(path_anchor) != 1: + raise SystemExit('could not locate reserved path regression insertion point') + text = text.replace(path_anchor, path_regression + path_anchor, 1) + source.write_text(text, encoding='utf-8') cli_path = Path('tests/mise_export_reserved_paths_cli.rs') cli = cli_path.read_text(encoding='utf-8') @@ -247,41 +143,44 @@ jobs: } ''' - if cli.count(test_anchor) != 1: - raise SystemExit('could not locate real CLI reserved path test') - cli = cli.replace(test_anchor, helper + test_anchor, 1) + if 'fn run_zed_with_plan(' not in cli: + if cli.count(test_anchor) != 1: + raise SystemExit('could not locate real CLI reserved path test') + cli = cli.replace(test_anchor, helper + test_anchor, 1) final_anchor = ''' assert_eq!( - fs::read(project.join("zed-env.json")).unwrap(), - original_plan - ); + fs::read(project.join("zed-env.json")).unwrap(), + original_plan + ); ''' source_case = ''' 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(); - fs::remove_dir(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(); + fs::remove_dir(project.join(".zed")).unwrap(); ''' - if cli.count(final_anchor) != 1: - raise SystemExit('could not locate final reserved path assertions') - cli_path.write_text(cli.replace(final_anchor, source_case + final_anchor, 1), encoding='utf-8') + if 'unexpected reserved-source error' not in cli: + if cli.count(final_anchor) != 1: + raise SystemExit('could not locate final reserved path assertions') + cli = cli.replace(final_anchor, source_case + final_anchor, 1) + cli_path.write_text(cli, encoding='utf-8') PY cargo fmt --all @@ -296,5 +195,9 @@ jobs: git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git add src/mise_export.rs tests/mise_export_reserved_paths_cli.rs - git commit -m 'fix(DEN-1462): preserve exporter ownership and state boundaries' + if git diff --cached --quiet; then + echo 'reserved source hardening already present' + exit 0 + fi + git commit -m 'fix(DEN-1462): reserve exporter state input path' git push origin HEAD:agent/den-1462-mise-export-current-main From f427fab0f894483258e24f8f5a1c0a65c7eb8d8f Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 12:22:30 -0500 Subject: [PATCH 10/35] ci(DEN-1462): run focused reserved-source patch on Windows --- .../apply-den-1462-ownership-integrity.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/apply-den-1462-ownership-integrity.yml b/.github/workflows/apply-den-1462-ownership-integrity.yml index 56c6d0a4..c7c69a17 100644 --- a/.github/workflows/apply-den-1462-ownership-integrity.yml +++ b/.github/workflows/apply-den-1462-ownership-integrity.yml @@ -4,21 +4,23 @@ on: push: branches: - agent/den-1462-mise-export-current-main - pull_request: - types: [opened, reopened, synchronize] permissions: contents: write concurrency: - group: den-1462-reserved-source-${{ github.event.pull_request.number || github.ref }} + group: den-1462-reserved-source-windows cancel-in-progress: true +defaults: + run: + shell: bash + jobs: apply: if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-24.04 - timeout-minutes: 35 + runs-on: windows-2025 + timeout-minutes: 40 steps: - name: Check out exporter branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -35,10 +37,9 @@ jobs: rustup default stable - name: Reject reserved export state as an input plan - shell: bash run: | set -euo pipefail - python3 - <<'PY' + python - <<'PY' from pathlib import Path source = Path('src/mise_export.rs') From b3cf17772f1d26eb698491c1c1765e34af9c15c2 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 12:28:17 -0500 Subject: [PATCH 11/35] test(DEN-1462): reject reserved export state as a plan source --- tests/mise_export_reserved_paths_cli.rs | 37 +++++++++++++++++++------ 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/tests/mise_export_reserved_paths_cli.rs b/tests/mise_export_reserved_paths_cli.rs index 718bacaf..79323755 100644 --- a/tests/mise_export_reserved_paths_cli.rs +++ b/tests/mise_export_reserved_paths_cli.rs @@ -24,7 +24,7 @@ fn write_plan(project: &Path) { .unwrap(); } -fn run_zed(project: &Path, home: &Path, output: &str) -> Output { +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()); @@ -35,14 +35,7 @@ fn run_zed(project: &Path, home: &Path, output: &str) -> Output { } command .args([ - "env", - "export", - "mise", - "--plan", - "zed-env.json", - "--output", - output, - "--write", + "env", "export", "mise", "--plan", plan, "--output", output, "--write", ]) .current_dir(project) .env("HOME", home) @@ -54,6 +47,10 @@ fn run_zed(project: &Path, home: &Path, output: &str) -> Output { .unwrap() } +fn run_zed(project: &Path, home: &Path, output: &str) -> Output { + run_zed_with_plan(project, home, "zed-env.json", output) +} + #[test] fn real_cli_refuses_source_state_and_transaction_staging_destinations() { let temp = tempfile::tempdir().unwrap(); @@ -85,6 +82,28 @@ fn real_cli_refuses_source_state_and_transaction_staging_destinations() { ); } + 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(); + fs::remove_dir(project.join(".zed")).unwrap(); + assert_eq!( fs::read(project.join("zed-env.json")).unwrap(), original_plan From 688a9a27e30bcbc6daa9c774bac84b04765dd2e5 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 12:28:50 -0500 Subject: [PATCH 12/35] fix(DEN-1462): use structural reserved-source patching --- .../apply-den-1462-ownership-integrity.yml | 234 ++++++++---------- 1 file changed, 97 insertions(+), 137 deletions(-) diff --git a/.github/workflows/apply-den-1462-ownership-integrity.yml b/.github/workflows/apply-den-1462-ownership-integrity.yml index c7c69a17..5286a7e8 100644 --- a/.github/workflows/apply-den-1462-ownership-integrity.yml +++ b/.github/workflows/apply-den-1462-ownership-integrity.yml @@ -42,146 +42,106 @@ jobs: python - <<'PY' from pathlib import Path - source = Path('src/mise_export.rs') - text = source.read_text(encoding='utf-8') - old_paths = ''' 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}`" - ); - ''' - new_paths = ''' let plan_folded = plan_relative.to_ascii_lowercase(); - let output_folded = output_relative.to_ascii_lowercase(); - let state_folded = EXPORT_STATE_PATH.to_ascii_lowercase(); - ensure!( - output_folded != plan_folded, - "mise output `{output_relative}` cannot overwrite its source environment plan" - ); - ensure!( - plan_folded != state_folded, - "environment plan cannot target reserved export state `{EXPORT_STATE_PATH}`" - ); - ensure!( - output_folded != state_folded, - "mise output cannot target reserved export state `{EXPORT_STATE_PATH}`" - ); - ''' - if old_paths in text: - text = text.replace(old_paths, new_paths, 1) - elif new_paths not in text: - raise SystemExit('could not locate either old or hardened path relationship block') + path = Path('src/mise_export.rs') + lines = path.read_text(encoding='utf-8').splitlines(keepends=True) - path_anchor = ''' #[test] - fn export_paths_reject_parent_drive_unc_and_symlink_escape() { - ''' - path_regression = ''' #[test] - fn reserved_export_state_cannot_be_used_as_plan_input() { - let temp = tempfile::tempdir().unwrap(); - let reserved = temp.path().join(EXPORT_STATE_PATH); - fs::create_dir_all(reserved.parent().unwrap()).unwrap(); - fs::write(&reserved, serde_json::to_vec_pretty(&simple_plan()).unwrap()).unwrap(); - - let error = export_mise( - temp.path(), - Path::new(EXPORT_STATE_PATH), - Path::new("mise.toml"), - MiseExportMode::Print, + function_index = next( + index + for index, line in enumerate(lines) + if line.strip() == 'fn validate_export_path_relationships(' + ) + output_index = next( + index + for index in range(function_index, len(lines)) + if lines[index].strip() + == 'let output_folded = output_relative.to_ascii_lowercase();' + ) + if not any( + line.strip() == 'let state_folded = EXPORT_STATE_PATH.to_ascii_lowercase();' + for line in lines[output_index : output_index + 8] + ): + indent = lines[output_index][: len(lines[output_index]) - len(lines[output_index].lstrip())] + lines.insert( + output_index + 1, + f'{indent}let state_folded = EXPORT_STATE_PATH.to_ascii_lowercase();\n', ) - .unwrap_err(); - assert!( - error - .to_string() - .contains("environment plan cannot target reserved export state") - ); - assert!(!temp.path().join("mise.toml").exists()); - } - - ''' - if 'fn reserved_export_state_cannot_be_used_as_plan_input()' not in text: - if text.count(path_anchor) != 1: - raise SystemExit('could not locate reserved path regression insertion point') - text = text.replace(path_anchor, path_regression + path_anchor, 1) - source.write_text(text, encoding='utf-8') - cli_path = Path('tests/mise_export_reserved_paths_cli.rs') - cli = cli_path.read_text(encoding='utf-8') - test_anchor = '''#[test] - fn real_cli_refuses_source_state_and_transaction_staging_destinations() { - ''' - helper = '''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() - } - - ''' - if 'fn run_zed_with_plan(' not in cli: - if cli.count(test_anchor) != 1: - raise SystemExit('could not locate real CLI reserved path test') - cli = cli.replace(test_anchor, helper + test_anchor, 1) - - final_anchor = ''' assert_eq!( - fs::read(project.join("zed-env.json")).unwrap(), - original_plan - ); - ''' - source_case = ''' fs::create_dir_all(project.join(".zed")).unwrap(); - fs::write( - project.join(".zed/mise-export-state.json"), - &original_plan, + expression_index = next( + index + for index in range(function_index, len(lines)) + if 'output_folded != EXPORT_STATE_PATH.to_ascii_lowercase()' in lines[index] + or lines[index].strip() == 'output_folded != state_folded,' ) - .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(); - fs::remove_dir(project.join(".zed")).unwrap(); - - ''' - if 'unexpected reserved-source error' not in cli: - if cli.count(final_anchor) != 1: - raise SystemExit('could not locate final reserved path assertions') - cli = cli.replace(final_anchor, source_case + final_anchor, 1) - cli_path.write_text(cli, encoding='utf-8') + expression_indent = lines[expression_index][ + : len(lines[expression_index]) - len(lines[expression_index].lstrip()) + ] + lines[expression_index] = f'{expression_indent}output_folded != state_folded,\n' + + if not any( + line.strip() == 'plan_folded != state_folded,' + for line in lines[output_index : expression_index + 4] + ): + ensure_start = expression_index - 1 + while ensure_start >= function_index and lines[ensure_start].strip() != 'ensure!(': + ensure_start -= 1 + if ensure_start < function_index: + raise SystemExit('could not locate reserved-output ensure block') + indent = lines[ensure_start][: len(lines[ensure_start]) - len(lines[ensure_start].lstrip())] + nested = indent + ' ' + block = [ + f'{indent}ensure!(\n', + f'{nested}plan_folded != state_folded,\n', + f'{nested}"environment plan cannot target reserved export state `{{EXPORT_STATE_PATH}}`"\n', + f'{indent});\n', + ] + lines[ensure_start:ensure_start] = block + + if not any( + 'fn reserved_export_state_cannot_be_used_as_plan_input()' in line + for line in lines + ): + target_index = next( + index + for index, line in enumerate(lines) + if line.strip() + == 'fn export_paths_reject_parent_drive_unc_and_symlink_escape() {' + ) + insertion_index = target_index - 1 + while insertion_index > 0 and lines[insertion_index].strip() != '#[test]': + insertion_index -= 1 + if lines[insertion_index].strip() != '#[test]': + raise SystemExit('could not locate reserved path test insertion point') + indent = lines[target_index][ + : len(lines[target_index]) - len(lines[target_index].lstrip()) + ] + nested = indent + ' ' + block = [ + f'{indent}#[test]\n', + f'{indent}fn reserved_export_state_cannot_be_used_as_plan_input() {{\n', + f'{nested}let temp = tempfile::tempdir().unwrap();\n', + f'{nested}let reserved = temp.path().join(EXPORT_STATE_PATH);\n', + f'{nested}fs::create_dir_all(reserved.parent().unwrap()).unwrap();\n', + f'{nested}fs::write(&reserved, serde_json::to_vec_pretty(&simple_plan()).unwrap()).unwrap();\n', + '\n', + f'{nested}let error = export_mise(\n', + f'{nested} temp.path(),\n', + f'{nested} Path::new(EXPORT_STATE_PATH),\n', + f'{nested} Path::new("mise.toml"),\n', + f'{nested} MiseExportMode::Print,\n', + f'{nested})\n', + f'{nested}.unwrap_err();\n', + f'{nested}assert!(\n', + f'{nested} error\n', + f'{nested} .to_string()\n', + f'{nested} .contains("environment plan cannot target reserved export state")\n', + f'{nested});\n', + f'{nested}assert!(!temp.path().join("mise.toml").exists());\n', + f'{indent}}}\n', + '\n', + ] + lines[insertion_index:insertion_index] = block + + path.write_text(''.join(lines), encoding='utf-8') PY cargo fmt --all @@ -195,7 +155,7 @@ jobs: git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add src/mise_export.rs tests/mise_export_reserved_paths_cli.rs + git add src/mise_export.rs if git diff --cached --quiet; then echo 'reserved source hardening already present' exit 0 From 817d0e44be95aa7370d01ac376be0dbf62425ea7 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 12:38:18 -0500 Subject: [PATCH 13/35] fix(DEN-1462): guard reserved exporter state at the public API --- src/lib.rs | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 10ca4e3d..fee2b353 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,7 +20,78 @@ pub mod interactive; pub mod lock_waiter; pub mod managed_install; pub mod manifestless; -pub mod mise_export; +#[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. + pub fn export_mise( + cwd: &Path, + plan_arg: &Path, + output_arg: &Path, + mode: MiseExportMode, + ) -> Result { + reject_reserved_state_plan(cwd, plan_arg)?; + 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; From 9dd3a37b25bf656911381ea98348b323dd040536 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 12:38:34 -0500 Subject: [PATCH 14/35] test(DEN-1462): certify reserved exporter state at the public API --- tests/mise_export_api_boundaries.rs | 70 +++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tests/mise_export_api_boundaries.rs diff --git a/tests/mise_export_api_boundaries.rs b/tests/mise_export_api_boundaries.rs new file mode 100644 index 00000000..8d84a7af --- /dev/null +++ b/tests/mise_export_api_boundaries.rs @@ -0,0 +1,70 @@ +use std::fs; +use std::path::Path; + +use zed_cli::mise_export::{MiseExportMode, export_mise}; + +fn valid_plan_json() -> &'static str { + r#"{ + "schema": 2, + "tools": {"node": [{"requirement": "22.4.0"}]}, + "env": {}, + "vars": {}, + "tasks": {}, + "platforms": ["linux-x64"], + "activation": "none" +} +"# +} + +#[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()); +} From c21f26c2370a426f36445d13186f540980dd1362 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 12:38:48 -0500 Subject: [PATCH 15/35] chore(DEN-1462): remove completed exporter hardening workflow --- .../apply-den-1462-ownership-integrity.yml | 164 ------------------ 1 file changed, 164 deletions(-) delete mode 100644 .github/workflows/apply-den-1462-ownership-integrity.yml diff --git a/.github/workflows/apply-den-1462-ownership-integrity.yml b/.github/workflows/apply-den-1462-ownership-integrity.yml deleted file mode 100644 index 5286a7e8..00000000 --- a/.github/workflows/apply-den-1462-ownership-integrity.yml +++ /dev/null @@ -1,164 +0,0 @@ -name: apply DEN-1462 reserved export-state source - -on: - push: - branches: - - agent/den-1462-mise-export-current-main - -permissions: - contents: write - -concurrency: - group: den-1462-reserved-source-windows - cancel-in-progress: true - -defaults: - run: - shell: bash - -jobs: - apply: - if: github.actor != 'github-actions[bot]' - runs-on: windows-2025 - timeout-minutes: 40 - steps: - - name: Check out exporter branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: agent/den-1462-mise-export-current-main - fetch-depth: 0 - persist-credentials: true - show-progress: false - - - name: Install validation toolchain - run: | - set -euo pipefail - rustup toolchain install stable --profile minimal --component rustfmt,clippy - rustup default stable - - - name: Reject reserved export state as an input plan - run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - path = Path('src/mise_export.rs') - lines = path.read_text(encoding='utf-8').splitlines(keepends=True) - - function_index = next( - index - for index, line in enumerate(lines) - if line.strip() == 'fn validate_export_path_relationships(' - ) - output_index = next( - index - for index in range(function_index, len(lines)) - if lines[index].strip() - == 'let output_folded = output_relative.to_ascii_lowercase();' - ) - if not any( - line.strip() == 'let state_folded = EXPORT_STATE_PATH.to_ascii_lowercase();' - for line in lines[output_index : output_index + 8] - ): - indent = lines[output_index][: len(lines[output_index]) - len(lines[output_index].lstrip())] - lines.insert( - output_index + 1, - f'{indent}let state_folded = EXPORT_STATE_PATH.to_ascii_lowercase();\n', - ) - - expression_index = next( - index - for index in range(function_index, len(lines)) - if 'output_folded != EXPORT_STATE_PATH.to_ascii_lowercase()' in lines[index] - or lines[index].strip() == 'output_folded != state_folded,' - ) - expression_indent = lines[expression_index][ - : len(lines[expression_index]) - len(lines[expression_index].lstrip()) - ] - lines[expression_index] = f'{expression_indent}output_folded != state_folded,\n' - - if not any( - line.strip() == 'plan_folded != state_folded,' - for line in lines[output_index : expression_index + 4] - ): - ensure_start = expression_index - 1 - while ensure_start >= function_index and lines[ensure_start].strip() != 'ensure!(': - ensure_start -= 1 - if ensure_start < function_index: - raise SystemExit('could not locate reserved-output ensure block') - indent = lines[ensure_start][: len(lines[ensure_start]) - len(lines[ensure_start].lstrip())] - nested = indent + ' ' - block = [ - f'{indent}ensure!(\n', - f'{nested}plan_folded != state_folded,\n', - f'{nested}"environment plan cannot target reserved export state `{{EXPORT_STATE_PATH}}`"\n', - f'{indent});\n', - ] - lines[ensure_start:ensure_start] = block - - if not any( - 'fn reserved_export_state_cannot_be_used_as_plan_input()' in line - for line in lines - ): - target_index = next( - index - for index, line in enumerate(lines) - if line.strip() - == 'fn export_paths_reject_parent_drive_unc_and_symlink_escape() {' - ) - insertion_index = target_index - 1 - while insertion_index > 0 and lines[insertion_index].strip() != '#[test]': - insertion_index -= 1 - if lines[insertion_index].strip() != '#[test]': - raise SystemExit('could not locate reserved path test insertion point') - indent = lines[target_index][ - : len(lines[target_index]) - len(lines[target_index].lstrip()) - ] - nested = indent + ' ' - block = [ - f'{indent}#[test]\n', - f'{indent}fn reserved_export_state_cannot_be_used_as_plan_input() {{\n', - f'{nested}let temp = tempfile::tempdir().unwrap();\n', - f'{nested}let reserved = temp.path().join(EXPORT_STATE_PATH);\n', - f'{nested}fs::create_dir_all(reserved.parent().unwrap()).unwrap();\n', - f'{nested}fs::write(&reserved, serde_json::to_vec_pretty(&simple_plan()).unwrap()).unwrap();\n', - '\n', - f'{nested}let error = export_mise(\n', - f'{nested} temp.path(),\n', - f'{nested} Path::new(EXPORT_STATE_PATH),\n', - f'{nested} Path::new("mise.toml"),\n', - f'{nested} MiseExportMode::Print,\n', - f'{nested})\n', - f'{nested}.unwrap_err();\n', - f'{nested}assert!(\n', - f'{nested} error\n', - f'{nested} .to_string()\n', - f'{nested} .contains("environment plan cannot target reserved export state")\n', - f'{nested});\n', - f'{nested}assert!(!temp.path().join("mise.toml").exists());\n', - f'{indent}}}\n', - '\n', - ] - lines[insertion_index:insertion_index] = block - - path.write_text(''.join(lines), encoding='utf-8') - PY - - cargo fmt --all - git diff --check - cargo test --locked mise_export::tests::ownership_cannot_transfer_or_repair_state_silently - cargo test --locked mise_export::tests::reserved_export_state_cannot_be_used_as_plan_input - cargo test --locked mise_export - cargo test --locked --test mise_export_cli - cargo test --locked --test mise_export_reserved_paths_cli - cargo clippy --locked --all-targets -- -D warnings - - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add src/mise_export.rs - if git diff --cached --quiet; then - echo 'reserved source hardening already present' - exit 0 - fi - git commit -m 'fix(DEN-1462): reserve exporter state input path' - git push origin HEAD:agent/den-1462-mise-export-current-main From e08910e86880fdc7f729daebec28b8f79c477508 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 12:58:43 -0500 Subject: [PATCH 16/35] style(DEN-1462): apply rustfmt to reserved-state CLI regression --- tests/mise_export_reserved_paths_cli.rs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/tests/mise_export_reserved_paths_cli.rs b/tests/mise_export_reserved_paths_cli.rs index 79323755..584eca58 100644 --- a/tests/mise_export_reserved_paths_cli.rs +++ b/tests/mise_export_reserved_paths_cli.rs @@ -83,17 +83,9 @@ fn real_cli_refuses_source_state_and_transaction_staging_destinations() { } 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", - ); + 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!( From 21394d0906c5093adec4e2666f05a942f7faae7e Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 13:34:59 -0500 Subject: [PATCH 17/35] chore(DEN-1462): stage current-main semantic merge --- .github/workflows/sync-den-1462-main.yml | 114 +++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 .github/workflows/sync-den-1462-main.yml diff --git a/.github/workflows/sync-den-1462-main.yml b/.github/workflows/sync-den-1462-main.yml new file mode 100644 index 00000000..fc609e24 --- /dev/null +++ b/.github/workflows/sync-den-1462-main.yml @@ -0,0 +1,114 @@ +name: sync DEN-1462 exporter with current main + +on: + push: + branches: + - agent/den-1462-mise-export-current-main + +permissions: + contents: write + +concurrency: + group: den-1462-current-main-sync + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + sync: + if: github.actor != 'github-actions[bot]' + runs-on: windows-2025 + timeout-minutes: 45 + steps: + - name: Check out exporter branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: agent/den-1462-mise-export-current-main + fetch-depth: 0 + persist-credentials: true + show-progress: false + + - name: Install validation toolchain + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + + - name: Merge current main and compose exporter dispatch + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git fetch origin main + cp src/main.rs "$RUNNER_TEMP/exporter-main.rs" + + set +e + git merge --no-ff --no-commit origin/main + merge_status=$? + set -e + if [[ $merge_status -ne 0 ]] && [[ -z "$(git diff --name-only --diff-filter=U)" ]]; then + echo 'merge failed without ordinary content conflicts' >&2 + exit "$merge_status" + fi + + git checkout origin/main -- \ + src/git_submodules/cli.rs \ + src/project_lock.rs \ + src/main.rs + + python - <<'PY' + import os + from pathlib import Path + + product = Path(os.environ['RUNNER_TEMP'], 'exporter-main.rs').read_text(encoding='utf-8') + current_path = Path('src/main.rs') + current = current_path.read_text(encoding='utf-8') + + import_line = 'use zed_cli::mise_export::{self, MiseExportMode};\n' + if import_line not in current: + anchor = 'use zed_cli::managed_install;\n' + if current.count(anchor) != 1: + raise SystemExit(f'expected one managed_install import, found {current.count(anchor)}') + current = current.replace(anchor, anchor + import_line, 1) + + start_marker = ' EnvCmd::Export {\n' + end_marker = ' EnvCmd::Verify {\n' + start = product.find(start_marker) + if start < 0: + raise SystemExit('exporter branch has no EnvCmd::Export arm') + end = product.find(end_marker, start) + if end < 0: + raise SystemExit('could not find EnvCmd::Verify after exporter arm') + export_block = product[start:end] + + if start_marker not in current: + insert = current.find(end_marker) + if insert < 0: + raise SystemExit('current main has no EnvCmd::Verify insertion anchor') + current = current[:insert] + export_block + current[insert:] + + current_path.write_text(current, encoding='utf-8') + PY + + unresolved=$(git diff --name-only --diff-filter=U) + if [[ -n "$unresolved" ]]; then + echo 'unresolved files remain after semantic composition:' >&2 + printf '%s\n' "$unresolved" >&2 + exit 1 + fi + + cargo fmt --all + git diff --check + cargo test --locked mise_export + cargo test --locked --test mise_export_api_boundaries + cargo test --locked --test mise_export_cli + cargo test --locked --test mise_export_reserved_paths_cli + cargo test --locked project_lock + cargo test --locked cli::tests::environment_export_is_typed_and_rejects_ambiguous_write_modes + cargo clippy --locked --all-targets -- -D warnings + + git add src/git_submodules/cli.rs src/project_lock.rs src/main.rs + git commit -m 'Merge current main into DEN-1462 deterministic mise exporter' + git push origin HEAD:agent/den-1462-mise-export-current-main From 7c5445b2cd15a503c8004da98cac31451ea2f1d4 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 13:36:25 -0500 Subject: [PATCH 18/35] chore(DEN-1462): remove completed semantic-sync workflow --- .github/workflows/sync-den-1462-main.yml | 114 ----------------------- 1 file changed, 114 deletions(-) delete mode 100644 .github/workflows/sync-den-1462-main.yml diff --git a/.github/workflows/sync-den-1462-main.yml b/.github/workflows/sync-den-1462-main.yml deleted file mode 100644 index fc609e24..00000000 --- a/.github/workflows/sync-den-1462-main.yml +++ /dev/null @@ -1,114 +0,0 @@ -name: sync DEN-1462 exporter with current main - -on: - push: - branches: - - agent/den-1462-mise-export-current-main - -permissions: - contents: write - -concurrency: - group: den-1462-current-main-sync - cancel-in-progress: true - -defaults: - run: - shell: bash - -jobs: - sync: - if: github.actor != 'github-actions[bot]' - runs-on: windows-2025 - timeout-minutes: 45 - steps: - - name: Check out exporter branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: agent/den-1462-mise-export-current-main - fetch-depth: 0 - persist-credentials: true - show-progress: false - - - name: Install validation toolchain - run: | - set -euo pipefail - rustup toolchain install stable --profile minimal --component rustfmt,clippy - rustup default stable - - - name: Merge current main and compose exporter dispatch - run: | - set -euo pipefail - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git fetch origin main - cp src/main.rs "$RUNNER_TEMP/exporter-main.rs" - - set +e - git merge --no-ff --no-commit origin/main - merge_status=$? - set -e - if [[ $merge_status -ne 0 ]] && [[ -z "$(git diff --name-only --diff-filter=U)" ]]; then - echo 'merge failed without ordinary content conflicts' >&2 - exit "$merge_status" - fi - - git checkout origin/main -- \ - src/git_submodules/cli.rs \ - src/project_lock.rs \ - src/main.rs - - python - <<'PY' - import os - from pathlib import Path - - product = Path(os.environ['RUNNER_TEMP'], 'exporter-main.rs').read_text(encoding='utf-8') - current_path = Path('src/main.rs') - current = current_path.read_text(encoding='utf-8') - - import_line = 'use zed_cli::mise_export::{self, MiseExportMode};\n' - if import_line not in current: - anchor = 'use zed_cli::managed_install;\n' - if current.count(anchor) != 1: - raise SystemExit(f'expected one managed_install import, found {current.count(anchor)}') - current = current.replace(anchor, anchor + import_line, 1) - - start_marker = ' EnvCmd::Export {\n' - end_marker = ' EnvCmd::Verify {\n' - start = product.find(start_marker) - if start < 0: - raise SystemExit('exporter branch has no EnvCmd::Export arm') - end = product.find(end_marker, start) - if end < 0: - raise SystemExit('could not find EnvCmd::Verify after exporter arm') - export_block = product[start:end] - - if start_marker not in current: - insert = current.find(end_marker) - if insert < 0: - raise SystemExit('current main has no EnvCmd::Verify insertion anchor') - current = current[:insert] + export_block + current[insert:] - - current_path.write_text(current, encoding='utf-8') - PY - - unresolved=$(git diff --name-only --diff-filter=U) - if [[ -n "$unresolved" ]]; then - echo 'unresolved files remain after semantic composition:' >&2 - printf '%s\n' "$unresolved" >&2 - exit 1 - fi - - cargo fmt --all - git diff --check - cargo test --locked mise_export - cargo test --locked --test mise_export_api_boundaries - cargo test --locked --test mise_export_cli - cargo test --locked --test mise_export_reserved_paths_cli - cargo test --locked project_lock - cargo test --locked cli::tests::environment_export_is_typed_and_rejects_ambiguous_write_modes - cargo clippy --locked --all-targets -- -D warnings - - git add src/git_submodules/cli.rs src/project_lock.rs src/main.rs - git commit -m 'Merge current main into DEN-1462 deterministic mise exporter' - git push origin HEAD:agent/den-1462-mise-export-current-main From 3b6ae4baaa18ba62dc47876f739d08f3b9f99cfe Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 13:43:12 -0500 Subject: [PATCH 19/35] chore(DEN-1462): stage checkout-lock serialization hardening --- .../workflows/apply-den-1462-project-lock.yml | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 .github/workflows/apply-den-1462-project-lock.yml diff --git a/.github/workflows/apply-den-1462-project-lock.yml b/.github/workflows/apply-den-1462-project-lock.yml new file mode 100644 index 00000000..2861fe50 --- /dev/null +++ b/.github/workflows/apply-den-1462-project-lock.yml @@ -0,0 +1,181 @@ +name: apply DEN-1462 checkout lock hardening + +on: + push: + branches: + - agent/den-1462-mise-export-current-main + +permissions: + contents: write + +concurrency: + group: den-1462-checkout-lock-hardening + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + apply: + if: github.actor != 'github-actions[bot]' + runs-on: windows-2025 + timeout-minutes: 45 + steps: + - name: Check out exporter branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: agent/den-1462-mise-export-current-main + fetch-depth: 0 + persist-credentials: true + show-progress: false + + - name: Install validation toolchain + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + + - name: Serialize exporter writes and startup recovery + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'{label}: expected one anchor, found {count}') + return text.replace(old, new, 1) + + main_path = Path('src/main.rs') + main = main_path.read_text(encoding='utf-8') + if 'use zed_cli::project_lock;\n' not in main: + main = replace_once( + main, + 'use zed_cli::preflight;\n', + 'use zed_cli::preflight;\nuse zed_cli::project_lock;\n', + 'project_lock import', + ) + old_recovery = ''' if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() { + let store = Store::from_env()?; + let _recovery_lock = store.install_lock()?; + let recovered = zed_cli::transaction::recover_pending(&cwd)?; + if recovered > 0 { + eprintln!("recovered {recovered} interrupted project transaction(s)"); + } + } + ''' + new_recovery = ''' if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() { + project_lock::with_lock(&cwd, "recover pending project transactions", || { + let store = Store::from_env()?; + let _recovery_lock = store.install_lock()?; + let recovered = zed_cli::transaction::recover_pending(&cwd)?; + if recovered > 0 { + eprintln!("recovered {recovered} interrupted project transaction(s)"); + } + Ok(()) + })?; + } + ''' + if old_recovery in main: + main = main.replace(old_recovery, new_recovery, 1) + elif new_recovery not in main: + raise SystemExit('could not locate startup recovery block') + main_path.write_text(main, encoding='utf-8') + + lib_path = Path('src/lib.rs') + lib = lib_path.read_text(encoding='utf-8') + old_export = ''' reject_reserved_state_plan(cwd, plan_arg)?; + super::mise_export_impl::export_mise(cwd, plan_arg, output_arg, mode) + ''' + new_export = ''' 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) + ''' + if old_export in lib: + lib = lib.replace(old_export, new_export, 1) + elif new_export not in lib: + raise SystemExit('could not locate public exporter delegation') + lib_path.write_text(lib, encoding='utf-8') + + test_path = Path('tests/mise_export_api_boundaries.rs') + test = test_path.read_text(encoding='utf-8') + if 'public_write_waits_for_checkout_operation_lock' not in test: + test = replace_once( + test, + 'use std::path::Path;\n', + 'use std::path::Path;\nuse std::sync::mpsc;\nuse std::thread;\nuse std::time::Duration;\n', + 'concurrency imports', + ) + test = replace_once( + test, + 'use zed_cli::mise_export::{MiseExportMode, export_mise};\n', + 'use zed_cli::mise_export::{MiseExportMode, export_mise};\nuse zed_cli::project_lock;\n', + 'project lock test import', + ) + concurrency_test = ''' + #[test] + fn public_write_waits_for_checkout_operation_lock() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("plan.json"), valid_plan_json()).unwrap(); + + let root = temp.path().to_path_buf(); + let worker_root = root.clone(); + let (started_tx, started_rx) = mpsc::channel(); + let (done_tx, done_rx) = mpsc::channel(); + + let worker = project_lock::with_lock(&root, "hold exporter test lock", || { + let worker = thread::spawn(move || { + started_tx.send(()).unwrap(); + let result = export_mise( + &worker_root, + Path::new("plan.json"), + Path::new("mise.toml"), + MiseExportMode::Write, + ); + done_tx.send(result.map(|report| report.output_sha256)).unwrap(); + }); + + started_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + assert!( + done_rx.recv_timeout(Duration::from_millis(250)).is_err(), + "export completed while another thread owned the checkout operation lock" + ); + Ok(worker) + }) + .unwrap(); + + let output_sha = done_rx + .recv_timeout(Duration::from_secs(10)) + .expect("export did not resume after checkout lock release") + .expect("export failed after checkout lock release"); + worker.join().unwrap(); + + assert_eq!(output_sha.len(), 64); + assert!(root.join("mise.toml").is_file()); + assert!(root.join(".zed/mise-export-state.json").is_file()); + } + ''' + test = test.rstrip() + '\n' + concurrency_test + test_path.write_text(test, encoding='utf-8') + PY + + cargo fmt --all + git diff --check + cargo test --locked --test mise_export_api_boundaries + cargo test --locked mise_export + cargo test --locked project_lock + cargo test --locked --test mise_export_cli + cargo test --locked --test mise_export_reserved_paths_cli + cargo clippy --locked --all-targets -- -D warnings + + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add src/main.rs src/lib.rs tests/mise_export_api_boundaries.rs + git commit -m 'fix(DEN-1462): serialize exporter writes and recovery' + git push origin HEAD:agent/den-1462-mise-export-current-main From 81e4d892021fad5a1d90180b1dca5c884f8df3e6 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 13:47:39 -0500 Subject: [PATCH 20/35] chore(DEN-1462): remove failed lock-hardening carrier --- .../workflows/apply-den-1462-project-lock.yml | 181 ------------------ 1 file changed, 181 deletions(-) delete mode 100644 .github/workflows/apply-den-1462-project-lock.yml diff --git a/.github/workflows/apply-den-1462-project-lock.yml b/.github/workflows/apply-den-1462-project-lock.yml deleted file mode 100644 index 2861fe50..00000000 --- a/.github/workflows/apply-den-1462-project-lock.yml +++ /dev/null @@ -1,181 +0,0 @@ -name: apply DEN-1462 checkout lock hardening - -on: - push: - branches: - - agent/den-1462-mise-export-current-main - -permissions: - contents: write - -concurrency: - group: den-1462-checkout-lock-hardening - cancel-in-progress: true - -defaults: - run: - shell: bash - -jobs: - apply: - if: github.actor != 'github-actions[bot]' - runs-on: windows-2025 - timeout-minutes: 45 - steps: - - name: Check out exporter branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: agent/den-1462-mise-export-current-main - fetch-depth: 0 - persist-credentials: true - show-progress: false - - - name: Install validation toolchain - run: | - set -euo pipefail - rustup toolchain install stable --profile minimal --component rustfmt,clippy - rustup default stable - - - name: Serialize exporter writes and startup recovery - run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise SystemExit(f'{label}: expected one anchor, found {count}') - return text.replace(old, new, 1) - - main_path = Path('src/main.rs') - main = main_path.read_text(encoding='utf-8') - if 'use zed_cli::project_lock;\n' not in main: - main = replace_once( - main, - 'use zed_cli::preflight;\n', - 'use zed_cli::preflight;\nuse zed_cli::project_lock;\n', - 'project_lock import', - ) - old_recovery = ''' if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() { - let store = Store::from_env()?; - let _recovery_lock = store.install_lock()?; - let recovered = zed_cli::transaction::recover_pending(&cwd)?; - if recovered > 0 { - eprintln!("recovered {recovered} interrupted project transaction(s)"); - } - } - ''' - new_recovery = ''' if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() { - project_lock::with_lock(&cwd, "recover pending project transactions", || { - let store = Store::from_env()?; - let _recovery_lock = store.install_lock()?; - let recovered = zed_cli::transaction::recover_pending(&cwd)?; - if recovered > 0 { - eprintln!("recovered {recovered} interrupted project transaction(s)"); - } - Ok(()) - })?; - } - ''' - if old_recovery in main: - main = main.replace(old_recovery, new_recovery, 1) - elif new_recovery not in main: - raise SystemExit('could not locate startup recovery block') - main_path.write_text(main, encoding='utf-8') - - lib_path = Path('src/lib.rs') - lib = lib_path.read_text(encoding='utf-8') - old_export = ''' reject_reserved_state_plan(cwd, plan_arg)?; - super::mise_export_impl::export_mise(cwd, plan_arg, output_arg, mode) - ''' - new_export = ''' 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) - ''' - if old_export in lib: - lib = lib.replace(old_export, new_export, 1) - elif new_export not in lib: - raise SystemExit('could not locate public exporter delegation') - lib_path.write_text(lib, encoding='utf-8') - - test_path = Path('tests/mise_export_api_boundaries.rs') - test = test_path.read_text(encoding='utf-8') - if 'public_write_waits_for_checkout_operation_lock' not in test: - test = replace_once( - test, - 'use std::path::Path;\n', - 'use std::path::Path;\nuse std::sync::mpsc;\nuse std::thread;\nuse std::time::Duration;\n', - 'concurrency imports', - ) - test = replace_once( - test, - 'use zed_cli::mise_export::{MiseExportMode, export_mise};\n', - 'use zed_cli::mise_export::{MiseExportMode, export_mise};\nuse zed_cli::project_lock;\n', - 'project lock test import', - ) - concurrency_test = ''' - #[test] - fn public_write_waits_for_checkout_operation_lock() { - let temp = tempfile::tempdir().unwrap(); - fs::write(temp.path().join("plan.json"), valid_plan_json()).unwrap(); - - let root = temp.path().to_path_buf(); - let worker_root = root.clone(); - let (started_tx, started_rx) = mpsc::channel(); - let (done_tx, done_rx) = mpsc::channel(); - - let worker = project_lock::with_lock(&root, "hold exporter test lock", || { - let worker = thread::spawn(move || { - started_tx.send(()).unwrap(); - let result = export_mise( - &worker_root, - Path::new("plan.json"), - Path::new("mise.toml"), - MiseExportMode::Write, - ); - done_tx.send(result.map(|report| report.output_sha256)).unwrap(); - }); - - started_rx.recv_timeout(Duration::from_secs(2)).unwrap(); - assert!( - done_rx.recv_timeout(Duration::from_millis(250)).is_err(), - "export completed while another thread owned the checkout operation lock" - ); - Ok(worker) - }) - .unwrap(); - - let output_sha = done_rx - .recv_timeout(Duration::from_secs(10)) - .expect("export did not resume after checkout lock release") - .expect("export failed after checkout lock release"); - worker.join().unwrap(); - - assert_eq!(output_sha.len(), 64); - assert!(root.join("mise.toml").is_file()); - assert!(root.join(".zed/mise-export-state.json").is_file()); - } - ''' - test = test.rstrip() + '\n' + concurrency_test - test_path.write_text(test, encoding='utf-8') - PY - - cargo fmt --all - git diff --check - cargo test --locked --test mise_export_api_boundaries - cargo test --locked mise_export - cargo test --locked project_lock - cargo test --locked --test mise_export_cli - cargo test --locked --test mise_export_reserved_paths_cli - cargo clippy --locked --all-targets -- -D warnings - - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add src/main.rs src/lib.rs tests/mise_export_api_boundaries.rs - git commit -m 'fix(DEN-1462): serialize exporter writes and recovery' - git push origin HEAD:agent/den-1462-mise-export-current-main From 851866ed2d30c8bb9fe07b6ed7c6480e633d5196 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 13:47:55 -0500 Subject: [PATCH 21/35] fix(DEN-1462): serialize public exporter writes --- src/lib.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index fee2b353..ab4f12ce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,7 +36,9 @@ pub mod mise_export { /// 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. + /// 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, @@ -44,6 +46,11 @@ pub mod mise_export { 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) } From 7d3c8ad2f5c8d2bd9f28d7d627ade4de0418ee80 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 13:48:27 -0500 Subject: [PATCH 22/35] test(DEN-1462): certify exporter and recovery lock serialization --- tests/mise_export_api_boundaries.rs | 113 +++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 1 deletion(-) diff --git a/tests/mise_export_api_boundaries.rs b/tests/mise_export_api_boundaries.rs index 8d84a7af..2790134d 100644 --- a/tests/mise_export_api_boundaries.rs +++ b/tests/mise_export_api_boundaries.rs @@ -1,7 +1,16 @@ use std::fs; -use std::path::Path; +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#"{ @@ -16,6 +25,49 @@ fn valid_plan_json() -> &'static str { "# } +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(); @@ -68,3 +120,62 @@ fn public_api_rejects_a_symlink_to_reserved_export_state() { ); 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()); +} From 67f1304d688482d38018de1383887001e4b2bc5f Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 13:48:50 -0500 Subject: [PATCH 23/35] chore(DEN-1462): stage startup recovery serialization --- .../apply-den-1462-recovery-lock.yml | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 .github/workflows/apply-den-1462-recovery-lock.yml diff --git a/.github/workflows/apply-den-1462-recovery-lock.yml b/.github/workflows/apply-den-1462-recovery-lock.yml new file mode 100644 index 00000000..03943eb0 --- /dev/null +++ b/.github/workflows/apply-den-1462-recovery-lock.yml @@ -0,0 +1,93 @@ +name: apply DEN-1462 startup recovery lock + +on: + push: + branches: + - agent/den-1462-mise-export-current-main + +permissions: + contents: write + +concurrency: + group: den-1462-startup-recovery-lock + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + apply: + if: github.actor != 'github-actions[bot]' + runs-on: windows-2025 + timeout-minutes: 45 + steps: + - name: Check out exporter branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: agent/den-1462-mise-export-current-main + fetch-depth: 0 + persist-credentials: true + show-progress: false + + - name: Install validation toolchain + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + + - name: Serialize startup recovery under the checkout operation lock + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + path = Path('src/main.rs') + text = path.read_text(encoding='utf-8') + if 'use zed_cli::project_lock;\n' not in text: + anchor = 'use zed_cli::preflight;\n' + if text.count(anchor) != 1: + raise SystemExit(f'expected one project-lock import anchor, found {text.count(anchor)}') + text = text.replace(anchor, anchor + 'use zed_cli::project_lock;\n', 1) + + old = ''' if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() { + // Recovery writes into the project and must be serialized against + // every installer sharing the checkout. + let store = Store::new(&cfg.home); + let _recovery_lock = store.install_lock()?; + zed_cli::transaction::recover_pending(&cwd)?; + } + ''' + new = ''' if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() { + // Recovery writes into the project and must be serialized against + // every operation sharing the checkout. Preserve project-lock -> + // store-lock ordering to avoid lock inversion with installers. + project_lock::with_lock(&cwd, "recover pending project transactions", || { + let store = Store::new(&cfg.home); + let _recovery_lock = store.install_lock()?; + zed_cli::transaction::recover_pending(&cwd)?; + Ok(()) + })?; + } + ''' + if old in text: + text = text.replace(old, new, 1) + elif new not in text: + raise SystemExit('could not locate startup recovery block') + path.write_text(text, encoding='utf-8') + PY + + cargo fmt --all + git diff --check + cargo test --locked --test mise_export_api_boundaries + cargo test --locked mise_export + cargo test --locked project_lock + cargo test --locked --test mise_export_cli + cargo test --locked --test mise_export_reserved_paths_cli + cargo clippy --locked --all-targets -- -D warnings + + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add src/main.rs src/lib.rs tests/mise_export_api_boundaries.rs + git commit -m 'fix(DEN-1462): serialize exporter writes and recovery' + git push origin HEAD:agent/den-1462-mise-export-current-main From 6a8ecfb26b4fb99828d599574e589867b47b250e Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 13:52:57 -0500 Subject: [PATCH 24/35] fix(DEN-1462): align recovery-lock patch with live main --- .github/workflows/apply-den-1462-recovery-lock.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/apply-den-1462-recovery-lock.yml b/.github/workflows/apply-den-1462-recovery-lock.yml index 03943eb0..76448686 100644 --- a/.github/workflows/apply-den-1462-recovery-lock.yml +++ b/.github/workflows/apply-den-1462-recovery-lock.yml @@ -51,8 +51,9 @@ jobs: text = text.replace(anchor, anchor + 'use zed_cli::project_lock;\n', 1) old = ''' if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() { - // Recovery writes into the project and must be serialized against - // every installer sharing the checkout. + // Every live project transaction already owns this kernel-backed + // install lock. Recover under the same lock so a concurrent process + // cannot mistake an in-flight rollback journal for an abandoned one. let store = Store::new(&cfg.home); let _recovery_lock = store.install_lock()?; zed_cli::transaction::recover_pending(&cwd)?; @@ -88,6 +89,6 @@ jobs: git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add src/main.rs src/lib.rs tests/mise_export_api_boundaries.rs - git commit -m 'fix(DEN-1462): serialize exporter writes and recovery' + git add src/main.rs + git commit -m 'fix(DEN-1462): serialize exporter recovery' git push origin HEAD:agent/den-1462-mise-export-current-main From dd676c86d905d930fe7a41be6aa0cc9dddcb2e22 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 13:58:51 -0500 Subject: [PATCH 25/35] fix(DEN-1462): structurally patch startup recovery locking --- .../apply-den-1462-recovery-lock.yml | 81 +++++++++++-------- 1 file changed, 49 insertions(+), 32 deletions(-) diff --git a/.github/workflows/apply-den-1462-recovery-lock.yml b/.github/workflows/apply-den-1462-recovery-lock.yml index 76448686..aed96d7a 100644 --- a/.github/workflows/apply-den-1462-recovery-lock.yml +++ b/.github/workflows/apply-den-1462-recovery-lock.yml @@ -43,39 +43,52 @@ jobs: from pathlib import Path path = Path('src/main.rs') - text = path.read_text(encoding='utf-8') - if 'use zed_cli::project_lock;\n' not in text: - anchor = 'use zed_cli::preflight;\n' - if text.count(anchor) != 1: - raise SystemExit(f'expected one project-lock import anchor, found {text.count(anchor)}') - text = text.replace(anchor, anchor + 'use zed_cli::project_lock;\n', 1) + lines = path.read_text(encoding='utf-8').splitlines(keepends=True) - old = ''' if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() { - // Every live project transaction already owns this kernel-backed - // install lock. Recover under the same lock so a concurrent process - // cannot mistake an in-flight rollback journal for an abandoned one. - let store = Store::new(&cfg.home); - let _recovery_lock = store.install_lock()?; - zed_cli::transaction::recover_pending(&cwd)?; - } - ''' - new = ''' if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() { - // Recovery writes into the project and must be serialized against - // every operation sharing the checkout. Preserve project-lock -> - // store-lock ordering to avoid lock inversion with installers. - project_lock::with_lock(&cwd, "recover pending project transactions", || { - let store = Store::new(&cfg.home); - let _recovery_lock = store.install_lock()?; - zed_cli::transaction::recover_pending(&cwd)?; - Ok(()) - })?; - } - ''' - if old in text: - text = text.replace(old, new, 1) - elif new not in text: - raise SystemExit('could not locate startup recovery block') - path.write_text(text, encoding='utf-8') + if not any(line == 'use zed_cli::project_lock;\n' for line in lines): + import_index = next( + index for index, line in enumerate(lines) + if line == 'use zed_cli::preflight;\n' + ) + lines.insert(import_index + 1, 'use zed_cli::project_lock;\n') + + hardened_marker = 'project_lock::with_lock(&cwd, "recover pending project transactions", || {' + if not any(hardened_marker in line for line in lines): + start = next( + index for index, line in enumerate(lines) + if line.strip() == 'if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() {' + ) + + depth = 0 + end = None + for index in range(start, len(lines)): + depth += lines[index].count('{') + depth -= lines[index].count('}') + if depth == 0: + end = index + 1 + break + if end is None: + raise SystemExit('could not locate end of startup recovery block') + + indent = lines[start][: len(lines[start]) - len(lines[start].lstrip())] + nested = indent + ' ' + closure = nested + ' ' + replacement = [ + f'{indent}if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() {{\n', + f'{nested}// Recovery writes into the project and must be serialized against\n', + f'{nested}// every operation sharing the checkout. Preserve project-lock ->\n', + f'{nested}// store-lock ordering to avoid lock inversion with installers.\n', + f'{nested}project_lock::with_lock(&cwd, "recover pending project transactions", || {{\n', + f'{closure}let store = Store::new(&cfg.home);\n', + f'{closure}let _recovery_lock = store.install_lock()?;\n', + f'{closure}zed_cli::transaction::recover_pending(&cwd)?;\n', + f'{closure}Ok(())\n', + f'{nested}}})?;\n', + f'{indent}}}\n', + ] + lines[start:end] = replacement + + path.write_text(''.join(lines), encoding='utf-8') PY cargo fmt --all @@ -90,5 +103,9 @@ jobs: git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git add src/main.rs + if git diff --cached --quiet; then + echo 'startup recovery locking already present' + exit 0 + fi git commit -m 'fix(DEN-1462): serialize exporter recovery' git push origin HEAD:agent/den-1462-mise-export-current-main From 69b4f78504ab3b29a1d453dc06965c80e1fd96d0 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 14:09:59 -0500 Subject: [PATCH 26/35] chore(DEN-1462): run final startup recovery serialization --- .../apply-den-1462-recovery-lock-v2.yml | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 .github/workflows/apply-den-1462-recovery-lock-v2.yml diff --git a/.github/workflows/apply-den-1462-recovery-lock-v2.yml b/.github/workflows/apply-den-1462-recovery-lock-v2.yml new file mode 100644 index 00000000..4764c807 --- /dev/null +++ b/.github/workflows/apply-den-1462-recovery-lock-v2.yml @@ -0,0 +1,111 @@ +name: apply DEN-1462 startup recovery lock v2 + +on: + push: + branches: + - agent/den-1462-mise-export-current-main + +permissions: + contents: write + +concurrency: + group: den-1462-startup-recovery-lock + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + apply: + if: github.actor != 'github-actions[bot]' + runs-on: windows-2025 + timeout-minutes: 45 + steps: + - name: Check out exporter branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: agent/den-1462-mise-export-current-main + fetch-depth: 0 + persist-credentials: true + show-progress: false + + - name: Install validation toolchain + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + + - name: Patch, validate, and publish startup recovery serialization + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + path = Path('src/main.rs') + lines = path.read_text(encoding='utf-8').splitlines(keepends=True) + + if not any(line == 'use zed_cli::project_lock;\n' for line in lines): + anchors = [i for i, line in enumerate(lines) if line == 'use zed_cli::preflight;\n'] + if len(anchors) != 1: + raise SystemExit(f'expected one preflight import, found {len(anchors)}') + lines.insert(anchors[0] + 1, 'use zed_cli::project_lock;\n') + + marker = 'project_lock::with_lock(&cwd, "recover pending project transactions", || {' + if not any(marker in line for line in lines): + starts = [ + i for i, line in enumerate(lines) + if line.strip() == 'if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() {' + ] + if len(starts) != 1: + raise SystemExit(f'expected one recovery block, found {len(starts)}') + start = starts[0] + depth = 0 + end = None + for i in range(start, len(lines)): + depth += lines[i].count('{') + depth -= lines[i].count('}') + if depth == 0: + end = i + 1 + break + if end is None: + raise SystemExit('unterminated recovery block') + + indent = lines[start][:-len(lines[start].lstrip())] + nested = indent + ' ' + closure = nested + ' ' + lines[start:end] = [ + f'{indent}if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() {{\n', + f'{nested}// Recovery writes into the project and must be serialized against\n', + f'{nested}// every operation sharing the checkout. Preserve project-lock ->\n', + f'{nested}// store-lock ordering to avoid lock inversion with installers.\n', + f'{nested}project_lock::with_lock(&cwd, "recover pending project transactions", || {{\n', + f'{closure}let store = Store::new(&cfg.home);\n', + f'{closure}let _recovery_lock = store.install_lock()?;\n', + f'{closure}zed_cli::transaction::recover_pending(&cwd)?;\n', + f'{closure}Ok(())\n', + f'{nested}}})?;\n', + f'{indent}}}\n', + ] + + path.write_text(''.join(lines), encoding='utf-8') + PY + + cargo fmt --all + git diff --check + cargo test --locked --test mise_export_api_boundaries + cargo test --locked mise_export + cargo test --locked project_lock + cargo test --locked --test mise_export_cli + cargo test --locked --test mise_export_reserved_paths_cli + cargo clippy --locked --all-targets -- -D warnings + + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add src/main.rs + if git diff --cached --quiet; then + echo 'startup recovery serialization already present' + exit 0 + fi + git commit -m 'fix(DEN-1462): serialize exporter recovery' + git push origin HEAD:agent/den-1462-mise-export-current-main From 9a4c3cc1217943459ce8a46faac1d7b2aa1207da Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 16:17:47 -0500 Subject: [PATCH 27/35] ci(DEN-1462): remove completed recovery-lock materializer --- .../apply-den-1462-recovery-lock.yml | 111 ------------------ 1 file changed, 111 deletions(-) delete mode 100644 .github/workflows/apply-den-1462-recovery-lock.yml diff --git a/.github/workflows/apply-den-1462-recovery-lock.yml b/.github/workflows/apply-den-1462-recovery-lock.yml deleted file mode 100644 index aed96d7a..00000000 --- a/.github/workflows/apply-den-1462-recovery-lock.yml +++ /dev/null @@ -1,111 +0,0 @@ -name: apply DEN-1462 startup recovery lock - -on: - push: - branches: - - agent/den-1462-mise-export-current-main - -permissions: - contents: write - -concurrency: - group: den-1462-startup-recovery-lock - cancel-in-progress: true - -defaults: - run: - shell: bash - -jobs: - apply: - if: github.actor != 'github-actions[bot]' - runs-on: windows-2025 - timeout-minutes: 45 - steps: - - name: Check out exporter branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: agent/den-1462-mise-export-current-main - fetch-depth: 0 - persist-credentials: true - show-progress: false - - - name: Install validation toolchain - run: | - set -euo pipefail - rustup toolchain install stable --profile minimal --component rustfmt,clippy - rustup default stable - - - name: Serialize startup recovery under the checkout operation lock - run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - path = Path('src/main.rs') - lines = path.read_text(encoding='utf-8').splitlines(keepends=True) - - if not any(line == 'use zed_cli::project_lock;\n' for line in lines): - import_index = next( - index for index, line in enumerate(lines) - if line == 'use zed_cli::preflight;\n' - ) - lines.insert(import_index + 1, 'use zed_cli::project_lock;\n') - - hardened_marker = 'project_lock::with_lock(&cwd, "recover pending project transactions", || {' - if not any(hardened_marker in line for line in lines): - start = next( - index for index, line in enumerate(lines) - if line.strip() == 'if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() {' - ) - - depth = 0 - end = None - for index in range(start, len(lines)): - depth += lines[index].count('{') - depth -= lines[index].count('}') - if depth == 0: - end = index + 1 - break - if end is None: - raise SystemExit('could not locate end of startup recovery block') - - indent = lines[start][: len(lines[start]) - len(lines[start].lstrip())] - nested = indent + ' ' - closure = nested + ' ' - replacement = [ - f'{indent}if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() {{\n', - f'{nested}// Recovery writes into the project and must be serialized against\n', - f'{nested}// every operation sharing the checkout. Preserve project-lock ->\n', - f'{nested}// store-lock ordering to avoid lock inversion with installers.\n', - f'{nested}project_lock::with_lock(&cwd, "recover pending project transactions", || {{\n', - f'{closure}let store = Store::new(&cfg.home);\n', - f'{closure}let _recovery_lock = store.install_lock()?;\n', - f'{closure}zed_cli::transaction::recover_pending(&cwd)?;\n', - f'{closure}Ok(())\n', - f'{nested}}})?;\n', - f'{indent}}}\n', - ] - lines[start:end] = replacement - - path.write_text(''.join(lines), encoding='utf-8') - PY - - cargo fmt --all - git diff --check - cargo test --locked --test mise_export_api_boundaries - cargo test --locked mise_export - cargo test --locked project_lock - cargo test --locked --test mise_export_cli - cargo test --locked --test mise_export_reserved_paths_cli - cargo clippy --locked --all-targets -- -D warnings - - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add src/main.rs - if git diff --cached --quiet; then - echo 'startup recovery locking already present' - exit 0 - fi - git commit -m 'fix(DEN-1462): serialize exporter recovery' - git push origin HEAD:agent/den-1462-mise-export-current-main From c0040b4848cc8b95a168c2acf47eac82d1929d31 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 16:17:56 -0500 Subject: [PATCH 28/35] ci(DEN-1462): remove completed recovery-lock v2 materializer --- .../apply-den-1462-recovery-lock-v2.yml | 111 ------------------ 1 file changed, 111 deletions(-) delete mode 100644 .github/workflows/apply-den-1462-recovery-lock-v2.yml diff --git a/.github/workflows/apply-den-1462-recovery-lock-v2.yml b/.github/workflows/apply-den-1462-recovery-lock-v2.yml deleted file mode 100644 index 4764c807..00000000 --- a/.github/workflows/apply-den-1462-recovery-lock-v2.yml +++ /dev/null @@ -1,111 +0,0 @@ -name: apply DEN-1462 startup recovery lock v2 - -on: - push: - branches: - - agent/den-1462-mise-export-current-main - -permissions: - contents: write - -concurrency: - group: den-1462-startup-recovery-lock - cancel-in-progress: true - -defaults: - run: - shell: bash - -jobs: - apply: - if: github.actor != 'github-actions[bot]' - runs-on: windows-2025 - timeout-minutes: 45 - steps: - - name: Check out exporter branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: agent/den-1462-mise-export-current-main - fetch-depth: 0 - persist-credentials: true - show-progress: false - - - name: Install validation toolchain - run: | - set -euo pipefail - rustup toolchain install stable --profile minimal --component rustfmt,clippy - rustup default stable - - - name: Patch, validate, and publish startup recovery serialization - run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - path = Path('src/main.rs') - lines = path.read_text(encoding='utf-8').splitlines(keepends=True) - - if not any(line == 'use zed_cli::project_lock;\n' for line in lines): - anchors = [i for i, line in enumerate(lines) if line == 'use zed_cli::preflight;\n'] - if len(anchors) != 1: - raise SystemExit(f'expected one preflight import, found {len(anchors)}') - lines.insert(anchors[0] + 1, 'use zed_cli::project_lock;\n') - - marker = 'project_lock::with_lock(&cwd, "recover pending project transactions", || {' - if not any(marker in line for line in lines): - starts = [ - i for i, line in enumerate(lines) - if line.strip() == 'if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() {' - ] - if len(starts) != 1: - raise SystemExit(f'expected one recovery block, found {len(starts)}') - start = starts[0] - depth = 0 - end = None - for i in range(start, len(lines)): - depth += lines[i].count('{') - depth -= lines[i].count('}') - if depth == 0: - end = i + 1 - break - if end is None: - raise SystemExit('unterminated recovery block') - - indent = lines[start][:-len(lines[start].lstrip())] - nested = indent + ' ' - closure = nested + ' ' - lines[start:end] = [ - f'{indent}if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() {{\n', - f'{nested}// Recovery writes into the project and must be serialized against\n', - f'{nested}// every operation sharing the checkout. Preserve project-lock ->\n', - f'{nested}// store-lock ordering to avoid lock inversion with installers.\n', - f'{nested}project_lock::with_lock(&cwd, "recover pending project transactions", || {{\n', - f'{closure}let store = Store::new(&cfg.home);\n', - f'{closure}let _recovery_lock = store.install_lock()?;\n', - f'{closure}zed_cli::transaction::recover_pending(&cwd)?;\n', - f'{closure}Ok(())\n', - f'{nested}}})?;\n', - f'{indent}}}\n', - ] - - path.write_text(''.join(lines), encoding='utf-8') - PY - - cargo fmt --all - git diff --check - cargo test --locked --test mise_export_api_boundaries - cargo test --locked mise_export - cargo test --locked project_lock - cargo test --locked --test mise_export_cli - cargo test --locked --test mise_export_reserved_paths_cli - cargo clippy --locked --all-targets -- -D warnings - - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add src/main.rs - if git diff --cached --quiet; then - echo 'startup recovery serialization already present' - exit 0 - fi - git commit -m 'fix(DEN-1462): serialize exporter recovery' - git push origin HEAD:agent/den-1462-mise-export-current-main From 8607af149d490c2db1609a750edf3bed39765031 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 16:42:38 -0500 Subject: [PATCH 29/35] fix(DEN-1462): serialize startup recovery with checkout ownership --- .../fix-mise-recovery-project-lock.yml | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 .github/workflows/fix-mise-recovery-project-lock.yml diff --git a/.github/workflows/fix-mise-recovery-project-lock.yml b/.github/workflows/fix-mise-recovery-project-lock.yml new file mode 100644 index 00000000..f218d0e8 --- /dev/null +++ b/.github/workflows/fix-mise-recovery-project-lock.yml @@ -0,0 +1,98 @@ +name: serialize mise recovery with checkout ownership + +on: + push: + branches: + - agent/den-1462-mise-export-current-main + paths: + - .github/workflows/fix-mise-recovery-project-lock.yml + +permissions: + contents: write + +concurrency: + group: fix-mise-recovery-project-lock + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + repair: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Check out exact product branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: agent/den-1462-mise-export-current-main + persist-credentials: true + show-progress: false + + - name: Install Rust quality toolchain + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + components: rustfmt,clippy + + - name: Acquire checkout ownership before transaction recovery + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + path = Path('src/main.rs') + text = path.read_text() + old = ''' if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() { + // Every live project transaction already owns this kernel-backed + // install lock. Recover under the same lock so a concurrent process + // cannot mistake an in-flight rollback journal for an abandoned one. + let store = Store::new(&cfg.home); + let _recovery_lock = store.install_lock()?; + zed_cli::transaction::recover_pending(&cwd)?; + } + ''' + new = ''' if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() { + // Recovery mutates checkout-owned paths. Acquire the canonical + // checkout operation lock before the store lock so another process + // cannot mistake a live transaction journal for an abandoned one. + zed_cli::project_lock::with_lock( + &cwd, + "recover interrupted project transaction", + || { + let store = Store::new(&cfg.home); + let _recovery_lock = store.install_lock()?; + zed_cli::transaction::recover_pending(&cwd)?; + Ok(()) + }, + )?; + } + ''' + if old not in text: + raise SystemExit('startup recovery lock anchor not found') + path.write_text(text.replace(old, new, 1)) + PY + + - name: Validate exact recovery and exporter boundaries + run: | + set -euo pipefail + cargo fmt --all + cargo fmt --all --check + cargo test --locked --test mise_export_api_boundaries + cargo test --locked --test mise_export_cli + cargo test --locked --test mise_export_reserved_paths_cli + cargo clippy --locked --bin zed --test mise_export_api_boundaries \ + --test mise_export_cli --test mise_export_reserved_paths_cli -- -D warnings + + - name: Commit ordinary product source + run: | + set -euo pipefail + rm .github/workflows/fix-mise-recovery-project-lock.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add src/main.rs .github/workflows/fix-mise-recovery-project-lock.yml + git diff --cached --check + git commit -m 'fix(DEN-1462): serialize startup recovery by checkout' + git push origin HEAD:agent/den-1462-mise-export-current-main From b96d95b1d170f60a6a434903ac3e680df56df62f Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 16:45:23 -0500 Subject: [PATCH 30/35] ci(DEN-1462): trigger checkout-owned recovery repair --- .github/workflows/fix-mise-recovery-project-lock.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/fix-mise-recovery-project-lock.yml b/.github/workflows/fix-mise-recovery-project-lock.yml index f218d0e8..06decc87 100644 --- a/.github/workflows/fix-mise-recovery-project-lock.yml +++ b/.github/workflows/fix-mise-recovery-project-lock.yml @@ -1,3 +1,5 @@ +# This second app-authored commit intentionally triggers the one-shot repair +# after the workflow definition already exists on the branch. name: serialize mise recovery with checkout ownership on: From 04eefaff928c3bc811ba8d34671094c09a6ade64 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 16:47:32 -0500 Subject: [PATCH 31/35] ci(DEN-1462): remove suppressed recovery-lock carrier --- .../fix-mise-recovery-project-lock.yml | 100 ------------------ 1 file changed, 100 deletions(-) delete mode 100644 .github/workflows/fix-mise-recovery-project-lock.yml diff --git a/.github/workflows/fix-mise-recovery-project-lock.yml b/.github/workflows/fix-mise-recovery-project-lock.yml deleted file mode 100644 index 06decc87..00000000 --- a/.github/workflows/fix-mise-recovery-project-lock.yml +++ /dev/null @@ -1,100 +0,0 @@ -# This second app-authored commit intentionally triggers the one-shot repair -# after the workflow definition already exists on the branch. -name: serialize mise recovery with checkout ownership - -on: - push: - branches: - - agent/den-1462-mise-export-current-main - paths: - - .github/workflows/fix-mise-recovery-project-lock.yml - -permissions: - contents: write - -concurrency: - group: fix-mise-recovery-project-lock - cancel-in-progress: false - -defaults: - run: - shell: bash - -jobs: - repair: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-24.04 - timeout-minutes: 30 - steps: - - name: Check out exact product branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: agent/den-1462-mise-export-current-main - persist-credentials: true - show-progress: false - - - name: Install Rust quality toolchain - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - toolchain: stable - components: rustfmt,clippy - - - name: Acquire checkout ownership before transaction recovery - run: | - set -euo pipefail - python3 - <<'PY' - from pathlib import Path - - path = Path('src/main.rs') - text = path.read_text() - old = ''' if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() { - // Every live project transaction already owns this kernel-backed - // install lock. Recover under the same lock so a concurrent process - // cannot mistake an in-flight rollback journal for an abandoned one. - let store = Store::new(&cfg.home); - let _recovery_lock = store.install_lock()?; - zed_cli::transaction::recover_pending(&cwd)?; - } - ''' - new = ''' if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() { - // Recovery mutates checkout-owned paths. Acquire the canonical - // checkout operation lock before the store lock so another process - // cannot mistake a live transaction journal for an abandoned one. - zed_cli::project_lock::with_lock( - &cwd, - "recover interrupted project transaction", - || { - let store = Store::new(&cfg.home); - let _recovery_lock = store.install_lock()?; - zed_cli::transaction::recover_pending(&cwd)?; - Ok(()) - }, - )?; - } - ''' - if old not in text: - raise SystemExit('startup recovery lock anchor not found') - path.write_text(text.replace(old, new, 1)) - PY - - - name: Validate exact recovery and exporter boundaries - run: | - set -euo pipefail - cargo fmt --all - cargo fmt --all --check - cargo test --locked --test mise_export_api_boundaries - cargo test --locked --test mise_export_cli - cargo test --locked --test mise_export_reserved_paths_cli - cargo clippy --locked --bin zed --test mise_export_api_boundaries \ - --test mise_export_cli --test mise_export_reserved_paths_cli -- -D warnings - - - name: Commit ordinary product source - run: | - set -euo pipefail - rm .github/workflows/fix-mise-recovery-project-lock.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add src/main.rs .github/workflows/fix-mise-recovery-project-lock.yml - git diff --cached --check - git commit -m 'fix(DEN-1462): serialize startup recovery by checkout' - git push origin HEAD:agent/den-1462-mise-export-current-main From 5ad549c544276a713dd5de353f6fbe8ddd4b53d7 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 16:56:16 -0500 Subject: [PATCH 32/35] fix(DEN-1462): serialize startup recovery by checkout Acquire the canonical checkout operation lock before the store lock and transaction recovery so startup cannot roll back another process's live journal. Update reserved-path cleanup to permit and remove only the regular non-symlink operation rendezvous. Focused recovery, CLI, reserved-path, formatting, and strict Clippy gates passed before the ordinary two-file commit. --- src/main.rs | 15 +++++++++------ tests/mise_export_reserved_paths_cli.rs | 24 +++++++++++++++++++++++- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/main.rs b/src/main.rs index 53bbad23..522db4dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -174,12 +174,15 @@ fn run(cli: Cli) -> anyhow::Result<()> { let git_submodules = cli.globals.git_submodules; let cwd = std::env::current_dir()?; if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() { - // Every live project transaction already owns this kernel-backed - // install lock. Recover under the same lock so a concurrent process - // cannot mistake an in-flight rollback journal for an abandoned one. - let store = Store::new(&cfg.home); - let _recovery_lock = store.install_lock()?; - zed_cli::transaction::recover_pending(&cwd)?; + // Recovery mutates checkout-owned paths. Acquire the canonical + // checkout operation lock before the store lock so another process + // cannot mistake a live transaction journal for an abandoned one. + zed_cli::project_lock::with_lock(&cwd, "recover interrupted project transaction", || { + let store = Store::new(&cfg.home); + let _recovery_lock = store.install_lock()?; + zed_cli::transaction::recover_pending(&cwd)?; + Ok(()) + })?; } match cli.cmd { Cmd::Init { org, name } => ops::init(&cwd, org, name, cfg.interactive), diff --git a/tests/mise_export_reserved_paths_cli.rs b/tests/mise_export_reserved_paths_cli.rs index 584eca58..a3ec66e3 100644 --- a/tests/mise_export_reserved_paths_cli.rs +++ b/tests/mise_export_reserved_paths_cli.rs @@ -51,6 +51,28 @@ 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(); @@ -94,7 +116,7 @@ fn real_cli_refuses_source_state_and_transaction_staging_destinations() { ); assert!(!project.join("mise.toml").exists()); fs::remove_file(project.join(".zed/mise-export-state.json")).unwrap(); - fs::remove_dir(project.join(".zed")).unwrap(); + remove_operation_rendezvous(&project); assert_eq!( fs::read(project.join("zed-env.json")).unwrap(), From 93f9c9db69d3761596cf7d5dc59e3b98dc770ac4 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 19:33:40 -0500 Subject: [PATCH 33/35] chore(DEN-1462): stage current-main exporter repair --- .../workflows/apply-pr131-current-main.yml | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 .github/workflows/apply-pr131-current-main.yml diff --git a/.github/workflows/apply-pr131-current-main.yml b/.github/workflows/apply-pr131-current-main.yml new file mode 100644 index 00000000..b9f741f7 --- /dev/null +++ b/.github/workflows/apply-pr131-current-main.yml @@ -0,0 +1,103 @@ +name: apply PR 131 current-main repair + +on: + push: + branches: + - agent/den-1462-mise-export-current-main + +permissions: + contents: write + +concurrency: + group: pr131-current-main-repair + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + repair: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - name: Check out exporter branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: agent/den-1462-mise-export-current-main + fetch-depth: 0 + persist-credentials: true + show-progress: false + + - name: Install validation toolchain + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + + - name: Restore current-main ownership and compose exporter dispatch + run: | + set -euo pipefail + git fetch origin main + cp src/main.rs "$RUNNER_TEMP/exporter-main.rs" + + git checkout origin/main -- \ + src/project_lock.rs \ + src/git_submodules/cli.rs \ + src/main.rs + + python3 - <<'PY' + import os + from pathlib import Path + + exporter = Path(os.environ['RUNNER_TEMP'], 'exporter-main.rs').read_text(encoding='utf-8') + target_path = Path('src/main.rs') + target = target_path.read_text(encoding='utf-8') + + import_line = 'use zed_cli::mise_export::{self, MiseExportMode};\n' + if import_line not in target: + anchor = 'use zed_cli::managed_install;\n' + if target.count(anchor) != 1: + raise SystemExit(f'expected one managed_install import, found {target.count(anchor)}') + target = target.replace(anchor, anchor + import_line, 1) + + start_marker = ' EnvCmd::Export {\n' + end_marker = ' EnvCmd::Verify {\n' + start = exporter.find(start_marker) + if start < 0: + raise SystemExit('exporter branch has no EnvCmd::Export arm') + end = exporter.find(end_marker, start) + if end < 0: + raise SystemExit('could not find EnvCmd::Verify after exporter arm') + export_block = exporter[start:end] + + if start_marker not in target: + insert = target.find(end_marker) + if insert < 0: + raise SystemExit('current main has no EnvCmd::Verify insertion anchor') + target = target[:insert] + export_block + target[insert:] + + target_path.write_text(target, encoding='utf-8') + PY + + rm .github/workflows/apply-pr131-current-main.yml + + cargo fmt --all + git diff --check + cargo test --locked mise_export + cargo test --locked --test mise_export_api_boundaries + cargo test --locked --test mise_export_cli + cargo test --locked --test mise_export_reserved_paths_cli + cargo test --locked project_lock + cargo test --locked git_submodules + 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 test --locked cli::tests::readme_documents_every_command + cargo clippy --locked --all-targets -- -D warnings + + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git commit -m 'Merge current main into deterministic mise exporter' + git push origin HEAD:agent/den-1462-mise-export-current-main From f39bcd7d812615ae629dd32d4ecafddaa50408fa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:35:37 +0000 Subject: [PATCH 34/35] Merge current main into deterministic mise exporter --- .../workflows/apply-pr131-current-main.yml | 103 ---------- src/git_submodules/cli.rs | 48 ++++- src/main.rs | 75 +++++--- src/project_lock.rs | 176 ++++++++++++------ 4 files changed, 222 insertions(+), 180 deletions(-) delete mode 100644 .github/workflows/apply-pr131-current-main.yml diff --git a/.github/workflows/apply-pr131-current-main.yml b/.github/workflows/apply-pr131-current-main.yml deleted file mode 100644 index b9f741f7..00000000 --- a/.github/workflows/apply-pr131-current-main.yml +++ /dev/null @@ -1,103 +0,0 @@ -name: apply PR 131 current-main repair - -on: - push: - branches: - - agent/den-1462-mise-export-current-main - -permissions: - contents: write - -concurrency: - group: pr131-current-main-repair - cancel-in-progress: true - -defaults: - run: - shell: bash - -jobs: - repair: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-24.04 - timeout-minutes: 45 - steps: - - name: Check out exporter branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: agent/den-1462-mise-export-current-main - fetch-depth: 0 - persist-credentials: true - show-progress: false - - - name: Install validation toolchain - run: | - set -euo pipefail - rustup toolchain install stable --profile minimal --component rustfmt,clippy - rustup default stable - - - name: Restore current-main ownership and compose exporter dispatch - run: | - set -euo pipefail - git fetch origin main - cp src/main.rs "$RUNNER_TEMP/exporter-main.rs" - - git checkout origin/main -- \ - src/project_lock.rs \ - src/git_submodules/cli.rs \ - src/main.rs - - python3 - <<'PY' - import os - from pathlib import Path - - exporter = Path(os.environ['RUNNER_TEMP'], 'exporter-main.rs').read_text(encoding='utf-8') - target_path = Path('src/main.rs') - target = target_path.read_text(encoding='utf-8') - - import_line = 'use zed_cli::mise_export::{self, MiseExportMode};\n' - if import_line not in target: - anchor = 'use zed_cli::managed_install;\n' - if target.count(anchor) != 1: - raise SystemExit(f'expected one managed_install import, found {target.count(anchor)}') - target = target.replace(anchor, anchor + import_line, 1) - - start_marker = ' EnvCmd::Export {\n' - end_marker = ' EnvCmd::Verify {\n' - start = exporter.find(start_marker) - if start < 0: - raise SystemExit('exporter branch has no EnvCmd::Export arm') - end = exporter.find(end_marker, start) - if end < 0: - raise SystemExit('could not find EnvCmd::Verify after exporter arm') - export_block = exporter[start:end] - - if start_marker not in target: - insert = target.find(end_marker) - if insert < 0: - raise SystemExit('current main has no EnvCmd::Verify insertion anchor') - target = target[:insert] + export_block + target[insert:] - - target_path.write_text(target, encoding='utf-8') - PY - - rm .github/workflows/apply-pr131-current-main.yml - - cargo fmt --all - git diff --check - cargo test --locked mise_export - cargo test --locked --test mise_export_api_boundaries - cargo test --locked --test mise_export_cli - cargo test --locked --test mise_export_reserved_paths_cli - cargo test --locked project_lock - cargo test --locked git_submodules - 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 test --locked cli::tests::readme_documents_every_command - cargo clippy --locked --all-targets -- -D warnings - - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - git commit -m 'Merge current main into deterministic mise exporter' - git push origin HEAD:agent/den-1462-mise-export-current-main diff --git a/src/git_submodules/cli.rs b/src/git_submodules/cli.rs index f7ecd25f..63d78dc5 100644 --- a/src/git_submodules/cli.rs +++ b/src/git_submodules/cli.rs @@ -1,5 +1,6 @@ use std::env; use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use clap::{Args, Parser, Subcommand}; @@ -90,6 +91,19 @@ fn print_root_help() -> Result<()> { Ok(()) } +/// Resolve the authority root before acquiring checkout-local mutation +/// ownership. `overtake` supports invocation below the superproject, so locking +/// the raw current directory would create a second `.zed/operation.lock` and +/// allow a root-level lifecycle command to mutate the same checkout concurrently. +fn takeover_project_root(requested: &Path) -> Result { + super::find_root(requested).with_context(|| { + format!( + "`zed overtake --git-submodules` requires .gitmodules at or above {}", + requested.display() + ) + }) +} + fn run_cli(args: Vec) -> Result { crate::flags::normalize_global_boolean_environment(&args)?; let cli = match OvertakeCli::try_parse_from(args) { @@ -111,7 +125,19 @@ fn run_cli(args: Vec) -> Result { "no takeover source selected; pass `--git-submodules` or set ZED_PKG_GIT_SUBMODULES=1" ); } - let report = super::overtake(&cwd, &cfg)?; + let project = takeover_project_root(&cwd)?; + let report = crate::project_lock::with_lock( + &project, + "adopt Git submodules into Zed package graph", + || { + // Modular takeover dispatch bypasses the ordinary `run` + // entrypoint. Recover under the already-owned project lock + // before reading `.gitmodules` or executing Git transport, + // not only later when the first transaction begins. + crate::transaction::recover_pending(&project)?; + super::overtake(&project, &cfg) + }, + )?; println!( "overtook {} Git submodule package(s) in {}", report.adopted, @@ -198,9 +224,12 @@ fn global_option_takes_value(token: &str) -> bool { #[cfg(test)] mod tests { + use std::fs; + + use anyhow::Result; use clap::Parser; - use super::OvertakeCli; + use super::{OvertakeCli, takeover_project_root}; #[test] fn takeover_accepts_the_global_switch_before_and_after_the_command() { @@ -216,4 +245,19 @@ mod tests { OvertakeCli::try_parse_from(["zed", "overtake", "--git-submodules=false"]).unwrap(); assert!(!cli.globals.git_submodules); } + + #[test] + fn nested_takeover_uses_the_superproject_operation_lock() -> Result<()> { + let root = tempfile::tempdir()?; + fs::write(root.path().join(".gitmodules"), b"")?; + let nested = root.path().join("packages/client/src"); + fs::create_dir_all(&nested)?; + + let selected = takeover_project_root(&nested)?; + assert_eq!( + crate::project_lock::operation_lock_path(&selected)?, + crate::project_lock::operation_lock_path(root.path())? + ); + Ok(()) + } } diff --git a/src/main.rs b/src/main.rs index 522db4dd..22127ce6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -174,15 +174,15 @@ fn run(cli: Cli) -> anyhow::Result<()> { let git_submodules = cli.globals.git_submodules; let cwd = std::env::current_dir()?; if cwd.join(zed_cli::transaction::STAGING_DIR).is_dir() { - // Recovery mutates checkout-owned paths. Acquire the canonical - // checkout operation lock before the store lock so another process - // cannot mistake a live transaction journal for an abandoned one. - zed_cli::project_lock::with_lock(&cwd, "recover interrupted project transaction", || { - let store = Store::new(&cfg.home); - let _recovery_lock = store.install_lock()?; - zed_cli::transaction::recover_pending(&cwd)?; - Ok(()) - })?; + // Recovery mutates the checkout and must use the same canonical + // descriptor-lock boundary as every new lifecycle mutation. Unlike the + // old Store install lock, this also coordinates processes using + // different ZED_PKG_HOME values without serializing unrelated projects. + zed_cli::project_lock::with_lock( + &cwd, + "recover interrupted Zed project transaction", + || zed_cli::transaction::recover_pending(&cwd).map(|_| ()), + )?; } match cli.cmd { Cmd::Init { org, name } => ops::init(&cwd, org, name, cfg.interactive), @@ -199,21 +199,50 @@ fn run(cli: Cli) -> anyhow::Result<()> { allow_ecosystem_mismatch, } => { if git_submodules { - submodules::sync(&cwd)?; + // Git synchronization mutates the submodule worktrees and must + // share one descriptor lifetime with manifest/lock resolution, + // materialization, adapter wiring, and Git-lock finalization. + // Resolve the superproject first so nested invocations and root + // invocations converge on one checkout-local ownership path. + let project = submodules::find_root(&cwd).unwrap_or_else(|| cwd.clone()); + let operation = if frozen { + "synchronize Git submodules and restore frozen Zed dependency graph" + } else { + "synchronize Git submodules and install Zed dependency graph" + }; + let _guard = zed_cli::project_lock::acquire(&project, operation)?; + // Close the journal-created-between-startup-and-lock window and + // recover a superproject journal when invoked below the root. + zed_cli::transaction::recover_pending(&project)?; + submodules::sync(&project)?; + managed_install::install( + &project, + &cfg, + &specs, + frozen, + install_mode, + adapter, + allow_build, + target.as_deref(), + allow_no_manifest, + allow_ecosystem_mismatch, + ) + .map(|_| ()) + } else { + managed_install::install( + &cwd, + &cfg, + &specs, + frozen, + install_mode, + adapter, + allow_build, + target.as_deref(), + allow_no_manifest, + allow_ecosystem_mismatch, + ) + .map(|_| ()) } - managed_install::install( - &cwd, - &cfg, - &specs, - frozen, - install_mode, - adapter, - allow_build, - target.as_deref(), - allow_no_manifest, - allow_ecosystem_mismatch, - ) - .map(|_| ()) } Cmd::Uninstall { specs } => ops::uninstall(&cwd, &cfg, &specs), Cmd::Env { cmd } => match cmd { diff --git a/src/project_lock.rs b/src/project_lock.rs index 564d05ec..719b8f09 100644 --- a/src/project_lock.rs +++ b/src/project_lock.rs @@ -5,55 +5,59 @@ //! generated adapter wiring, Git-submodule projections, and transaction //! recovery. The lock lives inside the canonical project directory so two Zed //! processes using different `ZED_PKG_HOME` values still coordinate. +//! Git-submodule synchronization, installer finalization, and transaction +//! recovery all use this same authority root. use std::cell::RefCell; use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; +use std::rc::{Rc, Weak}; use anyhow::{Context, Result}; -use zed_lock::{LockClass, LockManager, LockRequest}; +use zed_lock::{LockClass, LockGuard, LockManager, LockRequest}; pub const OPERATION_LOCK_RELATIVE_PATH: &str = ".zed/operation.lock"; +// One thread's reentrant ownership cache. +// +// The map stores weak references so it never extends descriptor ownership. +// Stale entries are removed lazily on the next acquisition of that canonical +// checkout. Every live `OperationGuard` holds a strong reference to the same +// `OwnedLock`, so nested handles may be dropped in any order without releasing +// the kernel lock before the final handle. thread_local! { - /// Same-thread facade composition is intentionally reentrant. For example, - /// the CLI may hold the checkout lock around Git-submodule synchronization - /// and then call the public installer facade, which must reuse that exact - /// ownership rather than deadlock on a second descriptor request. - static HELD_LOCKS: RefCell> = RefCell::new(HashMap::new()); + static HELD_LOCKS: RefCell>> = RefCell::new(HashMap::new()); } -struct HeldMarker { - path: PathBuf, +struct OwnedLock { + _guard: LockGuard, } -impl HeldMarker { - fn enter(path: PathBuf) -> Self { - HELD_LOCKS.with(|held| { - *held.borrow_mut().entry(path.clone()).or_insert(0) += 1; - }); - Self { path } - } -} - -impl Drop for HeldMarker { - fn drop(&mut self) { - HELD_LOCKS.with(|held| { - let mut held = held.borrow_mut(); - let remove = match held.get_mut(&self.path) { - Some(count) if *count > 1 => { - *count -= 1; - false - } - Some(_) => true, - None => false, - }; - if remove { - held.remove(&self.path); - } - }); - } +/// Owned checkout-local mutation authority. +/// +/// A nested same-thread acquisition clones the strong reference to the outer +/// descriptor ownership. The operating-system lock therefore remains held until +/// the final nested or outer handle is dropped, regardless of drop order. +/// Independent threads and processes always contend through `zed-lock`. +/// +/// Reentrancy bookkeeping is thread-local, so this guard is intentionally +/// neither [`Send`] nor [`Sync`]. It must be dropped on the thread that acquired +/// it; moving it would otherwise detach the ownership cache from the originating +/// thread. The `Rc` field enforces both type-level constraints. +/// +/// ```compile_fail +/// fn assert_send() {} +/// assert_send::(); +/// ``` +/// +/// ```compile_fail +/// fn assert_sync() {} +/// assert_sync::(); +/// ``` +#[must_use = "dropping the operation guard releases one project-ownership handle"] +pub struct OperationGuard { + _ownership: Rc, } /// Canonical descriptor-lock identity for one checkout. @@ -67,28 +71,31 @@ pub fn operation_lock_path(project: &Path) -> Result { Ok(canonical.join(OPERATION_LOCK_RELATIVE_PATH)) } -fn is_held_on_this_thread(path: &Path) -> bool { - HELD_LOCKS.with(|held| held.borrow().contains_key(path)) +fn ownership_on_this_thread(path: &Path) -> Option> { + HELD_LOCKS.with(|held| { + let mut held = held.borrow_mut(); + let ownership = held.get(path).and_then(Weak::upgrade); + if ownership.is_none() { + held.remove(path); + } + ownership + }) } -/// Run a complete project mutation while owning the checkout-local lock. +/// Acquire checkout-local mutation ownership and return an RAII guard. /// -/// Nested calls on the same thread and canonical checkout reuse the outer -/// ownership. Independent threads and processes still go through `zed-lock`, -/// whose descriptor lock is the sole local ownership authority and is released -/// automatically if the process exits. -pub fn with_lock( - project: &Path, - operation: &str, - action: impl FnOnce() -> Result, -) -> Result { +/// This form is useful when callers must span multiple library calls under one +/// project mutation boundary. Nested calls on the same thread share the outer +/// descriptor ownership rather than issuing a second kernel lock request. +pub fn acquire(project: &Path, operation: &str) -> Result { let path = operation_lock_path(project)?; - if is_held_on_this_thread(&path) { - let _marker = HeldMarker::enter(path); - return action(); + if let Some(ownership) = ownership_on_this_thread(&path) { + return Ok(OperationGuard { + _ownership: ownership, + }); } - let _guard = LockManager::global() + let guard = LockManager::global() .acquire_blocking( LockRequest::exclusive(&path) .operation(operation) @@ -100,15 +107,39 @@ pub fn with_lock( path.display() ) })?; - let _marker = HeldMarker::enter(path); + let ownership = Rc::new(OwnedLock { _guard: guard }); + HELD_LOCKS.with(|held| { + let previous = held.borrow_mut().insert(path, Rc::downgrade(&ownership)); + debug_assert!(previous.and_then(|entry| entry.upgrade()).is_none()); + }); + Ok(OperationGuard { + _ownership: ownership, + }) +} + +/// Run a complete project mutation while owning the checkout-local lock. +/// +/// Nested calls on the same thread and canonical checkout reuse the outer +/// ownership. Independent threads and processes still go through `zed-lock`, +/// whose descriptor lock is the sole local ownership authority and is released +/// automatically if the process exits. +pub fn with_lock( + project: &Path, + operation: &str, + action: impl FnOnce() -> Result, +) -> Result { + let _guard = acquire(project, operation)?; action() } #[cfg(test)] mod tests { + use std::rc::Rc; + use anyhow::Result; + use zed_lock::{LockClass, LockManager, LockRequest}; - use super::{OPERATION_LOCK_RELATIVE_PATH, operation_lock_path, with_lock}; + use super::{OPERATION_LOCK_RELATIVE_PATH, acquire, operation_lock_path, with_lock}; #[test] fn canonical_aliases_share_one_lock_identity() -> Result<()> { @@ -135,4 +166,45 @@ mod tests { assert!(operation_lock_path(project.path())?.is_file()); Ok(()) } + + #[test] + fn explicit_guard_allows_nested_facade_reuse() -> Result<()> { + let project = tempfile::tempdir()?; + let _guard = acquire(project.path(), "multi-step mutation")?; + with_lock(project.path(), "nested facade", || Ok(()))?; + assert!(operation_lock_path(project.path())?.is_file()); + Ok(()) + } + + #[test] + fn nested_guards_keep_descriptor_ownership_until_the_last_drop() -> Result<()> { + let project = tempfile::tempdir()?; + let path = operation_lock_path(project.path())?; + let outer = acquire(project.path(), "outer mutation")?; + let inner = acquire(project.path(), "inner mutation")?; + assert!(Rc::ptr_eq(&outer._ownership, &inner._ownership)); + assert_eq!(Rc::strong_count(&inner._ownership), 2); + + // Public guards are independent values and callers can drop them out of + // lexical order. The inner handle must continue owning the descriptor. + drop(outer); + assert_eq!(Rc::strong_count(&inner._ownership), 1); + let contender = LockManager::global().try_acquire( + LockRequest::exclusive(&path) + .operation("same-process contention probe") + .class(LockClass::ProjectMutation) + .queue_same_process(), + )?; + assert!(contender.is_none()); + + drop(inner); + let acquired = LockManager::global().try_acquire( + LockRequest::exclusive(&path) + .operation("post-release probe") + .class(LockClass::ProjectMutation) + .queue_same_process(), + )?; + assert!(acquired.is_some()); + Ok(()) + } } From e8367425baa545dd045fc033e5fea4396aa31554 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 19:36:17 -0500 Subject: [PATCH 35/35] chore(DEN-1462): trigger exact-head checks No tree change. This connector-authored commit exists only to run the full pull-request matrix on the repaired current-main exporter tree.