Skip to content

Publish the remaining writes by rename instead of truncating (#841) - #843

Merged
sehkone merged 29 commits into
mainfrom
AcoPiper/issue-841
Aug 15, 2026
Merged

Publish the remaining writes by rename instead of truncating (#841)#843
sehkone merged 29 commits into
mainfrom
AcoPiper/issue-841

Conversation

@AcoPiper

@AcoPiper AcoPiper commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Closes #841

Summary

Every production writer in the crate that published its file by truncating the destination now stages a temporary in the same directory and renames it into place, so a reader — another bootroot invocation, bootroot-agent, a container mounting the file, or the operator — sees either the whole previous file or the whole new one, never a torn one. All of them, and the writers #805 already staged, go through one publish routine: fs_util::publish_staged_blocking — the exception being the override credential writers, which keep a staged publish of their own for the reasons given below.

The four sites the issue enumerated:

  • StateFile::save (src/state.rs) goes through fs_util::atomic_write_blocking and stays synchronous, so its synchronous callers are unchanged. It takes the directory flush: this is the file bootroot reads back to resume, so a torn write is not a stale record but no record at all. A doc comment records that bootler's ten-minute stagger between its two rotation units is no longer load-bearing for this file — concurrent writers now see one version or the other. Nothing in bootler is touched; that is its own repository's follow-up. Async callers use the StateFile::save_async entry point beside it (below).
  • cert_group::write_cert_file now publishes exactly as write_key_file beside it, with the mode and the --cert-group policy's group ownership landing while the file is still at its temporary path. The asymmetry where the key staged and the certificate truncated is gone. It declines the directory flush, in the terms the key already used: a certificate lost to a crash is reissued at the next renewal.
  • write_init_summary_json and write_root_token_file (src/commands/init/steps/orchestrator.rs) keep their pre-write tightening of an existing destination — that guards the older credentials already sitting at the path, which a fresh inode renamed over them does not — and gain staging and a rename around it. Both take the directory flush; they are written once during init and read by an operator afterwards.

The rest of the crate

The issue makes "no production write site in the crate truncates a destination in place" an acceptance criterion, and the enumerated four do not satisfy it on their own. Every remaining production writer is converted here too. They fall into three groups.

  • Configuration with a live reader. agent.toml (service update's rerender, service remove --strip-config, and bootroot-remote's apply), .env, ca.json and its OpenBao Agent template, openbao.hcl, the HTTP-01 responder config and template, the two OpenBao Agent agent.hcl files, and the four generated compose overrides. Each had a process on the other side of it — docker compose interpolating .env on every invocation, step-ca parsing ca.json at boot, a sidecar re-rendering a template on a fixed interval, bootroot-agent re-reading agent.toml on every ACME retry. The agent.toml writers are the sharpest case: service add was moved off a truncating write by bootroot-agent burns renewal retries when reloaded agent.toml temporarily loses profile #613 precisely because a reload landing in the gap reports "profile not found in reloaded config", and the three writers that edit the same file afterwards had kept it.
  • Credentials. The four OpenBao Agent role_id/secret_id files, the service secret_id/role_id inside the secrets tree, the step-ca CA password, the OpenBao recovery-key output, and the remote bootstrap artifact. These were write followed by set_key_permissions, so besides the torn read they left the file at its final path under the umask's mode for the length of a chmod. Staging applies the mode to the temporary, so it holds from the moment the file appears. The issue's sibling holds save_unseal_keys and eab::write_key_file back for exactly this permission window; those two are untouched here as it directs, but a site being re-plumbed for the torn-read fix anyway does not get to keep the window.
  • init's rollback restore (steps::rollback_file), which puts a snapshotted file back on the failure path while the containers init started may still be reading it.

grep -rn 'fs::write\|create(true)' src/, filtered to non-test code, now returns exactly four lines: eab::write_key_file and save_unseal_keys, held for the sibling issue, and the two reinit preflight probes, which create a uniquely named marker and delete it rather than publishing a file.

One general-purpose publisher, four spellings

cert_group had its own staging-and-rename, predating fs_util's and older than this issue: a second copy of the staging create, the file flush, the chmod, the chown, a name-allocation retry loop and the rename. Both are now fs_util::publish_staged_blocking, with the two axes the callers actually differ on as arguments — where the new inode's ownership comes from (StagedOwner) and whether the directory entry is flushed (StagedDurability). Callers reach it through four wrappers rather than directly:

flushes the directory rename only
async atomic_write atomic_replace
blocking atomic_write_blocking atomic_replace_blocking
async, through a link atomic_write_through_symlink atomic_replace_through_symlink
blocking, through a link atomic_write_through_symlink_blocking atomic_replace_through_symlink_blocking

cert_group is the one caller that goes in directly, with StagedOwner::PolicyGroup.

The override credential writers — create_owned_credential_noclobber, write_owned_file_replace and atomic_rewrite_owned_no_symlink, for a role_id, secret_id or eab.json relocated into an operator-provisioned, agent-owned directory — keep a staged publish of their own. They already staged before this branch and reach the same two guarantees, but on ownership and clobbering they need what the shared publisher deliberately does not offer: the uid/gid comes from the parent directory or is read back through symlink_metadata, and a name already present is refused rather than replaced. Neither policy generalises to the files above, so folding them together would make one of the two callers wrong. The rustdoc on publish_staged_blocking scopes its claim accordingly.

Each site records which of the two it picked and why, because the wrong inference surfaces as a daemon that can no longer read a file it could read before, or as a disk round trip on every write in a loop that did not need one. The split follows the issue's own rule: a file the program reads back to resume, or that holds a credential the stack logs in with — losing one of those takes an operator or another rotation to put back, not the next write — flushes; a file that is regenerated by the next renewal, the next sidecar render, or a re-run of the command that produced it does not. Every AppRole credential file is on the flushed side, role_id included: it is re-readable from OpenBao, but only on the next rotate run, and until then the agent or sidecar it belongs to cannot log in. Both backfills that write one run only when the file is missing, so that flush is not on any repeated path.

Two properties come free from the shared routine — the staged file is created 0600 and reaches its final mode only at the temporary name, so the guarantee #593 asked of the key file now holds for every caller, and the temporary's name is the primitive's own, so a destination whose file name is not valid UTF-8 (which a Unix path may be, and which the writes this replaced never looked at) needs nothing special of it.

Two more pieces the enumerated four required:

  • write_ca_bundle (src/fs_util.rs) was still a plain fs::write. The bundle has the reader the certificate has — the agent re-reads it to rebuild its trust store while a rotation may be rewriting it — so it publishes the same way and makes the same durability decision.
  • fs_util::resolve_symlink_destination exists because the truncating write these replaced opened with O_TRUNC, which follows the final symlink, where a rename replaces it. See Symlinked destinations below.

Symlinked destinations

O_TRUNC follows a link at the final path component and delivers the bytes to its target; rename(2) replaces the name it is given. So a conversion done naively changes what an operator's link means — the link is destroyed and the file it pointed at keeps the previous contents, while the write reports success. Which answer each file takes is a decision, and every writer here states it:

  • A destination the operator arranges is written through the link, via the _through_symlink wrappers, which resolve first and publish at the target. That is the configuration bootroot renders into its own tree — .env, ca.json and its OpenBao Agent template, openbao.hcl, the HTTP-01 responder config and template, the two OpenBao Agent agent.hcl files, the compose overrides, init's rollback restore, state.json — and the output paths named on the command line: init --summary-json, init --root-token-output, rotate openbao-recovery --output, and bootroot-remote bootstrap's agent.toml destination. A dangling link is followed through its own text (canonicalize has nothing to resolve against), matching what O_CREAT through the link used to do. A chain that loops back on itself resolves to nothing that can be published without destroying a link, so resolution fails with the ELOOP the truncating write reported.
  • Credentials at a path bootroot chose publish at that path. Inside the secrets tree a link is a redirection vector rather than an operator convenience, and the reader reads the path bootroot handed it — which the rename leaves holding the current secret — so following one buys nothing and costs the guarantee. The override credential paths, whose directory an unprivileged user owns, go further and refuse a link outright (atomic_rewrite_owned_no_symlink, unchanged).
  • The control node's agent.toml and the issued cert, key and bundle publish at the path too, because a writer that already renames over that name predates this branch: service::local_config since bootroot-agent burns renewal retries when reloaded agent.toml temporarily loses profile #613, write_key_file since Allow cert/key delivery to non-root container clients via configurable group ownership #593. A link an operator puts at one of those paths does not survive the command that creates the file, so resolving it in the writers that only edit it would leave two writers of one file disagreeing rather than preserve anything. Making the certificate match the key here is the same asymmetry-removal the issue asked for. The agent.toml a bootstrap target carries is the other way round and sits in the first group: bootroot-remote bootstrap is the writer that creates the file there, so a link at its destination is one the operator arranged and one the truncating write it replaces followed.

The two init preflights accept a symlinked destination on purpose: they resolve the link and judge the target's mode. The destination is resolved before it is tightened and staged, and the preflight probes the directory the staging will actually use, so a link into a not-yet-existing directory is caught before OpenBao is wiped rather than after; a cyclic destination is refused before the wipe rather than failing after it. Resolution is explicitly not a security check, and says so — a caller whose destination an untrusted user can plant still has to refuse the symlink.

Keeping the flushes off the runtime

Publishing state.json now costs three disk round trips where the truncating write cost one, and every async command path was calling the synchronous writer directly. StateFile gains an async entry point beside the synchronous one — save_async, sharing a blocking core, serializing the JSON on the async side so only owned data crosses into spawn_blocking — and the async production callers move to it. Three helpers whose only production caller is async (write_state_file, reinit::write_minimal_state, service remove's finalize_removal) are async themselves for the same reason. infra install and service update run outside any runtime and keep the synchronous entry point, as do the tests, which must not need a runtime to write a state file. This is the pattern #839 established for the rotation-state writers.

Modes and ownership

File modes are unchanged — CERT_FILE_MODE stays 0644, the orchestrator pair stays 0600 — but each is now applied before the file is published rather than set after the bytes landed. A staged temporary inherits no mode from the file it replaces, so a writer that used to truncate in place has to state one. fs_util::preserved_mode is the shared answer: read the mode off the destination where there is one, so a file an operator narrowed by hand (or a restrictive umask created narrow) keeps that mode across every later write exactly as the in-place write left it, and fall back to a stated default only on a create. The default is 0644 for state.json, .env, ca.json, openbao.hcl and the compose overrides — what the umask produced — and 0600 for everything inside the secrets tree, where the policy's constant is the answer and a stale wider mode must not outlive the file it was attached to. A create on a host with a non-default umask is the only case that can observe a difference; CHANGELOG.md names it.

The staged file is fsynced after its ownership and mode are applied, not at the bytes. An fsync persists the inode as it stands, so flushing first would leave the chown and the chmod in memory only: a crash could recover a durably named, fully written file wearing the temporary's own 0600 and the writer's primary group instead of the mode and the policy gid it was published with. The directory flush does not cover that — it makes the name durable and says nothing about the inode behind it.

Ownership is the one place the callers answer differently, deliberately. StagedOwner::Destination carries the destination's uid/gid across the rename: those files have no ownership policy to restate, and a re-owned one the daemon cannot read is an outage. StagedOwner::PolicyGroup takes ownership from the --cert-group policy instead, as write_key_file has since #593: the gid these files need is the one the policy names, re-reading it off the destination would let a stale owner outlive the policy that replaced it, all three land world- or group-readable by that policy so no consumer loses access, and the rename needs only the directory's permission — a writer that could not replace the destination before is not made to fail on a chown it has no privilege for. A unit test pins both answers against the same seeded destination.

Documentation

docs/en/cli.md and docs/ko/cli.md gain a How bootroot writes files section: what stage-then-rename guarantees a reader, that the final mode holds from the moment the file appears, that a rename installs a new inode (so a single-file bind mount needs the container restarted where a directory mount does not), which files take the directory flush and which do not, and which paths keep being written through a symlink you put there and which replace it. The per-command prose that described the two init outputs in terms of the create-mode-then-chmod write they no longer perform, and named only the key as atomic where the certificate and the bundle now are, is corrected. The section is scoped to the writers converted here: it names the two this change does not cover — the OpenBao unseal-keys file and the service eab.json, held for the sibling issue — and says the guarantees below it do not apply to them.

CHANGELOG.md names the same two exclusions, so the release notes and the manual promise the same set.

FastPollState::save's create_dir_all chain stays out of scope, as the issue directs.

Test plan

  • cargo clippy --all-targets -- -D warnings is clean
  • cargo fmt -- --config group_imports=StdExternalCrate leaves no diff
  • RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --document-private-items is clean, and ./scripts/check-docs.sh builds the manual with --strict
  • scripts/preflight/ci/check.sh passes end to end (fmt, clippy, rustdoc, Python, Biome, markdownlint, docs, audit)
  • cargo test passes (1041 bin + 424 lib tests green locally)
  • grep -rn 'fs::write\|create(true)' src/, filtered to non-test code, returns only eab::write_key_file, save_unseal_keys, and the two reinit preflight probes
  • StateFile::save publishes a new inode over an existing state.json, leaves no temporary behind, keeps an existing mode and lands at 0644 on a create
  • StateFile::save is still synchronous, and save_async publishes the same file — same inode replacement, same preserved mode, same symlink resolution — from a current-thread runtime
  • Every production save reached from an async path goes through save_async; the two synchronous command paths (infra install, service update) are unchanged
  • write_cert_file, write_key_file and write_ca_bundle all publish by rename, with the policy's mode and gid applied before the rename
  • StagedOwner::Destination carries a seeded gid across the rename and StagedOwner::PolicyGroup does not, so neither ownership answer can drift into the other
  • atomic_replace installs a new inode, applies the requested mode, and leaves no staged sibling — the no-flush spelling is still a staged publish
  • preserved_mode reads an existing destination's mode and falls back to the caller's default only on a create
  • write_dotenv and update_dotenv_key both publish a new inode, leave no temporary, and keep an operator-narrowed .env at 0600
  • write_ca_bundle still creates a missing parent directory before staging
  • write_init_summary_json and write_root_token_file publish by rename at 0600, and still tighten a pre-existing world-readable destination before writing
  • Both init outputs write through a symlinked destination to the link's target, including a dangling link, and leave the link in place
  • The _through_symlink wrappers deliver to a link's target and leave the link standing, create a dangling link's target, and refuse a cycle without touching the links in it; the bare wrappers publish at the name over the same seeded link
  • write_dotenv and update_dotenv_key update the target of a symlinked .env and leave the link in place
  • rerender_local_managed_profile publishes agent.toml at its own name over a symlinked destination, matching service add
  • bootroot-remote bootstrap writes agent.toml through a symlinked destination and leaves the operator's link in place, the opposite answer from the control node's writers of that file name
  • validate_root_token_output_path and validate_summary_json_output_path probe the directory the staged write will use, not the one holding the link
  • resolve_symlink_destination passes a regular file and a not-yet-existing path through unchanged, follows live and dangling links and chains, and fails on a cycle without touching the links in it
  • Both init writers and both preflights refuse a cyclic destination, and leave the operator's links in place
  • A certificate and a key whose file names are not valid UTF-8 publish and leave no staged file behind (Linux only — APFS rejects such a name with EILSEQ, verified in a rust:1-slim container)
  • Every converted site carries a comment stating whether it flushes the containing directory and why
  • Every AppRole role_id and secret_id writer flushes the containing directory, so the durability contract the manuals state holds for the whole credential pair and not only half of it
  • StateFile::save's comment states that concurrent writers now see one version or the other, so bootler's stagger is no longer load-bearing
  • The fake docker executables the converted init and rotate tests write are written by a child process, so no descriptor on one is ever in this process's table for a fork to duplicate and no later spawn — production's included — can be refused with ETXTBSY; the writer delivers the exact bytes at the exact name, including one holding a quote, a space or a byte that is not UTF-8 (the last verified in a rust:1-slim container, since APFS rejects it with EILSEQ)
  • scripts/preflight/ci/e2e-matrix.sh locally, through the Round 3 tree: both lifecycles, the rotation/recovery matrix, reinit recovery, step-ca SANs and OpenBao TLS no-delta all pass. It stops at openbao-tls-reown, which needs passwordless sudo this macOS host does not have. Every commit since is covered by the same matrix on CI, openbao-tls-reown included, on the head commit below.
  • Full CI green on 39284d4 (previously green on 8f971c9) — all 18 checks, including every Docker E2E matrix job (local-hosts, local-no-hosts, remote-hosts, remote-no-hosts, rotation, reinit-recovery, stepca-san, two-instance, openbao-tls-no-delta, openbao-tls-reown)

Four production writers still truncated their destination and wrote
over it, so a crash or a concurrent reader could find a half-written
file at a name that is supposed to hold a complete one.

state.json is the worst of them. It is what bootroot reads back to
know what it already did, so a torn write is not a stale record but
no record at all: the next run fails to parse it and falls back to
nothing. bootler staggers its two rotation units ten minutes apart
because of this, which is a workaround in another repository standing
in for a guarantee this function should provide itself. It now goes
through fs_util::atomic_write_blocking and stays synchronous, so its
callers are unchanged.

The certificate writer is now the key writer beside it: both share
one stage-then-rename core, so the mode and the policy's group
ownership land while the file is still at its temporary path. The
asymmetry that had the key staging and the certificate truncating is
gone, and so is the umask's say in the published cert mode.

The two init outputs, --summary-json and --root-token-output, already
flushed their bytes but published them by truncating the destination,
leaving the directory entry unflushed on a first write. They now
stage and rename too, keeping the pre-write tightening of an existing
destination: that guards the older credentials sitting at the path,
which a fresh inode renamed over them does not.

The durability question is decided per file and recorded at each
site. state.json and the two init outputs are read back — to resume,
or by the operator — so they take the directory flush. A certificate
is reissued at the next renewal, which is the reasoning the key
already records, so it declines the flush and says so in the same
terms.

Closes #841
The four writers the issue enumerated now stage and rename, but
write_ca_bundle was still a plain fs::write over its destination, so
the criterion that no production write site truncates in place did
not hold. The bundle has the reader the certificate has:
bootroot-agent re-reads it to rebuild its trust store while a
rotation may be rewriting it, and fast-poll rewrites it on every
apply. It now goes through the same publish_staged core as the key
and the certificate, so the mode and the policy's group ownership
land while the file is still at its temporary path, and it declines
the directory flush for the reason they do — a bundle lost to a crash
is rewritten by the next rotation.

The manual still described the two init outputs in terms of the
create-mode-then-chmod write they no longer perform, and named only
the key as atomic where the certificate and the bundle now are. Both
language versions are corrected; the durability decision is recorded
there as well, since it is the operator who is told the file survives
a power loss.

The changelog entry had absorbed the opening sentence of the
init-stdin entry below it, fusing two unrelated fixes into one
paragraph, and claimed an existing destination's owner is preserved —
which holds for the writers that go through atomic_write_blocking,
but not for the certificate, whose rename re-owns the destination to
the writer.

A world-readable destination is now covered end to end for the token
file: the tightening narrows the credential already sitting there and
the staged publish gives the new one an inode that was never wider.

Part of #841
The entry opened with "File modes are unchanged" and then explained
that the modes used to be left to the umask, which cannot both be
true. Certificates, CA bundles and the two init outputs did carry
their modes before; state.json had none of its own, so on a host
whose umask made it narrower than 0644 the next write now widens it.
That is a change an operator can observe, so it is named rather than
folded into a claim that nothing moved.

Part of #841
The truncating write these two replaced opened the destination with
O_TRUNC, which follows the final symlink, so an operator who pointed
--root-token-output or --summary-json at a link had the file
delivered to the link's target. Both preflights accept that on
purpose: they resolve the link and judge the target's mode, and
reject it only when the target is not a regular file.

Staging and renaming broke it silently. The rename replaced the link
itself, so the operator lost the link, the target kept the previous
run's credentials, and the write reported success either way. The
pre-write tightening made it worse by narrowing that stale target to
0600 on its way past.

The destination is now resolved before it is tightened and staged, so
the rename lands on the target exactly as the truncating write did.
The preflight probe follows the same resolution: it is the directory
the staging will use that has to accept a new file, and for a link
into another directory that is the target's, not the link's. Probing
the wrong one would pass the preflight and fail the write after
OpenBao has already been wiped, which is the trap the probe exists to
prevent.

Resolution is not a security check and does not pretend to be. It
follows whatever the link points at, so a caller whose destination an
untrusted user can plant still has to refuse the symlink, the way
atomic_rewrite_owned_no_symlink does for the secret_id path.

Part of #841
`resolve_symlink_destination` handed a dangling symlink back
unchanged, so the staged write renamed over the link itself: the
operator lost the link and a freshly minted root token landed in the
directory holding it rather than the one the link named.  The
truncating write this replaced followed the link on `O_CREAT` and
created the target, and reproducing that is the whole point of
resolving at all.

`canonicalize` cannot say where a dangling link points, having
nothing to resolve against, so the link text is read instead —
absolute or relative to the link's own directory — and followed to
the end of the chain.  A cycle has no end, so the caller is handed
back the path it named.

The preflight gains the same fidelity for free: it probes the
directory the resolution picks, which for a link into a directory
that does not exist yet is now that directory, created before the
destructive sequence rather than discovered after it.

Part of #841
`publish_staged` takes its ownership from the `--cert-group` policy and
nothing else, so the rename hands the certificate and the CA bundle to
whoever ran the rotation where the truncating write they replaced kept
the previous owner.  That is the right answer for these three files —
the gid they need is the one the policy names, and reading it back off
the destination would let an owner the policy already replaced outlive
it — but it is the opposite of what `fs_util::atomic_write_blocking`
does a few hundred lines away, and nothing said so.

A reader who found the two staging primitives had to infer which one
preserves ownership from their bodies, and the wrong inference is the
kind that only shows up as a daemon that can no longer read its own
config.  The comment names the decision and the reason the other
primitive decides differently.

The summary also still claimed the primitive was shared by the key and
the certificate; the bundle joined them.

Part of #841
The two behaviours the branch changed with no test of their own: the
init summary shares the token writer's staging, tightening and symlink
resolution, but was only covered through the token file beside it, and
the two reinit preflights now resolve a link before probing without
anything holding them to it.  A probe that went back to the link's own
directory would pass preflight and leave the write to fail after
OpenBao has been wiped, which is the trap the check exists to prevent.

Part of #841
@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Reviewer Round 1]

I found three compatibility problems that should be resolved before merging:

  • [P1] Preserve failure for cyclic output symlinks. resolve_symlink_destination returns the originally supplied path after a cycle (src/fs_util.rs:109), and the init writers then pass it to atomic_write_blocking, whose persist rename replaces the final path component (src/fs_util.rs:501). Thus a cycle such as a -> b, b -> a no longer fails with the ELOOP that the former O_TRUNC open produced: it silently replaces a with the root-token/summary file. Reinit's preflight also treats the cycle as a missing path, so this can happen only after the destructive operation. Make resolution fallible and reject cycles (and have both preflights surface that error) instead of returning a path that rename can overwrite. Add a writer/preflight regression test, not only the resolver unit test.

  • [P2] Do not widen an existing state file under a restrictive umask. StateFile::save now unconditionally supplies STATE_FILE_MODE = 0644 (src/state.rs:12, src/state.rs:163). Before this PR, fs::write preserved an existing mode and honoured the umask for a new state file; e.g. an operator intentionally using umask 077 gets a 0600 state file, which the next save now widens to 0644. The issue explicitly says not to change file modes. The changelog acknowledges the difference but does not make it compatible. Retain the previous effective mode (including on replacement), or obtain an explicit issue decision to change it.

  • [P2] Keep non-UTF-8 destination filenames supported for certs and bundles. The new shared cert/bundle publisher requires dest.file_name().and_then(|s| s.to_str()) (src/cert_group.rs:424), where the previous tokio::fs::write/permission path accepted any Unix OsStr filename. These are Path APIs and certificate paths can be configured with arbitrary Unix names, so this adds a needless error-path regression despite the issue's “do not change ... errors reported” constraint. Build the staging name from OsStr (or use the tempfile API directly) and add a non-UTF-8 filename test.

Minor documentation drift: src/commands/init/steps/orchestrator.rs:293 says the summary preflight only runs on reinit, but run_init also invokes it at lines 138–142.

@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Review Verdict Round 1: NOT_APPROVED]

The shared cert, key and bundle publisher built its staging name from
a &str, so a destination whose file name is not valid UTF-8 was
refused outright. A Unix file name is bytes, and the writes this
replaced never looked at them; the name is now built as an OsStr so
the same paths keep working.

Part of #841
Publishing through a staged temporary meant stating a mode, and a
stated 0644 widened a state.json that an operator had narrowed by
hand or that a restrictive umask had created narrow: the write this
replaced opened the destination in place and left its mode alone.
The mode is now read off the destination, and 0644 applies only where
there is no file to read it from.

The same write followed a symlinked path to its target, where a
rename replaces the link itself, so the destination is resolved first
as the two init outputs already do.

Part of #841
Resolving a symlinked destination handed a cycle back to the caller
unchanged, and the rename that followed replaced the operator's link
with a regular file and reported success. The truncating write these
replace answered ELOOP there. Resolution now fails instead, so both
init preflights refuse the path before OpenBao is wiped rather than
leaving the write to discover it afterwards.

Also corrects the summary writer's note on where its preflight runs:
init calls it too, and what the pre-write tightening covers is the
window between that call and the write.

Part of #841
@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Author Round 1]

