fix(cli): atomic mds build/watch outputs and .map sidecars via atomic_write_file (removes the spurious metadata warning); empty directory exits non-zero; atomic_write_file contract documented (#227, #225, #204, #226) - #385
Merged
Conversation
…targets, takes the mode from lstat — removes the spurious metadata warning (#227, #225) `atomic_write_file` was built for EXISTING files: it opened with `NativeFs::check_symlink(path)`, which hard-fails `file not found` on an absent target, and then took the Unix mode from a second `std::fs::metadata` call whose every `Err` printed `cannot get metadata for {path}: {e}` to stderr and silently continued with no mode (PR #240). That warning fired on every first `mds build` and a stat failure was never a decision, only a notice (#225). Existence is now decided by one `lstat` (`Path::symlink_metadata`, which never follows): - `Ok` + symlink -> refused, live or dangling: `cannot write {path}: refusing to replace a symlink`. - `Ok` + regular -> existing file: the `NativeFs::check_symlink` TOCTOU re-check runs exactly as before, and the mode comes from the lstat result already in hand — no second stat, so the warning has no site left. - `Err(NotFound)` -> new file: the final-component check is skipped and `tempfile::Builder::permissions(0o666)` is requested so the kernel applies the umask, matching what `std::fs::write` produced. `tempfile`'s default is 0600, which would have made every fresh artifact owner-only. - any other `Err` -> hard error `cannot stat {path}: {e}`, never a guess. `create_dir_all` stays out of the primitive (callers own it) and no parent-directory symlink check is added — macOS `/tmp` -> `/private/tmp` would break under one. Existing-file behaviour is byte-identical. RED (8 new unit tests written first, run against the unmodified primitive — `cargo nextest run -p mds-cli --bin mds atomic_write_file`: 4 passed, 4 failed): - atomic_write_file_creates_missing_target — FAIL: `cannot write …/fresh.md: file not found: …/fresh.md` - atomic_write_file_new_file_mode_matches_std_fs_write — FAIL: same `file not found` (never reached the 0600-vs-0644 mode assertion) - atomic_write_file_refuses_dangling_symlink_target — FAIL: got `file not found` instead of a symlink refusal - atomic_write_file_unreadable_parent_is_hard_error — FAIL: got `file not found` instead of `cannot stat` - atomic_write_file_existing_mode_0640_preserved — PASS (must stay passing) - atomic_write_file_refuses_live_symlink_target — PASS (must stay passing) - atomic_write_file_failure_preserves_original_and_leaves_no_temp — PASS - atomic_write_file_directory_target_refused_without_residue — PASS Every rejection test carries a positive control in the same test (the live symlink test writes the real file, the failure test re-runs once the directory is writable and asserts the inode DID change) and every chmod is restored before the assertions run. GREEN: - `cargo nextest run -p mds-cli --bin mds atomic_write_file` — 8/8 passed - `cargo nextest run -p mds-cli --bin mds` — 155/155 passed - `cargo nextest run -p mds-cli --test cli_fmt --test cli_lint` — 172/172 passed, unchanged-green (includes `fmt_single_file_preserves_mode_0644`, `lint_fix_preserves_mode_0644` and the stderr filename gates) - `cargo fmt --all --check` clean; `cargo clippy --workspace --all-targets -- -D warnings` clean - `grep -rn 'cannot get metadata' crates/` — 0 hits (positive control: the same grep finds a planted copy of the string) The rustdoc block gains a `# Contract (#226)` section stating that replace-by-rename gives the target a new inode and therefore preserves neither hard links nor ACLs, xattrs or owner/group — only the permission bits.
…gh atomic_write_file; add the write-funnel guard (#227) Five raw `std::fs::write` sites in build.rs now go through `crate::output::atomic_write_file` (temp file in the target directory → rename): `write_output`, the directory-mode compiled-output writer, and all three `.map` sidecar writers (stdin, single-file, directory mode). A failed or interrupted build can no longer leave a truncated artifact, and a symlink at an output path is refused instead of being written through. `create_dir_all` stays at each call site — the primitive deliberately does not create directories. Two production writes stay raw, each with a justification comment and an allow-list entry in the new guard: `mds init` (creates only, after an explicit exists-check) and the test-only watch readiness marker (already temp+rename). ## Write-funnel guard `crates/mds-cli/tests/write_funnel.rs` converts "did we remember every write site?" into a machine-checked invariant over `crates/mds-cli/src/**`: needles `fs::write(` / `File::create(` counted after masking comments and string literals and stripping `#[cfg(test)] mod` blocks. Three tests: the scan itself (with a lexical pin that the choke point still `.sync_all()`s and `.persist(`s), a positive self-check on synthetic sources, and an anti-rot check that every allow-list entry is still live. Lexical limits (`OpenOptions`, aliases) are stated in the module doc. Scanner helpers are copied from `crates/mds-core/tests/yaml_funnel.rs` — integration-test binaries cannot share code across crates. ## RED → GREEN evidence Tests written first and run against ee8c3f0 (`--no-fail-fast`, 121 tests, 114 passed / 7 failed): | id | test | RED @ ee8c3f0 | GREEN | |---|---|---|---| | T-F1 | write_sites_are_funnelled | FAIL — "raw write sites outside the atomic funnel (5 found)", all in build.rs | pass | | T-F2 | the_guard_flags_a_planted_raw_write | pass (positive self-check, synthetic sources only) | pass | | T-F3 | every_allowlist_entry_is_live | pass | pass | | T-B1 | build_o_first_build_emits_only_compiled_to | pass (sentinel pin against a naive reroute re-adding the #225 warning) | pass | | T-B2 | build_o_new_output_mode_matches_std_fs_write | pass (pin) | pass | | T-B3 | build_o_existing_output_mode_0640_preserved | pass — `std::fs::write` over an existing file already preserved the mode, so this is a pin, not a fix | pass | | T-B4 | build_o_symlinked_output_target_rejected | FAIL — exit 0, "Compiled to …/link.md"; main wrote through the link | pass | | T-B5 | build_o_write_failure_preserves_existing_output_no_temp_residue | FAIL — exit 0; main overwrote inside a 0555 directory | pass | | T-B6 | build_o_success_leaves_no_temp_residue | pass (pin) | pass | | T-B7 | build_o_bare_filename_writes_in_cwd | pass (pin) | pass | | T-S1 | build_source_map_sidecar_symlink_target_rejected | FAIL — exit 0, "Source map written to …" | pass | | T-S2 | build_stdin_source_map_sidecar_symlink_target_rejected | FAIL — exit 0, "Source map written to …" | pass | | T-D1 | dir_build_out_dir_source_map_no_temp_residue | pass (pin) | pass | | T-D2 | dir_build_write_failure_preserves_existing_outputs | FAIL — exit 0, "2 built, 0 failed" into a 0555 out-dir | pass | | T-D3 | dir_build_source_map_sidecar_symlink_target_rejected | FAIL — exit 0, "1 built, 0 failed" | pass | Every rejection test carries a positive control; every chmod is restored before the assertions run. All Unix-only tests are `#[cfg(unix)]`. No `#[ignore]`, no checked-in fixtures. Gates: `cargo nextest run -p mds-cli` 867/867 passed (851 before this commit); `--test print_discipline` 11/11 (no sanitizer-registry edits needed — `safe_inline` is already registered); `--test cli_watch` 79/79; `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings` and `cargo clippy -p mds-cli --all-targets --features startup-race-probe -- -D warnings` all exit 0; `RUSTDOCFLAGS="-D warnings" cargo doc -p mds-core --no-deps` clean. ## Durability split (measured, contingency C-1 applied) `sync_all()` is `F_FULLFSYNC` on macOS. Routing every artifact through it was measured on this machine (medians of 3, same session, back to back, only build.rs differing): | measurement | raw writes | atomic + unconditional fsync | atomic + C-1 | |---|---|---|---| | `watch_dir_mode_idle_500_files_no_recompile` (solo) | 1.436 s | 4.694 s (+227 %) | 1.554 s (+8 %) | | `cli_watch` suite (nextest) | 4.205 s | 8.347 s (+98 %) | 4.331 s (+3 %) | | `cli_watch` suite (wall) | 4.527 s | 8.667 s (+91 %) | 4.659 s (+3 %) | | `dir_build` suite | 0.652 s | 0.725 s | 0.483 s | Threshold BREACHED: the `cli_watch` suite grew +91 % wall against a +20 % budget, and inside the parallel suite the 500-file test reached 8.418 s against a 10 s `READY_TIMEOUT` — one slower runner from failing. Contingency C-1 applied: `pub(crate) enum Durability { Fsync, RenameOnly }` is now a third parameter to `atomic_write_file`. `fmt.rs` / `lint.rs` pass `Fsync` — they rewrite the user's hand-authored `.mds` source in place, so bytes lost to a power failure are lost for good. `write_output`, the dir-mode writer and all three `.map` sites pass `RenameOnly` — compiled artifacts are derived and a rebuild reproduces them. Atomicity is unconditional either way: it comes from the rename, not the fsync. `atomic_write_file_rename_only_matches_fsync_contract` pins that `RenameOnly` changes nothing else (content, fresh-file mode, symlink refusal, no residue). ## User-visible text (D7) On temp-file creation failure the message is now `cannot create temp file for {path}: {io error} at path "{tmp}"` where it was `cannot write {path}: {io error}`. In directory mode the per-file line is `error: cannot create temp file for …` where it was `error: cannot write …` — the primitive's message already names the path, so the call site no longer prefixes it (printing the path twice). Write failures still exit 1: the primitive returns a bare `miette::miette!` report, which `build::exit_code` maps to 1 rather than the I/O class 2. That is unchanged behaviour, not a decision taken here; exit 2 is arguably more correct and is left for a follow-up. Refs #227, #225
…h keeps waiting (#204) Tests first, deliberately failing. `mds build|check|fmt|lint <dir>` on a tree with zero .mds files exits 0 today (and prints nothing under --quiet), which is a silent CI green pass on a mistyped or not-yet-populated directory. The sibling all-excluded case already errors; these tests pin the empty case to the same shape. `mds watch <dir>` stays unchanged and is pinned as such. Three exit-0 tests are rewritten IN PLACE (not deleted); each records the reversal in its doc comment. RED evidence, at d44cc47 with the tests applied and no production change: cargo nextest run -p mds-cli --test dir_build --test cli_commands \ --test cli_lint --no-fail-fast → 197 tests run: 186 passed, 11 failed cargo nextest run -p mds-cli --test cli_watch watch_dir_mode_empty_root → 1 test run: 1 passed | id | test | RED | |------|-------------------------------------------------------------|-----| | E-1a | dir_build_empty_dir_exits_one | FAIL — exit Some(0), want Some(1) | | E-1b | dir_build_empty_dir_quiet_still_emits_diagnostic | FAIL — exit Some(0), stderr empty | | E-1c | dir_build_genuinely_empty_exits_one_without_excluded_diagnostic | FAIL — exit Some(0), want Some(1) | | E-1d | cli_build_directory_empty_exits_one_missing_root_exits_two | FAIL — exit Some(0) on the empty half | | E-2a | dir_check_empty_dir_exits_one | FAIL — exit Some(0), want Some(1) | | E-2b | dir_check_empty_dir_quiet_still_emits_diagnostic | FAIL — exit Some(0), stderr empty | | E-3a | dir_fmt_empty_dir_exits_one | FAIL — exit Some(0), want Some(1) | | E-3b | dir_fmt_empty_dir_quiet_still_emits_diagnostic | FAIL — exit Some(0), stderr empty | | E-3c | dir_fmt_empty_dir_check_flag_exits_one | FAIL — exit Some(0), want Some(1) | | E-4a | lint_directory_empty_exits_two_prints_no_summary | FAIL — exit Some(0), want Some(2) | | E-4c | lint_directory_empty_format_json_exits_two_no_envelope | FAIL — exit Some(0), want Some(2) | | E-5 | dir_check_missing_root_exits_two | PASS — pin of unchanged behaviour | | W-E1 | watch_dir_mode_empty_root_starts_and_picks_up_new_file | PASS — pin that watch is unchanged | Every failure is the exit-code assertion, i.e. the behaviour under change; the message needles are asserted in the same tests and are reached once the exit code is right. The two pins are green before and after by construction. cargo fmt --all: clean. cargo clippy -p mds-cli --all-targets -- -D warnings: clean.
…thing to do is an error, like all-excluded (#204) `mds build|check|fmt|lint <dir>` on a tree with zero .mds files exited 0 and, under --quiet, printed nothing at all: a silent CI green pass on a mistyped or not-yet-populated directory. The sibling "all candidates under default-excluded directories" case already errored and already bypassed --quiet; the empty case now does the same thing in the same shape. no .mds files found in <dir>; nothing was built exit 1 build.rs no .mds files found in <dir>; nothing was checked exit 1 main.rs no .mds files found in <dir>; nothing was formatted exit 1 fmt.rs no .mds files found in <dir>; nothing was linted exit 2 lint.rs Exit 2 for lint is its usage-error code, matching lint's own all-excluded arm; build/check/fmt use 1. Each arm is an inline `eprintln!` + `process::exit` mirroring the all-excluded arm directly above it: no new flag, no new `MdsError` variant, no `exit_code` arm, no shared helper — "nothing to do" is not an error class the error enum should grow. `mds watch <dir>` is UNCHANGED and deliberately so: it still starts and arms on an empty root, because a file created later is a valid flow there. `watch_dir_mode_empty_root_starts_and_picks_up_new_file` pins that. fmt's arm sits before the `read_only = check || diff` split, so --check and --diff behave identically on an empty tree. The path is interpolated with the existing `safe_path` sanitizer, so tests/print_discipline.rs needed zero registry edits (11/11 green). Also documents the contract in code: the `run_build_directory` rustdoc gains a "Nothing to build is an error (#204)" paragraph; lint.rs's AD-216-9 comment now covers both early exits. GREEN: cargo nextest run -p mds-cli → 875/875 passed, 0 skipped (867 before + 8 new tests; 5 more were rewritten in place). All all-excluded tests and the mixed-tree positive controls (dir_build_mixed_excluded_and_normal_processes_normal, dir_fmt_skips_node_modules) still pass. cargo fmt --all: clean. cargo clippy --workspace --all-targets -- -D warnings: clean. cargo clippy -p mds-cli --all-targets --features startup-race-probe -- -D warnings: clean.
…7.2, SECURITY.md, RELEASING.md); empty-directory exit contract (spec §7.2/§7.4/§7.5/§7.9, README); CHANGELOG (#227, #225, #226, #204) Documents the four behaviour changes this branch landed, at the sites that carry the user-facing contract: - spec §7.2 "Output writing" — temp-file-then-rename for mds build / mds watch compiled outputs and .map sidecars and for mds fmt / mds lint --fix source rewrites; the symlink refusal; the fsync split (sources fsync, derived artifacts rename only); the not-preserved list (hard links, ACLs, xattrs, owner/group) with permission bits preserved on Unix. Same contract restated in SECURITY.md (filesystem boundary) and RELEASING.md (ops note, replacing the older two-line version). crates/mds-cli/tests/write_funnel.rs is named as the enforcer at every site; the claim is scoped to the enumerated write paths, not to every file MDS writes. - spec §7.2/§7.3/§7.4/§7.5/§7.9 and README — the empty-directory exit contract: build/check/fmt exit 1, lint exits 2, all four emit the diagnostic even under --quiet; mds watch <dir> is unchanged and still starts on an empty tree. - CHANGELOG [Unreleased] — an inline BREAKING (CLI) entry under Changed for the exit-code change with the migration note, a Fixed entry for the atomic build outputs, and an Internal entry for the documented rename contract. - .devflow/features/mds-{fmt,lint}/KNOWLEDGE.md — atomic_write_file now takes a Durability parameter and is shared with build/watch; the write_funnel guard is recorded as a gotcha; the stale output.rs:432 line reference is corrected. Also fixes three pre-existing rustdoc errors in mds-cli so the mds-cli rustdoc gate passes: two unresolved intra-doc links (SanitizedReport::labels / ::source_code, now plain backticked text) and an unclosed HTML tag <output-file> in a clap doc comment (now backticked; --help renders the backticks). Docs and doc comments only — no production logic touched.
…the exit-0 reversal in cli_commands.rs (#204) No other changes — the rest of the diff (build.rs/fmt.rs/lint.rs/main.rs eprintln blocks, output.rs atomic_write_file/Durability, new test files) was already clean of unused imports, dead code, and redundant comments, so no optional clarity edits were made.
…cates in place and both exists()/write follow a symlink at the target (#227)
…e not accepted by the atomic writer (#227)
This was referenced Sep 14, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three changes to
mds-cli, all at the filesystem boundary:mds build/mds watchcompiled artifact and.mapsidecar now goes throughcrate::output::atomic_write_file(temp file in the target directory →rename), the same primitivemds fmt/mds lint --fixalready used. The primitive itself was built for existing files only; it now creates missing targets, refuses symlink targets (live or dangling), and takes the Unix mode from a singlelstat— which removes the spuriouscannot get metadata for {path}warning that fired on every first build (atomic_write_file: metadata() failure silently yields mode 0600 via .ok() #225/fix: atomic_write_file: warn on metadata failure instead of silently using 0600 #240). A newcrates/mds-cli/tests/write_funnel.rsguard makes "every write is funnelled" a machine-checked invariant.mds build|check|fmt|lint <dir>on a tree with zero.mdsfiles exited 0, and printed nothing at all under--quiet— a silent CI green pass on a mistyped or not-yet-populated directory. It now exits 1/1/1/2 with a diagnostic that bypasses--quiet, matching the sibling all-excluded arm.mds watch <dir>is unchanged.atomic_write_filegives the target a new inode, so it preserves permission bits but not hard links, ACLs, xattrs or owner/group. Stated in the rustdoc, spec §7.2,SECURITY.mdandRELEASING.md.security/snyk (dean0x)is SCA-only for this PR — the Snyk MCP server failed to connect locally (ENOENT onsnyk-macos-arm64), so nosnyk_code_scanran oncrates/mds-cli/src/output.rs; Rust is not scanned by Snyk Code here in any case.What was wrong
mds buildandmds watchwrote artifacts withstd::fs::write, which isO_TRUNC+ write. A failed or interrupted build left a truncated artifact where a valid one had been, and a symlink at the output path was written through.atomic_write_filehad four first-build blockers: it opened withNativeFs::check_symlink(path), which hard-failsfile not foundon a nonexistent target; it does notcreate_dir_all;tempfile::Builder's default mode is 0600, so every fresh artifact would have been owner-only; and its secondstd::fs::metadatacall printedcannot get metadata for {path}: {e}on anyErr, includingNotFound— i.e. on every first build — then silently continued with no mode (fix: atomic_write_file: warn on metadata failure instead of silently using 0600 #240, atomic_write_file: metadata() failure silently yields mode 0600 via .ok() #225).build.rswas a separate raw writer, so even reroutingwrite_outputwould have left it bypassing the funnel. Three.mapsidecar writes (stdin, single-file, directory mode) were raw as well: five raw sites in total.--quietnothing was printed either, while the adjacent "all candidates under default-excluded directories" case already errored and already bypassed--quiet.Design
Existence is decided by one
lstat(Path::symlink_metadata), notexists()ormetadata(). Both of the latter follow symlinks, so a dangling link reads as "absent" and the create path would then follow it on rename;lstatnever follows and yields the mode in the same call, so no second stat and no warning site remains.No parent-directory symlink check.
NativeFs::check_symlinkresolves directory-level links by documented rule, and macOS/tmp→/private/tmpwould break under a strict check. Only the final component is refused.Builder::permissions(0o666)on the new-file branch only, so the kernel applies the umask and a fresh artifact matches whatstd::fs::writeproduced (0644 under 022). The existing-file branch keeps takingmode & 0o7777from the lstat result.create_dir_allstays out of the primitive — callers own directory creation; thebuild.rscall sites already had it.D6 — the durability gate was BREACHED, so contingency C-1 was applied.
sync_all()isF_FULLFSYNCon macOS (~7 ms/file).sync_allwas to stay unconditional behind a measured +20 % budget; it was measured (medians of 3, same session, back to back, onlybuild.rsdiffering):watch_dir_mode_idle_500_files_no_recompile(solo)cli_watchsuite (wall)dir_buildsuiteThe
cli_watchsuite grew +91 % wall against the +20 % budget, and inside the parallel suite the 500-file test reached 8.418 s against the harness's 10 sREADY_TIMEOUT— one slower runner from a hard failure. C-1:pub(crate) enum Durability { Fsync, RenameOnly }is a third parameter toatomic_write_file.fmt.rs(2 sites) andlint.rs(6 sites) passFsync— they rewrite the user's hand-authored.mdssource in place, so bytes lost to a power failure are gone for good. The six build/watch sites passRenameOnly— compiled artifacts are derived and a rebuild reproduces them. Atomicity is unconditional either way: it comes from the rename, not the fsync.Empty-tree exits mirror the all-excluded arms exactly: build/check/fmt exit 1, lint exits 2 (its usage-error code, matching lint's own all-excluded arm). Each is an inline
eprintln!+process::exitdirectly beside the arm it mirrors.No helper, no
MdsErrorvariant, noexit_codearm, no--allow-emptyflag. Four independent sites with four different message tails; a shared print helper is exactly the kind of indirection that rots print discipline. "Nothing to do" is not an error class the error enum should grow, and with zero users an opt-out flag can be added on demand rather than shipped speculatively.crates/mds-cli/tests/write_funnel.rsis the single enforcement point. It countsfs::write(/File::create(acrosscrates/mds-cli/src/**after masking comments and string literals and stripping#[cfg(test)] modblocks, against an exact-maxallow list with exactly two entries:mds init(creates only, after an explicit exists-check) and the test-only watch readiness marker (already temp + rename). A dead entry fails the scan too.Behaviour changes
cannot write {path}: refusing to replace a symlink); main wrote through it. T-B4, T-S1, T-S2, T-D3, T-U4/T-U5.std::fs::writesucceeded there (it only needs write permission on the file); the temp-file create does not. T-B5, T-D2, T-U6.std::fs::writerefused. T-B3 / T-U3 territory.mds build|check|fmt|lint <dir>with no.mdsfiles exits 1/1/1/2 withno .mds files found in <dir>; nothing was built|checked|formatted|linted, emitted even under--quiet. E-1a..E-4c.mds watch <dir>is unchanged and still arms on an empty tree — W-E1.cannot get metadata for {path}message is gone, and a non-NotFoundstat error is now a hard error (cannot stat {path}: {e}) rather than a printed notice plus a silent guess (atomic_write_file: metadata() failure silently yields mode 0600 via .ok() #225). T-B1, T-U8.tempfile's 0600. T-U2, T-B2.mds build --helpnow renders`<output-file>`in backticks — a side effect of fixing a pre-existing rustdoc error (unclosed HTML tag) in the clap doc comment. No test pinned that line.-o /dev/null,/dev/stdout) is no longer accepted — exit 1,cannot create temp file for /dev/null: Operation not permitted; write to stdout (omit-o) or usemds checkinstead (documented in CHANGELOG + spec §7.2).Write-failure messages also changed shape:
cannot create temp file for {path}: {io error} at path "{tmp}"where it wascannot write {path}: {io error}, and directory mode no longer re-prefixes the path (the primitive's message already names it).Evidence
ee8c3f0# Contractrustdocd44cc47write_funnel.rs+ Durability split (C-1)f26b820588a8e392322e698614f0cli_commands.rsad026f2mds initraw-write justification —--forcetruncates in place and bothexists()/write follow a symlink at the target683bbb6/dev/null, FIFOs) are not accepted by the atomic writerThe last three are review-pass commits: alignment → polish, self-review → justification fix, and a documented consequence surfaced by review.
Per-commit RED → GREEN. Commit 1: 8 unit tests written first and run against the unmodified primitive — 4 passed / 4 RED (T-U1 create, T-U2 fresh-file mode, T-U5 dangling symlink, T-U8 unreadable parent), all four failing with
file not foundfrom the existence probe; T-U3/U4/U6/U7 pass before and after. Commit 2: 121 tests run, 114 passed / 7 RED — T-F1 (raw write sites outside the atomic funnel (5 found), all inbuild.rs), T-B4, T-B5, T-S1, T-S2, T-D2, T-D3; T-B1/B2/B3/B6/B7 and T-D1 are pins that must stay green (T-B1 is the sentinel against a naive reroute re-adding the #225 warning). Commit 3: 11 test fns RED, every failure on the exit-code assertion, with E-5 (dir_check_missing_root_exits_two) and W-E1 (watch_dir_mode_empty_root_starts_and_picks_up_new_file) green pins. Commit 4: GREEN, 875/875. Every rejection test carries a positive control in the same test; everychmodis restored before the assertions run; no#[ignore], no checked-in fixtures.Mutation testing — atomic-write path (M1–M14):
write_outputback to raw.mapback to rawpermissions(0o666)set_permissionsrestorefmt_single_file_preserves_mode_0644,lint_fix_preserves_mode_0644metadata()probe instead of lstatis_symlinkrefusalcreate_dir_allbuild_out_dir_creates_directoryunaffected:--out-dirpath)NotFound→Err-otestsErr→Nonesync_allcannot get metadataprintmain.rsentrypermissions(0o666)unconditionalmode & 0o7777afterwards, so there is no observable effectMutation testing — empty-directory exits (N1–N9): all caught. Four independent
process::exitsites, so a mutation at one site is caught by exactly that command's E-test trio (plain /--quiet/ variant); exit-code-only and message-only mutations are caught separately by theassert_eq!onstatus.code()and thecontainsneedles.Flake screen.
cli_watch×3: 79/79 each time, no recurrence of theLEAKmarker seen while the unconditional-fsync variant was in the tree; the 500-file idle test at 0.955 / 0.967 / 0.971 s.Gates.
cargo nextest run -p mds-cli875/875 (baseline 843);mds-core1445;cargo test --workspacegreen; doctests 53;cargo +1.88 checkok;RUSTDOCFLAGS="-D warnings" cargo doc --no-depsclean for bothmds-coreandmds-cli(the 3 pre-existingmds-clirustdoc errors are fixed here);cargo fmt --all --checkandcargo clippy --workspace --all-targets -- -D warnings(plus thestartup-race-probefeature) exit 0;verify-no-control-bytes.mjsandverify-versions.mjspass;npm run test:gates212 / 0 fail;print_discipline11/11 with ZERO registry edits (safe_inlineandsafe_pathare already registered sanitizers); release-surface path diff empty.Review passes.
mds initallow-list justification claimed "only ever creates a file" but--forcetruncates in place andexists()/writefollow a symlink at the target — text corrected inmain.rs+write_funnel.rs, behaviour unchanged.Known limitations / out of scope
O_NOFOLLOWon the write fd is still deferred. The refusal is an lstat check plus the existingcheck_symlinkTOCTOU re-check, not an open-time guarantee.miette::miette!report which does not downcast toMdsError. Arguably wrong, but unchanged by this PR and left for a follow-up..mds-tmp-*.tmpresidue can survive a SIGKILL between create and rename. Walker and watch filters ignore.tmp, so it is inert; this is the same exposuremds fmt/mds lint --fixalready have.read_direrror, which is pre-existing behaviour and not touched here.--out-dirpointing at a symlinked directory stays allowed (directory-level links are resolved by rule) — spec §4.6 Imports "Rules:" omits symlink rejection — spec/implementation drift on a security control #327.--allow-emptyescape hatch; add on demand.load_configstill runs before the walk, so a malformed config still errors before the empty-tree diagnostic.mds initfollows a symlink at the target path and--forcetruncates in place (pre-existing; deliberately left raw and allow-listed; follow-up issue to be filed post-merge)..mdsfiles are partials (_foo.mds) still exits 0 with nothing built — the walker collects partials so the empty-tree arm is not reached; same "silent green" shape as build: empty directory build exits 0 — silent success hazard in CI #204, follow-up issue post-merge.MdsError), whereas spec §7.9 maps I/O errors to 2 — unchanged from main's write-failure code; needs anMdsErrorvariant, out of this PR.MoveFileExWwith replace) can fail with a sharing violation when another process holds the output open, where the old truncating write succeeded — pre-existing forfmt/lint, now also reachesbuild/watch; not testable in this CI matrix.write_funnel.rskeys its allow-list on file basename;crates/mds-cli/srcis flat today so this is latent.set_permissionswith& 0o7777;fchmodvia the open handle and& 0o777would be strictly tighter (pre-existing, untouched).RenameOnlyartifacts a power loss can still leave an unflushed new file (the rustdoc says so) — the rename guarantees atomicity, fsync guarantees durability, and only fmt/lint get the latter.Changes
crates/mds-cli/src/output.rs: lstat-based existence, symlink refusal, new-file mode,Durabilityenum,# Contract (#226)rustdoc, 9 unit tests.crates/mds-cli/src/build.rs:write_output, the directory-mode compiled writer and all three.mapsidecar writers now callatomic_write_file(..., Durability::RenameOnly); the four empty-tree exits live inbuild.rs/main.rs/fmt.rs/lint.rs.crates/mds-cli/src/{fmt,lint}.rspassDurability::Fsyncat their 8 existing sites.crates/mds-cli/src/main.rs(mds init) andcrates/mds-cli/src/watch.rs(readiness marker).crates/mds-cli/tests/write_funnel.rs(scan, planted-write self-check, anti-rot).crates/mds-cli/tests/{cli_build,cli_source_map,dir_build,cli_commands,cli_lint,cli_watch}.rs.spec.md§7.2 (output writing + dir-mode exit), §7.3, §7.4, §7.5, §7.9;README.md×4 (dir mode, watch, fmt, lint);CHANGELOG.md(Changed — BREAKING (CLI); Fixed; Internal);SECURITY.md(replace-by-rename bullet);RELEASING.md(ops note);.devflow/features/mds-{fmt,lint}/KNOWLEDGE.md.output.rsand the unclosed<output-file>tag inmain.rs, so themds-clirustdoc gate is clean for the first time.Related Issues
Closes #227
Closes #204
Refs #226 (closed post-merge with a disposition comment)
Refs #225 #240 #239 #196