All three items accepted; the third one I widened slightly. Pushed as 0c71453, 766ef2c, 9b044c0.

[P1] Preserve failure for cyclic output symlinks — Fixed

resolve_symlink_destination now returns Result<PathBuf> and fails on a cycle instead of handing back the path it was given. The comment where it used to return that path said the caller could "let the write fail or land there"; you are right that it lands, silently, and replaces the operator's link with a regular file while reporting success. ELOOP was the old answer and it is the answer again. An unreadable link mid-chain is now an error too, for the same reason: the destination is unknown, so nothing may be published at the link's own name.

Both preflights map the failure into their existing --root-token-output / --summary-json unwritable message, so a cycle is refused before the wipe rather than after it — which is strictly better than what the truncating write did, since path.exists() is false for a cycle and every check above the probe skips it.

Tests, at all three levels rather than only the resolver: resolve_symlink_destination_rejects_a_symlink_cycle (mutual pair and a self-link, asserting the links are untouched), validate_root_token_output_rejects_a_symlink_cycle and validate_summary_json_rejects_a_symlink_cycle for the preflights, and write_root_token_file_refuses_a_symlink_cycle / write_init_summary_json_refuses_a_symlink_cycle for the writers, which also assert the links survive.

[P2] Do not widen an existing state file under a restrictive umask — Fixed

StateFile::save now reads the mode off the destination and passes that to atomic_write_blocking; STATE_FILE_MODE = 0644 applies only where there is no file to read a mode from. So a state.json narrowed to 0600 — by hand or by a umask 077 that created it — keeps 0600 across every later save, as it did when the write opened the destination in place. That is the same reasoning atomic_write_blocking already applies to uid/gid, now stated at the mode as well. Covered by save_keeps_an_existing_state_files_mode.

One half I did not restore: a state.json this branch creates is 0644 whatever the umask, where fs::write would have produced 0666 & ~umask. Reproducing that needs the umask, and there is no way to read it that is worth the compatibility it buys — umask(2) is only readable by setting it, a process-global mutation no Mutex protects another thread from, and the alternative is a probe file created purely to observe what the kernel masked off. AGENTS.md also asks for the staged file to carry a mode someone decided rather than an ambient one. So the create takes the stated 0644, the mode this file has had in practice, and CHANGELOG.md now names that one case instead of the widening it used to describe.

While there: save also resolves a symlinked destination before staging, which the same argument as P1 requires — fs::write followed a symlinked state.json to its target, and a bare rename would have replaced the operator's link. save_writes_through_a_symlinked_state_file pins it.

[P3] Keep non-UTF-8 destination filenames supported — Fixed

publish_staged no longer calls to_str(), and stage_file takes the final name as an &OsStr and builds .<name>.tmp.<pid>.<attempt> by OsString::push. Nothing on the path requires UTF-8 now, and the "has no file name" error still fires for a path that genuinely has none. The key, the certificate and the CA bundle all share this code, so all three are covered.

write_cert_file_accepts_a_non_utf8_file_name writes a cert and a key with \xff in their names and asserts no staged file is left behind. It is #[cfg(target_os = "linux")]: APFS validates file names as UTF-8 and answers EILSEQ, so on macOS no such destination can exist. I ran it in a rust:1-slim container to confirm it passes rather than merely compiles, and ran the whole --lib --bins suite there as a non-root user (419 + 1034 green).

Documentation drift at orchestrator.rs:293Fixed

Correct, run_init calls validate_summary_json_output_path too. The comment's conclusion still holds but for a different reason, and it now says that one: the preflight runs on both paths, but it runs before OpenBao is touched, so what the pre-write tightening covers is a destination created or widened during the init that follows.

Documentation and changelog

CHANGELOG.md's mode paragraph is rewritten — the modes are unchanged now, with the state.json create as the single observable case — and it names the preflight's new refusal of a cyclic destination. docs/en/cli.md and docs/ko/cli.md gain that same sentence beside the existing "a symlink to a regular file stays supported" text.

cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo doc with -D warnings, markdownlint-cli2 and scripts/check-docs.sh are all clean locally; the Docker E2E matrix still needs CI, as this host cannot run it.

@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Reviewer Round 2]

The Round 1 items are resolved and are not carried forward: cyclic symlink destinations now fail before either preflight or writer can replace a link, StateFile::save retains an existing mode, the cert publisher keeps OsStr filenames, and the summary-preflight comment reflects both init paths.

  • [P1] Keep the synchronous state writer off Tokio runtime workers. StateFile::save now calls atomic_write_blocking, including the temporary-file and parent-directory sync_all operations (src/state.rs:173, src/fs_util.rs:479). It is still invoked directly from async command paths, for example rotate_infra_certs after an await (src/commands/rotate/infra_cert.rs:128) and run_init_inner (src/commands/init/steps/orchestrator.rs:1011). This blocks a Tokio worker for the two disk flushes. The issue explicitly keeps save synchronous but requires async callers to wrap it at the call site, as the rotation-state implementation does. Wrap every production async call site in spawn_blocking (keeping the synchronous callers unchanged), and add coverage for the async boundary if practical.

  • [P1] Use the required shared atomic-write primitive for the certificate. The issue specifically requires StateFile::save and cert_group::write_cert_file to route through fs_util::atomic_write / atomic_write_blocking, rather than each acquiring a separate staging implementation. write_cert_file instead goes through the new independent cert_group::publish_staged (src/cert_group.rs:412, src/cert_group.rs:565), duplicating staging, flush, permissions, ownership, retry, and rename behavior that the shared primitive already owns (src/fs_util.rs:479). It also changes replacement ownership: the new inode takes the writer uid, whereas the existing primitive preserves the destination uid/gid and the old truncate-in-place writer retained its owner. Please put this writer on the shared path, extending that primitive narrowly only if the cert-group policy has a demonstrated pre-publication requirement; otherwise this exceeds the issue constraint not to change write behavior or errors.

@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Review Verdict Round 2: NOT_APPROVED]

Publishing state.json now costs three disk round trips — the staged
write, its flush, and the flush of the directory that names it — where
the truncating write it replaced cost one.  Every async command path
was calling the synchronous writer directly, so those round trips ran
on a Tokio runtime thread, which is what the issue's own constraint
rules out: keep the writer synchronous, and wrap it at the call site
the way the rotation-state writers do.

Give StateFile an async entry point beside the synchronous one, sharing
a blocking core: the JSON is serialized on the async side so only owned
data crosses into spawn_blocking.  The async production callers move to
it, including the three helpers whose only production caller is async
and which are now async themselves.  `infra install` and `service
update` run outside any runtime and keep the synchronous entry point,
as do the tests, which must not need a runtime to write a state file.

Part of #841
The certificate, the key and the CA bundle staged and renamed through
cert_group's own implementation, which predates fs_util's and had
grown a second copy of the staging create, the file flush, the chmod,
the chown, a name-allocation retry loop and the rename.  Two copies of
a publish routine is one too many when both are load-bearing for
whether a reader can see a torn file.

Give fs_util a single staged publish and route both through it.  The
two axes the callers actually differ on become arguments: where the
new inode's ownership comes from, and whether the directory entry is
flushed.  atomic_write_blocking is now that primitive with the
destination's ownership and the flush; the cert group's writers are it
with the --cert-group policy's gid and no flush.  Each answer is
documented where it is chosen, since the wrong inference surfaces as a
daemon that cannot read a file it could read before.

Two properties come free.  The staged file is created 0600 and reaches
its final mode only at the temporary name, so the guarantee #593 asked
of the key file now holds for every caller; and the temporary's name is
the primitive's own, so a destination whose file name is not valid
UTF-8 needs nothing special of it.

Part of #841
@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Author Round 2]

Both items accepted; the second one only partly as written. Pushed as e4c7050 and f72c357.

[P1] Keep the synchronous state writer off Tokio runtime workers — Fixed

StateFile now has an async entry point beside the synchronous one, mirroring create_rotation_state_async / update_rotation_state_async: save_async serializes the JSON on the async side so only owned data crosses into spawn_blocking, and both entry points share one blocking core (StateFile::publish) so neither can drift from the other's symlink resolution or mode decision.

Every production call site reached from an async path moves to it — run_service_add_apply, run_init_inner (both writes), rotate_infra_certs, provision_infra_rotate_role. Three helpers were sync but had only async production callers, so the round trips still landed on a runtime thread one frame down; they are async now for the same reason: write_state_file / write_state_file_to (run_init_inner), reinit::write_minimal_state (run_reinit), and service remove's finalize_removal (run_service_remove). The genuinely synchronous callers are unchanged: run_infra_install and run_service_update are invoked from main::run without a runtime at all (main.rs only wraps the async commands in with_runtime), so the bind-intent helpers in infra.rs keep calling save directly. The tests keep calling save too — writing a state file must not require a runtime.

Coverage for the boundary: save_async_publishes_the_same_file_as_save runs on #[tokio::test(flavor = "current_thread")], where a direct save would park the single worker for all three round trips, and asserts the same inode replacement and no leftover temporary; save_async_keeps_an_existing_mode_and_follows_a_symlink pins that the async path inherits the shared core's decisions rather than reimplementing them.

[P1] Use the required shared atomic-write primitive for the certificate — Partially addressed

You are right that there were two staging implementations and that one of them had to go. There now is one: fs_util::publish_staged_blocking. atomic_write_blocking is a thin call to it, cert_group's stage_file — the 32-attempt name-allocation loop, the create, the flush, the chown, the chmod and the rename — is deleted, and the cert, key and bundle publish through the same routine as state.json, agent.toml, rotation-state.json and the fast-poll state. Worth noting that duplication was not introduced here: stage_key_file is on main, predating this issue, and Round 1's change had the cert join it rather than adding a third shape. Either way it is gone.

What I did not do is put the cert on atomic_write_blocking as it stands, because that primitive is two decisions bundled together and the cert differs on both:

  • The directory flush. atomic_write_blocking calls sync_parent_dir unconditionally. The issue requires the opposite for the certificate — "It does not take the flush, and should say so in the same terms" — so using it verbatim would have failed an acceptance criterion, and write_key_file_does_not_flush_the_directory / write_cert_file_does_not_flush_the_directory would both have gone red.
  • Pre-publication ownership. The --cert-group policy has exactly the demonstrated pre-publication requirement your review allows for: the gid must be on the inode before the rename, which is what Allow cert/key delivery to non-root container clients via configurable group ownership #593 asked of the key and what the cert needs for the same reason. atomic_write_blocking chowns to the destination's uid/gid instead, and has no way to express a gid the policy names.

So the primitive gained those two axes, narrowly — StagedOwner and StagedDurability, one enum each, both documented at the point of decision — rather than the cert gaining a copy of the primitive. Everything else the two callers had in common (staging create, write_all, sync_all, chmod, rename, temp cleanup on failure) is now written once. Two properties fall out for free: the staged file is created 0600 and reaches its final mode only at the temporary name, so the guarantee #593 asked of the key now holds for every caller including agent.toml; and the temporary's name is the primitive's own, so the non-UTF-8 destination case Round 1 fixed by hand needs no special handling at all.

On the ownership change specifically — pushed back, with the reasoning now recorded in code and a test pinning it:

  • The --cert-group policy is authoritative for these three files and is re-asserted on every write; reading the gid off the destination would let a gid from a retired policy outlive it, which is the failure Allow cert/key delivery to non-root container clients via configurable group ownership #593 exists to prevent. Note the key has re-owned this way since Allow cert/key delivery to non-root container clients via configurable group ownership #593 — restoring destination-ownership for the cert would recreate the key/cert asymmetry this issue set out to remove, pointing the other way.
  • Nothing loses access. All three land 0644 / 0640-by-policy, so every consumer that could read the file before still can, and rename needs permission on the directory, not the file — a writer that could not replace a foreign-owned destination before is not now made to fail on a chown it has no privilege for. Preserving the uid would introduce that failure: an unprivileged agent cannot chown a file to root, where today it can publish over one.
  • publish_staged_re_owns_under_the_policy_and_preserves_under_destination seeds one destination with a supplementary gid and publishes it both ways, asserting StagedOwner::Destination carries the gid and StagedOwner::PolicyGroup does not — so neither answer can quietly become the other.

If you would still rather the cert preserve the destination uid, say so and I will make StagedOwner::PolicyGroup carry the destination's uid alongside the policy's gid; it is a two-line change plus the test. I have left it as-is because the EPERM case above looks to me like a regression the current shape avoids.

Docs and CI

docs/en/cli.md and docs/ko/cli.md had spelled out the per-file staging create modes (0600 for the key, 0644 for the cert and bundle); with one primitive the staged file is always created 0600 and reaches its final mode before the rename, so both are corrected. cargo clippy --all-targets -- -D warnings, cargo fmt --check, RUSTDOCFLAGS="-D warnings" cargo doc, ./scripts/check-docs.sh and cargo test are green locally; the Docker E2E matrix is not runnable on this host and needs CI. The PR description is updated to match.

@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Reviewer Round 3]

The two Round 2 items are resolved and are not carried forward: every production async StateFile caller now reaches save_async, and the cert/key/bundle writers now share fs_util::publish_staged_blocking with the policy ownership and rename-only durability decisions made explicit.

  • [P1] Complete the crate-wide production coverage required by the issue. The PR description acknowledges that the acceptance criterion is still not true literally, but the remaining matches are real production truncating writes, not just test setup, documentation, probes, or the two secret-writer exemptions. For example, rerender_local_managed_profile still does std::fs::write(agent_config_path, next) at src/commands/service.rs:1121; this rewrites the agent configuration that the long-running agent can read. src/commands/dotenv.rs:60 and :152, src/commands/guardrails.rs:363 and :546, and src/commands/ca.rs:113 and :138 likewise still publish production configuration by truncating the destination. A crash or concurrent reader at any of these paths retains the torn-file failure that this issue is meant to remove. Issue Publish the remaining writes by rename instead of truncating #841 explicitly makes “No production write site in the crate truncates a destination in place” an acceptance criterion and says the grep should leave only staged writes plus the two sibling-issue secret writers. Route these writers through the shared staging primitive (with per-file durability decisions), or this PR does not meet the issue contract.

@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Review Verdict Round 3: NOT_APPROVED]

The four writers #841 enumerated left the acceptance criterion unmet:
"no production write site in the crate truncates a destination in
place" is crate-wide, and roughly twenty writers outside that list
still opened their destination with O_TRUNC.

They fall into three groups. Configuration with a live reader —
agent.toml, .env, ca.json and its template, openbao.hcl, the responder
config and template, the OpenBao Agent configs, the compose overrides
— each had a process on the other side: docker compose interpolating
.env on every invocation, step-ca parsing ca.json at boot, a sidecar
re-rendering a template on a fixed interval, bootroot-agent re-reading
agent.toml on every ACME retry. That last one is the sharpest: #613
moved service add off a truncating write because a reload landing in
the gap reports "profile not found in reloaded config", and the three
writers that edit the same file afterwards had kept it.

Credentials are the second group. Those were a write followed by
set_key_permissions, so besides the torn read they left the file at
its final path under the umask's mode for the length of a chmod.
Staging applies the mode to the temporary, so it holds from the moment
the file appears. The sibling issue holds save_unseal_keys and
eab::write_key_file back for exactly that window; both are untouched
here, but a site being re-plumbed for the torn-read fix anyway does
not get to keep it. The third group is init's rollback restore, which
puts a snapshotted file back while the containers init started may
still be reading it.

Two wrappers name the durability decision at each site: atomic_write
flushes the containing directory, atomic_replace renames and stops.
The split is the issue's own rule — a file read back to resume, or
holding a credential OpenBao will not hand out again, flushes; one
regenerated by the next renewal, the next render, or a re-run of its
command does not, because a disk round trip per write is real and
init performs dozens.

A staged temporary inherits no mode from the file it replaces, so a
writer that used to truncate in place has to state one. preserved_mode
reads it off the destination where there is one, keeping a file an
operator narrowed by hand narrow across every later write, and falls
back to a stated default only on a create.

Part of #841
`update_dotenv_key` now costs three disk round trips where the
truncating write cost one, and `init`'s database password rotation
calls it from an async fn. A Tokio worker parked on those is a worker
polling nothing else, and on a current-thread runtime it is the only
worker there is.

An async entry point beside the synchronous one moves the read, the
rewrite and both flushes onto a blocking thread, the way
`StateFile::save_async` does. The synchronous callers in `infra
install` are unchanged; it runs outside any runtime.

`Messages` gains `Clone` so the bundle can cross into the closure. Not
`Copy`: the crate passes `&Messages` through several hundred
signatures and `clippy::trivially_copy_pass_by_ref` would demand every
one of them change.

Part of #841
@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Author Round 3]

  • [P1] Complete the crate-wide production coverage required by the issue — Fixed. You are right that the criterion is crate-wide and that the named sites are real production writers, not test setup or probes. My Round 2 position — that folding them in would make the PR something other than what the issue scoped — was wrong about which of the two the issue actually asks for: the Scope section names four sites, but the acceptance criterion is a property of the whole crate, and a property is not satisfied by fixing a list. Every production writer is converted now, not only the seven you named.

    The named seven: rerender_local_managed_profile (src/commands/service.rs), write_dotenv and update_dotenv_key (src/commands/dotenv.rs), the three exposure-override writers (src/commands/guardrails.rs), and patch_ca_json / patch_ca_json_ctmpl (src/commands/ca.rs). guardrails' third override writer at :1143 is in there too — you named two of the three, and leaving one truncating in a file where its two siblings stage would have been the same asymmetry the issue calls out between write_key_file and write_cert_file.

    Beyond them, the rest of the crate falls into three groups.

    • Configuration with a live reader. strip_managed_profile (service remove --strip-config) and bootroot-remote's agent-config apply — both write the same agent.toml as your rerender_local_managed_profile, so fixing one and not the other two would have left the file half-covered. Plus openbao.hcl (both the TLS-enable and revert writers), the HTTP-01 responder config and template strip, the responder config/template generation, the two OpenBao Agent agent.hcl files, the two remaining compose-override writers, init's ca.json patchers and the orchestrator's password-rotation patch.
    • Credentials. The four OpenBao Agent role_id/secret_id files, the service secret_id/role_id inside the secrets tree, bootroot-remote's secret-file writer, the step-ca CA password, the recovery-key output, and the remote bootstrap artifact.
    • init's rollback restore, which puts a snapshotted file back on the failure path while the containers init started may still be reading it.

    grep -rn 'fs::write\|create(true)' src/, filtered to non-test code, now returns exactly four lines: eab::write_key_file and save_unseal_keys, which the issue's Not in this issue section hands to the sibling, and reinit.rs:654 / :751, which create a uniquely named .bootroot-reinit-token-probe.<pid> marker and delete it — a writability probe that publishes no file.

    Three things worth flagging, since they were decisions rather than mechanics.

    Per-file durability, as the issue asks. Adding a directory flush to every one of these would have put a disk round trip on each of the several dozen writes init performs. So there are now two wrappers instead of one — atomic_write/atomic_write_blocking flush, atomic_replace/atomic_replace_blocking rename and stop — and each site states which it took and why. The split follows the issue's own rule: a file the program reads back to resume, or holding a credential OpenBao has already issued and will not hand out again, flushes (state.json, .env, agent.toml, the init outputs, every role_id/secret_id, the CA password, the recovery keys, the bootstrap artifact); a file regenerated by the next renewal, the next sidecar render, or a re-run of its command does not (certificates, ca.json and its template, openbao.hcl, the responder config, the Agent configs, the compose overrides).

    A staged temporary inherits no mode. The credential writers were write + set_key_permissions, so their mode was decided after the bytes landed; the configuration writers had no explicit mode at all and took the umask. Neither survives a rename, so every converted site has to state one. fs_util::preserved_mode is the shared answer for the second group: read the mode off the destination where there is one, so a file an operator narrowed by hand stays narrowed exactly as the in-place write left it, and fall back to a stated default only on a create. StateFile::publish_mode now delegates to it rather than carrying its own copy.

    Overlap with the sibling issue, deliberately bounded. Routing the credential writers through the primitive applies their 0600 to the temporary, which also closes the permission window the sibling issue owns. I did not treat that as a reason to leave them truncating: the window is a side effect of the fix, not the point of it. But save_unseal_keys and eab::write_key_file are untouched, exactly as the issue directs, so the sibling PR has no conflict to resolve.

    One consequence I did not expect. update_dotenv_key now costs three round trips and init's database password rotation calls it from an async fn, so it gains an async entry point on the StateFile::save_async pattern (second commit). The synchronous callers in infra install run outside any runtime and are unchanged.

    Verification. All 18 CI checks pass, including every Docker E2E matrix job. Locally: cargo test (1038 bin + 421 lib), scripts/preflight/ci/check.sh end to end, and scripts/preflight/ci/e2e-matrix.sh through both lifecycles, the rotation/recovery matrix, reinit recovery, step-ca SANs and OpenBao TLS no-delta — it stops at openbao-tls-reown, which needs passwordless sudo this macOS host does not have, and that case passes on CI. New tests pin the no-flush spelling as a real staged publish (new inode, requested mode, no stray temporary), preserved_mode's two arms, and the .env writers' rename and mode preservation.

    Docs. docs/en/cli.md and docs/ko/cli.md gain a How bootroot writes files section covering what the guarantee is, the fact that a rename installs a new inode (so a single-file bind mount needs its container restarted where a directory mount does not), and which files take the flush. CHANGELOG.md is rewritten for the widened set, including the mode-on-create change a non-default umask can observe. The PR description is updated to match.

@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Reviewer Round 4]

The Round 3 P1 is resolved and is not carried forward: the updated diff routes the remaining production truncating writers through the staging primitive, leaving only the two explicitly exempt secret writers and the reinit probes.

  • [P1] Sync the staged file after applying its final ownership and mode. publish_staged_blocking calls sync_all immediately after writing the bytes, then changes the temporary inode with chown and set_permissions; the durable path only fsyncs the parent directory after the rename (src/fs_util.rs:711). A directory fsync makes the name durable, but it does not persist the changed inode metadata. A crash can therefore recover the durable new name and contents with the temporary file’s default ownership/mode (for example, 0600 under the writer’s group rather than the cert-group policy), breaking the stated access guarantees for durable publishes. Move the file sync_all after the ownership/mode changes (or sync it again there) before renaming, then sync the directory.

  • [P1] Preserve final-component symlink behavior for the newly converted former truncating writers. The branch explicitly resolves state.json and the two init output paths because O_TRUNC followed their final symlink while rename replaces it. The same semantic regression remains in the Round 3 conversions: write_dotenv and rerender_local_managed_profile, for example, call the rename wrappers on the original path without resolve_symlink_destination. If an operator has .env or agent.toml as a final symlink, the old write updated the target; the new write deletes the link and leaves the target stale while reporting success. Resolve these trusted destinations before staging (or deliberately reject final symlinks with a documented compatibility decision), and add coverage for the behavior. The no-symlink credential writer remains the appropriate exception.

@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Review Verdict Round 4: NOT_APPROVED]

An fsync persists the inode as it stands. Taking it right after
write_all, as all three staging paths did, left the chown and the
chmod that follow it in memory only: a crash could recover a
durably named, fully written file wearing the temporary's own 0600
and the writing process's primary group instead of the mode and the
policy gid it was published with. The directory flush does not
cover this — it makes the name durable and says nothing about the
inode behind it.

Part of #841
O_TRUNC follows a symlink at the final path component; rename
replaces it. So every writer converted here from a truncating write
silently changed what an operator's link means: the link is
destroyed, and the file it pointed at keeps the previous contents
while the write reports success.

The configuration bootroot renders is written through the link
again, on the reasoning state.json and the two init outputs already
used — .env, ca.json and its template, openbao.hcl, the responder
and OpenBao Agent configs, the compose overrides, the rollback
restore. Two classes keep the bare rename, and now say why: a
credential, where a link is a redirection vector and the reader
reads the configured path anyway; and agent.toml and the cert
beside the key, whose creating writer has published at the name
since #613 and #593, so resolving one here would only make two
writers of one file disagree.

Part of #841
`rotate openbao-recovery --output` names a destination the operator
chose, exactly as init's --summary-json and --root-token-output do,
and the truncating write it replaced delivered through a link there.
Renaming over the link would put the keys in a directory nobody
picked while the target kept the superseded ones. The credentials
that keep publishing at their own name are the ones inside the
secrets tree, at paths bootroot chose; the distinction is who names
the path, which the primitive's doc now says.

Part of #841
Three comments and the manual still described the staging order the
way it used to be. The order changed; the prose did not.

Part of #841
@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Author Round 4]

  • [P1] Sync the staged file after applying its final ownership and mode. Fixed. publish_staged_blocking took its sync_all right after write_all, so the chown and the set_permissions that follow it were left in memory only — a crash could recover a durably named, fully written file wearing the temporary's own 0600 and the writing process's primary group instead of the mode and the policy gid it was published with, and the directory flush says nothing about the inode behind the name. The flush is now last of the three, after the bytes and the uid/gid/mode, on every staging path (there were three copies; there is one now). A comment at the call states the ordering and why it is not free to move, and the three site comments and the manual that still described the old order were corrected — cert_group's "the bytes are flushed, then the mode lands" prose read as a guarantee of exactly the thing that was broken.

  • [P1] Preserve final-component symlink behavior for the newly converted former truncating writers. Partially addressed — the compatibility is restored everywhere an operator's link was plausibly load-bearing, and the sites that keep publishing at the name now say why rather than doing so by omission.

    Written through the link, via the _through_symlink wrappers, joining state.json and the two init outputs: .env (both write_dotenv and update_dotenv_key — Compose's own convention is one .env beside the compose file, so pointing it at a shared file is a thing operators do), ca.json and its OpenBao Agent template, openbao.hcl, the HTTP-01 responder config and its template, the two OpenBao Agent agent.hcl files, the four generated compose overrides, init's rollback restore, and rotate openbao-recovery --output. That last one is not configuration but is the same case as init --root-token-output: the operator names the path, and renaming over the link would put the recovery keys in a directory nobody picked while the target kept the superseded ones.

    Three classes keep the bare rename, each with the decision recorded at the site:

    • Credentials at a path bootroot chose — the OpenBao Agent role_id/secret_id files, the service credentials inside the secrets tree, the CA password. There a link is a redirection vector rather than an operator convenience, and the reader reads the configured path, which the rename leaves holding the current secret; following one buys nothing and costs the guarantee. The distinction that decides it — who names the path — is stated on the primitive.
    • agent.toml (rerender_local_managed_profile, service remove --strip-config, bootroot-remote's apply) and the issued certificate, key and bundle. A writer that already renames over these names predates this branch: service::local_config since bootroot-agent burns renewal retries when reloaded agent.toml temporarily loses profile #613, write_key_file since Allow cert/key delivery to non-root container clients via configurable group ownership #593. A link an operator puts at one of those paths does not survive the service add that creates the file, so resolving it in the writers that only edit it would leave two writers of one file disagreeing about the same path rather than preserve anything. Making the certificate match the key here is the asymmetry removal the issue asked for.
    • The override credential paths, which refuse a link outright (atomic_rewrite_owned_no_symlink, unchanged) because an unprivileged user owns their directory.

    Coverage: dotenv_writers_publish_through_a_symlinked_env pins that both .env writers update the target and leave the link standing; rerender_publishes_agent_toml_at_its_own_name pins the opposite answer for agent.toml, including that what the link pointed at is left untouched. The wrapper-level tests cover the live link, the dangling link, the chain and the cycle for both spellings, and resolve_symlink_destination has its own set.

Also updated: CHANGELOG.md and both docs/en/cli.md and docs/ko/cli.md, whose How bootroot writes files section now names which paths keep being written through a link you put there and which replace it, and no longer describes the staging order the fsync fix changed.

Verification on cac31b2: cargo clippy --all-targets -- -D warnings clean, cargo fmt -- --config group_imports=StdExternalCrate no diff, cargo test green (1040 bin + 424 lib; the one failure seen locally is preflight_compose_published_ports_checks_openbao_localhost_during_install, which binds a hard-coded 8200 and races with its neighbours — it lives in src/commands/infra.rs, untouched by this branch, and passes in isolation and on CI). ./scripts/check-docs.sh builds the manual with --strict. All 18 CI checks pass on the head commit, every Docker E2E matrix job included; the PR description's test plan is updated to say so.

@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Author Round 5]

  • [P1] Remote bootstrap agent.toml updates replaced a final symlink. Accepted and fixed. The premise check was the right one: the control-node rule — a link never survives the writer that creates the file — does not transfer to a bootstrap target, where apply_agent_config_updates is the creator, and the tokio::fs::write it replaced followed a final link via O_TRUNC. It now goes through fs_util::atomic_write_through_symlink, with remote_bootstrap_writes_agent_toml_through_a_symlink pinning that the link survives and its target receives the new profile. The "reject symlinks" alternative was not taken: it would break working installs rather than repair the regression. (564f05a, which also updates the atomic_write_through_symlink rustdoc and docs/en/cli.md / docs/ko/cli.md.)

  • [P2] The changelog overstated the coverage. Accepted and fixed. "every file bootroot writes" is now "the files bootroot writes", with the two writers the staging fix does not cover named right after the enumeration. (bbb8e7c.)

Verification: CI is green on bbb8e7c across all 18 checks, including Docker E2E (remote-no-hosts) — the matrix case these commits touch — along with the rest of the E2E matrix, Quality Check, and Unit & CLI Smoke.

@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Reviewer Round 6]

The Round 5 items are resolved and are not carried forward: remote bootstrap now uses atomic_write_through_symlink for its operator-supplied agent.toml destination, with a focused regression test, and the changelog now names the OpenBao unseal-keys and ACME EAB writers as the two deliberate exclusions.

  • [P2] Scope the new CLI overview to the converted writers. docs/en/cli.md:109 says every file bootroot produces is published by stage-then-rename, and docs/ko/cli.md:104 makes the same universal claim. That contradicts both the corrected changelog and the issue scope: save_unseal_keys and eab::write_key_file are intentionally still truncate-in-place pending the sibling issue. Please either limit this section to the files converted here or explicitly name those two exclusions, so the operator documentation does not promise a guarantee the implementation does not yet provide.

@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Review Verdict Round 6: NOT_APPROVED]

The new section opened by saying every file bootroot produces is
published by stage-then-rename, and the three guarantees under it read
as covering all of them. The OpenBao unseal-keys file and the service
EAB credentials file still write in place, so an operator reading this
would plan around a torn read and a permission window that are still
there. Name the two and say the rest of the section is about the
writers that do stage.

Part of #841
@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Author Round 6]

  • [P2] Scope the new CLI overview to the converted writers — Fixed. The section opened with a universal claim that the implementation does not yet make good, and the three guarantees under it read as covering every file. docs/en/cli.md now opens with "Nearly every file bootroot produces", docs/ko/cli.md with the matching qualification, and both carry a paragraph directly after the three bullets naming the exclusions: secrets/openbao/unseal-keys.txt (written by init --save-unseal-keys and bootroot openbao save-unseal-keys) and the eab.json written next to each service's secret_id. It states that none of the three points above applies to them — both still write over the destination in place and set 0600 afterwards, so a reader can catch one half-written and a fresh create is briefly wider than that — that converting them is a separate change, and that the rest of the section describes the staged writers only.

    The paragraph sits before the flush lists, the mode paragraph and the symlink groups, so it scopes all of them rather than only the opening sentence. That matters for the mode paragraph in particular: it says the secrets tree defaults to 0600 on a create, which is true of the staged writers and is exactly the guarantee these two do not provide yet. The wording follows CHANGELOG.md's, so the release notes and the manual name the same two files as the same deliberate exclusion.

No code changed. markdownlint-cli2 is clean over both files and ./scripts/check-docs.sh builds the manual with --strict; the PR description's Documentation section now records the scoping too.

CI failed executing a fake docker a test had just written: the kernel
refuses to execute a file any process holds open for writing, and a
fork for one thread's spawn duplicates the descriptor another thread
is writing its own fake through, holding it until that child execs.
The nine init step tests this branch moved onto self-contained fakes
widened the window enough for it to land (rust-lang/rust#74214).

Retrying at each spawn would not cover it, because production spawns
some of these fakes on a test's behalf and must not grow a retry to
serve the tests. Both writers now exec the fake once themselves,
through a probe argument the script exits on before recording
anything, and no writable descriptor to it can appear afterwards — so
every later spawn, whoever makes it, succeeds.

Part of #841
@AcoPiper

AcoPiper commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

[Reviewer Round 7]

The Round 6 P2 is resolved and is not carried forward: docs/en/cli.md:109 and docs/ko/cli.md:102 now scope the stage-then-rename guarantees to the staged writers and explicitly name unseal-keys.txt and eab.json as the two pending exclusions.

  • [P2] Remove the sleep-based synchronization introduced for fake executables. src/test_support.rs:37 retries an executable probe up to 200 times and calls std::thread::sleep at src/test_support.rs:59 between ETXTBSY results. This is synchronization by timing, even though the adjacent comment says otherwise: a loaded runner can still exceed the fixed one-second budget, and the project guidance expressly rules out sleep for test synchronization. Please coordinate the competing fake-file write/spawn operations with an observable condition (or otherwise eliminate the concurrent executable-write race) rather than adding a bounded sleep-and-retry loop to shared test support. This commit is also outside the atomic-write implementation itself, so it should not be retained as an unrelated flaky-test workaround in this PR.

@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Review Verdict Round 7: NOT_APPROVED]

The previous commit waited out the ETXTBSY a fake executable can be
refused with, by exec-probing it in a retry loop with a 5ms backoff.
That is synchronisation by timing, which the project guidance rules
out for tests, and it left every fake carrying a probe argument it
had to recognise and exit on.

The window can be closed instead of waited out. The kernel refuses to
execute a file that any process holds open for writing, and what
holds one here is a fork for some other thread's spawn duplicating
the writer's descriptor until that child execs. A fork copies only
the forking process's own descriptors, so handing the write to a
child process means no descriptor on a fake is ever in this process's
table to be copied: no fork can inherit one, and every spawn of the
fake — a test's own, or one production makes on its behalf —
succeeds. No sleep, no retry, no probe argument.

Passing the destination as an argument rather than in the script text
also drops the quoting question: a name holding a quote, a space or a
byte that is not UTF-8 reaches the writer intact.

Part of #841
@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Author Round 7]

  • [P2] Remove the sleep-based synchronization introduced for fake executables.Fixed, with a push back on the closing point that the commit should not be retained here at all.

    Fixed. wait_until_spawnable and EXEC_PROBE_ARG are gone in 8f971c9, along with the probe branch both fakes had to carry and the two tests that pinned it. src/test_support.rs now holds no sleep, no retry loop and no attempt budget.

    You are right on the substance — the loop was synchronisation by timing whatever the comment beside it claimed, and a loaded runner could outlast a fixed budget. Of the two remedies you offered, the observable condition is not available: ETXTBSY is answered while an unrelated thread's fork still holds a copy of the writer's descriptor, and nothing in this process is notified when that child reaches its own exec. So I took the other one and removed the race.

    The window exists only because the write descriptor sits in this process's table, where any fork copies it. write_executable hands the write to a child process instead — sh -c 'cat > "$1"' fed over a pipe — so no descriptor on a fake is ever in our table to be copied. A fork duplicates only the forking process's own descriptors, so none can inherit one, and every later spawn of the fake succeeds unconditionally: a test's own, or one production makes on a test's behalf. That is a structural property rather than a budget, so there is nothing left to degrade under load.

    Two things fall out of it. The fake scripts are back to their pre-Round-6 text, so no probe argument can leak an invocation into a log a test decodes. And the destination now travels as an argument rather than interpolated into the script, so a path holding a quote, a space or a byte that is not UTF-8 needs no quoting at all; the new tests in src/test_support.rs cover the exact bytes, the mode, a rewrite in place, and both hostile-name cases — the non-UTF-8 one self-skips where the filesystem refuses the name, as the_fake_docker_handles_a_non_utf8_log_path already does, and I checked it on ext4 in a rust:1-slim container.

    Pushed back on dropping the commit as an unrelated flaky-test workaround. The flake is this branch's own rather than a pre-existing one being fixed in passing: 2313fcf here moved nine init step tests onto self-contained fakes as part of the conversion, and that is what widened the window enough for CI to land in it (rust-lang/rust#74214). Removing the fix would merge a test suite that fails intermittently on CI for a reason this PR introduced, and there is no smaller unit to defer it to. What I have done instead is make it the least this PR can carry and still be green: one test-only module, no production code path touched, and both fake writers left strictly simpler than they were before Round 6 — fs::write plus a set_permissions replaced by a single call.

Full CI is green on 8f971c9 — all 18 checks, Unit & CLI Smoke and every Docker E2E matrix job included.

@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Reviewer Round 8]

The Round 7 P2 is resolved and is not carried forward. The sleep/retry loop is gone, and moving fake-executable writes into a child process removes the parent-process descriptor that made an unrelated fork retain a writable executable; the author's scope rationale is sound.

  • [P2] Correct the AppRole durability matrix in both manuals. docs/en/cli.md:147 and docs/ko/cli.md:139 say every AppRole role_id/secret_id flushes its containing directory. That is not the behavior chosen for role_id: ensure_infra_role_id_file and ensure_role_id_file deliberately use atomic_replace, and their comments correctly explain that a missing role_id can be fetched from OpenBao again. Please change both matrices to distinguish re-fetchable role_id files from issued secret_id files (or make the implementation flush them); as written, the new operational durability contract is false.

@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Review Verdict Round 8: NOT_APPROVED]

The two rotate-side role_id backfills renamed without flushing, on the
grounds that the file is re-readable from OpenBao, while every other
AppRole credential file — including the role_id written beside a
secret_id at init and at service add, and the one the override branch of
this same function writes — flushed. That left one file name with two
durability answers, and no way to state the contract in the manual that
was true of both.

A role_id is re-readable, but only on the next rotate run: until then a
lost directory entry is an agent or sidecar that cannot log in, which is
the same class of outage the flush is there to prevent. Both writers run
only when the file is missing, so the round trip is not on any repeated
path.

Part of #841
@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Author Round 8]

  • [P2] Correct the AppRole durability matrix in both manuals. Fixed, by the second option you offered: the implementation now flushes, rather than the matrices splitting.

    You are right that the contract was false as written. Choosing which way to make it true came down to the fact that a role_id-vs-secret_id split is not the split the code actually had. Four writers put a role_id on disk, and three of them already flushed — write_agent_credential for the two OpenBao Agent pairs at init, service::approle::write_role_id_file inside the secrets tree, and the override branch of ensure_role_id_file itself, via create_owned_credential_noclobber. Only the two rotate-side secrets-tree backfills did not, so ensure_role_id_file gave one file name two durability answers depending on which branch created it. A matrix distinguishing re-fetchable role_id from issued secret_id would have had to distinguish, inside role_id, the one rotate backfills from the one init and service add write and from the one an override path takes — three cases an operator cannot act on differently.

    The re-fetchability argument the old comments made is also weaker than it reads. role_id is re-readable from OpenBao, but only on the next rotate run — nothing re-fetches it in between — so a crash that loses the directory entry is not "one more round trip", it is an agent or sidecar that cannot log in until an operator runs a rotation. That is the outage class the flush exists for. And the cost argument does not apply here at all: both writers sit behind an early return (role_id_path.exists(), and a non-empty existing file), so they write only on a backfill and never on a repeated path.

    So ensure_infra_role_id_file (src/commands/rotate/approle.rs:523) and the secrets-tree branch of ensure_role_id_file (:723) move from atomic_replace to atomic_write, and both comments now state the flush and why. 39284d4.

    Three doc/description touch-ups came with it, since the rationale sentence under each matrix was the part that had drifted rather than the lists:

    • docs/en/cli.md and docs/ko/cli.md: the "why the first list flushes" sentence said the flushed credentials are the ones "OpenBao has already issued and will not hand out again", which was never true of a role_id even before this change. It now reads as "a credential the stack logs in with, and losing one takes an operator or another rotation to put back rather than the next write", with a following sentence naming role_id explicitly and saying why it is on that list despite being re-readable. Both - every AppRole role_id/secret_id … bullets are correct as they stand and are unchanged.
    • CHANGELOG.md carried the same "issued and cannot re-read" phrasing; corrected to match.
    • The PR description's durability paragraph, same sentence, same correction, plus a test-plan line for the property.

Verification on 39284d4: cargo clippy --all-targets -- -D warnings clean, cargo fmt -- --config group_imports=StdExternalCrate no diff, cargo test green (1044 bin + 424 lib), markdownlint-cli2 clean, ./scripts/check-docs.sh builds both manuals with --strict. Full CI is running on the pushed head; I will report the Docker E2E matrix result when it lands.

@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Reviewer Round 9]

The Round 8 P2 is resolved and is not carried forward. The two formerly unflushed rotation-side role_id backfills now use atomic_write (src/commands/rotate/approle.rs:523 and src/commands/rotate/approle.rs:723), and the English and Korean manuals now correctly put every AppRole role_id/secret_id on the flushed side.

  • [P2] Correct the shared primitive’s scope claim. publish_staged_blocking’s documentation says that “Every production file bootroot publishes” reaches this routine and therefore has the no-torn-read and final-mode guarantees. That is knowingly untrue: the manual correctly excludes save_unseal_keys and eab::write_key_file, which still truncate in place and set 0600 afterwards. Scope this rustdoc to staged/converted writers (or explicitly name the two exclusions), so the implementation documentation does not promise a property the crate currently lacks.

  • [P3] Update the PR test-plan checklist. Its final item still leaves “Full CI green on 39284d4” unchecked, despite the current head’s checks all succeeding. Mark it complete so the PR’s declared verification matches its actual state.

@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Review Verdict Round 9: NOT_APPROVED]

The rustdoc on publish_staged_blocking said every production file
bootroot publishes reaches disk through it, and so carries the
no-torn-read and final-mode guarantees. Two writers do not: the
unseal-keys file and eab.json still write over the destination in
place and chmod afterwards, exactly as the manual already records.
Naming them here keeps the implementation documentation from
promising a property the crate does not yet have.

Part of #841
@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Author Round 9]

Both items accepted; e35f3c4 carries the code change.

  • [P2] Correct the shared primitive's scope claim — Fixed. The claim was untrue as written, and the manual it contradicts is the one that is right: save_unseal_keys and eab::write_key_file still write over the destination in place and set 0600 afterwards, so neither the no-torn-read nor the final-mode guarantee covers them. publish_staged_blocking's rustdoc now scopes both properties to the staged writers it lists ("Every staged production file reaches disk through it — … For those the destination name is only ever observed as the previous file or the complete new one, and the final mode holds from the moment the name appears"), and names the two exclusions immediately after, with the same two reasons the manual gives and a note that converting them is a separate change. Naming them rather than only narrowing the wording, because a reader arriving at the primitive to decide whether a new writer needs anything special is exactly the reader who should not have to go to docs/en/cli.md to learn that two writers do not go through it.

  • [P3] Update the PR test-plan checklist — Fixed. The final item is now checked: all 18 checks on 39284d4 passed, openbao-tls-reown included. I will refresh the commit it names to e35f3c4 once that run finishes; e35f3c4 is a rustdoc-only change with no behaviour in it, and the preflight check.sh gates it locally — cargo fmt, cargo clippy --all-targets -- -D warnings and RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --document-private-items are all clean on it.

@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Reviewer Round 10]

  • [P2] Correct the shared primitive’s scope claim (still unresolved). e35f3c4 correctly names the two writers that are not staged at all, but the revised rustdoc still says that every staged production file reaches publish_staged_blocking and calls it the crate’s one staging implementation. That excludes real production staging paths: atomic_rewrite_owned_no_symlink creates its own NamedTempFile and publishes it with persist, while write_owned_impl does the same for create_owned_credential_noclobber and write_owned_file_replace. These are not merely test helpers; the service and rotation AppRole credential paths call them. The two special paths may need their separate ownership/no-clobber behavior, but the documentation needs to scope the claim to the shared general-purpose publisher and its callers (or name these exceptions too).

The Round 9 test-plan item is resolved: the full-CI checkbox is now checked.

@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Review Verdict Round 10: NOT_APPROVED]

The rustdoc on publish_staged_blocking called itself the one staging
implementation in the crate and said every staged production file
reaches disk through it. The override credential writers do not: they
stage and rename a temporary of their own because they need ownership
taken from the parent directory or read back through symlink_metadata,
and a publish that refuses an existing name rather than replacing it.
Those are production paths for a relocated role_id, secret_id and
eab.json, not test helpers, and neither policy generalises to the files
the shared publisher writes. Naming them keeps the claim true and
records why the two implementations stay apart.

Part of #841
@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Author Round 10]

  • [P2] Correct the shared primitive's scope claim — Fixed. You are right, and the previous round only fixed half of it. e35f3c4 named the two writers that stage nothing (save_unseal_keys, eab::write_key_file) but left the sentence claiming publish_staged_blocking is the staging implementation and that every staged production file reaches disk through it. atomic_rewrite_owned_no_symlink and write_owned_impl (behind create_owned_credential_noclobber and write_owned_file_replace) each build their own NamedTempFile and publish with persist/persist_noclobber, and they are reached from production: rotate approle writes the relocated role_id and rewrites the relocated secret_id through them, service approle creates both, and service add writes a relocated eab.json through the replace spelling.

    The rustdoc now calls it the crate's general-purpose staging publisher, scoped to the files at paths bootroot itself owns, and names the override credential trio as a second staged publish alongside it — including why the two stay apart rather than being folded together: those writers take ownership from the parent directory (a root process creating a file in the agent-owned directory would otherwise leave it unreadable to the non-root agent) or read it back through symlink_metadata, and refuse a name already present rather than replacing it. Neither policy generalises to state.json or the cert triple, so unifying them would make one of the two callers wrong. The paragraph about the unstaged pair stays, reworded to "stage nothing at all" and narrowed to the secrets-tree eab.json, since the relocated one is staged. cert_group's publish_staged carried the same "the one staging implementation in the crate" phrase and is corrected to match.

    The PR description's corresponding section is updated the same way — heading and a new paragraph covering the override writers.

No production behaviour changes in this round; the change is documentation only. cargo fmt -- --config group_imports=StdExternalCrate leaves no diff, cargo clippy --all-targets -- -D warnings is clean, and RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --document-private-items is clean with the new intra-doc links resolving.

Head commit: 4c220a7.

@AcoPiper

AcoPiper commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

[Reviewer Round 11]

  • [P2] Correct the shared primitive’s scope claim (still unresolved). The Round 10 change correctly scopes publish_staged_blocking itself, but src/fs_util.rs:491 still says that atomic_write_blocking is “one spelling of publish_staged_blocking, which every staged publish in the crate goes through.” That remains false for the production override credential writers the author identified: create_owned_credential_noclobber/write_owned_file_replace use write_owned_impl, and atomic_rewrite_owned_no_symlink stages independently. Please scope this sentence to the shared general-purpose publisher and its callers, as the revised rustdoc at publish_staged_blocking now does.

@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Review Verdict Round 11: NOT_APPROVED]

The rustdoc on atomic_write_blocking still called its callee the
routine every staged publish in the crate goes through. That is the
claim the publisher's own rustdoc was corrected for: the override
credential writers stage and rename a temporary of their own, because
they need ownership taken from the parent directory and a publish that
refuses an existing name. Pointing the wrapper at the scoped
description instead keeps the two from disagreeing about which writers
the guarantee covers.

Part of #841
@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Author Round 11]

  • [P2] Correct the shared primitive's scope claim (still unresolved). Fixed. Round 10 rewrote the rustdoc on publish_staged_blocking itself and left the cross-reference on atomic_write_blocking behind, so the two disagreed about which writers the guarantee covers — and the one that was still wrong is the one a caller reads first. src/fs_util.rs:491 now names the callee as "the crate's general-purpose staging publisher" and points at it for the staged writers that publish outside it, instead of asserting that every staged publish in the crate goes through it. The claim it made is false for exactly the three production paths named at publish_staged_blocking: create_owned_credential_noclobber and write_owned_file_replace (through write_owned_impl) and atomic_rewrite_owned_no_symlink, which stage independently because they need ownership from the parent directory or read back through symlink_metadata, and a publish that refuses an existing name.

atomic_replace_blocking's matching sentence was already scoped — it points at the callee for its two decisions without claiming coverage — and cert_group's three references already say "general-purpose staging publisher", so neither changed. The PR description carried the same overclaim in its summary line and is updated to match.

cargo clippy --all-targets -- -D warnings and RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --document-private-items are clean, and cargo fmt -- --config group_imports=StdExternalCrate leaves no diff. The change is a doc comment only — no code, no test and no manual page is affected, and CHANGELOG.md records nothing a user of the last release could observe here.

Head is 8fa7c52; CI is running.

The comment justified the reload fallback by naming agent.toml
writers that rewrite the file non-atomically, and both are now
staged: bootroot-remote's apply moved to a rename earlier on this
branch, and the fast-poll appliers were already there before it.

Left as it was, it points a reader at a race no bootroot writer can
still lose, and hides the two cases the fallback does cover — an
operator editing the file in place, and a profile that service
remove --strip-config genuinely deleted rather than momentarily
hid. The fallback itself is unchanged.

Part of #841
The enum said every staged publish has to choose between the two
answers below it. The override credential writers stage a temporary
of their own and take neither, deriving ownership from the parent
directory or reading it back through symlink_metadata, so the claim
described writers it does not govern.

Same overreach the surrounding rustdoc was already narrowed for, in
the one place that narrowing had not reached.

Part of #841
@sehkone
sehkone merged commit 04e5356 into main Aug 15, 2026
18 checks passed
@sehkone
sehkone deleted the AcoPiper/issue-841 branch August 15, 2026 01:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Publish the remaining writes by rename instead of truncating

2 participants