refactor: finish the atomic-writer consolidation and document the write policy - #661
refactor: finish the atomic-writer consolidation and document the write policy#661mattmillerai wants to merge 8 commits into
Conversation
… tmp+replace sites (BE-4353) Consolidate the duplicated "write via tmp file + os.replace" pattern into a single shared helper `comfy_cli.file_utils.atomic_write_text(path, content, *, fsync=False)` and migrate the vanilla copies onto it: - skills.write_manifest — also FIXES an existing tmp-file leak (an exception between the write and the rename previously left the tmp behind). - skills._atomic_write_text — removed; all four skill writers now use the shared helper. - cql/loader.write_object_info_cache — keeps its best-effort try/except OSError wrapper around the call. - jobs_state.write — uses fsync=True, keeping its locking.file_lock wrapper. - command/workflow._atomic_write_text — removed; a sixth identical copy the ticket's finder overlooked, migrated for completeness. auth/store._write_all is deliberately left untouched: it is a security-hardened superset (O_EXCL + 0o600 at open, 0o700 parent dir, fsync) protecting secrets.
Rewrite the tmp-file creation to use tempfile.mkstemp in the destination directory, addressing the consolidated cursor-review panel findings: - High: drop the PID-only tmp name that let concurrent writers collide on <dest>.<pid>.tmp; mkstemp gives a unique name per write. - Medium (CWE-377): mkstemp opens with O_CREAT|O_EXCL and doesn't follow symlinks, so a pre-planted symlink can't redirect the write. - Medium: fsync now uses the O_RDWR fd from mkstemp, so os.fsync no longer silently no-ops on Windows (FlushFileBuffers needs write access). - Low: except BaseException so a KeyboardInterrupt mid-write still cleans up the tmp file, per the docstring. - Nit: fsync the parent directory after os.replace so the rename itself is durable against power loss. Add tests for unique-per-write tmp names and no-symlink-following. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ert fsync (BE-4353) CodeRabbit review follow-ups: - mkstemp hardcodes the tmp file to 0600 and os.replace carries that mode onto the destination, so a first atomic write silently stripped group/other read from shared outputs. Restore the existing destination's mode, else the umask-derived default for a new file, before the rename. This matches the pre-migration write_text() behavior of the migrated call sites. - Strengthen the fsync test to spy on os.fsync and assert it's actually invoked on the tmp fd (and the parent dir fd off Windows), plus add mode-preservation tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolves the CodeQL py/overly-permissive-file (high) alert flagged on the atomic-write mode-preservation test. The test only needs a non-0600 mode to prove mkstemp's hardcoded 0600 does not clobber the destination mode; 0o640 keeps the group-read regression guard without granting world read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t (BE-4353) The prior 0o640 (world→group) still trips py/overly-permissive-file because group-read is flagged too. The test only needs a non-0600 mode to prove mkstemp's hardcoded 0600 doesn't clobber the destination mode, so use owner-execute (0o700) — a bit 0600 lacks, with no group/other bits — keeping the required CodeQL check green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolves conflicts in workflow.py (combine main's --stdout JSON-envelope guard with the shared atomic_write_text rename) and cql/loader.py (keep both the atomic_write_text import and build_http_only_opener import).
…te policy Migrates the three remaining umask-policy tmp+os.replace writers onto the shared file_utils helpers and writes down the four-tier policy that decides which writers may use them. - add atomic_write_bytes, sharing one private _atomic_write implementation with atomic_write_text (text encodes utf-8 and delegates) - project.py _write_assets_lock -> atomic_write_text(fsync=True); gains tmp-file cleanup on failure, which the inline version lacked - outdated.py _save_cache -> atomic_write_text(fsync=False), inside the existing best-effort OSError wrapper - templates.py _persist_cache -> atomic_write_bytes(fsync=False); the cache moves from mkstemp's incidental 0600 to umask mode, which is intended for a public gallery index - document the four tiers in file_utils.py, plus the two invariants that justify jobs_state's tier and the un-fsynced parent directory - point auth/store._write_all and download_state.write_path at that note so the next dedup sweep does not re-flag them No writer's permissions change beyond the templates cache noted above.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Found 10 finding(s).
| Severity | Count |
|---|---|
| 🟠 High | 1 |
| 🟡 Medium | 4 |
| 🟢 Low | 5 |
Panel: 8/8 reviewers contributed findings.
comfy_cli/file_utils.py:116 — 🟠 High — Switching the shared implementation from os.fdopen(fd, "w", encoding="utf-8") to "wb" drops text-mode newline translation, so on Windows every atomic_write_text caller now emits bare LF where it previously emitted CRLF. Callers that read an existing file with universal newlines and rewrite it whole (e.g. the comfy_cli/skills writers for AGENTS.md/CLAUDE.md) will silently convert CRLF files to LF; either keep a newline-translating text path for atomic_write_text or document the deliberate change and update the bytes-variant test comment that still claims "no platform newline translation" distinguishes the twin. Raised by 2 of 8 reviewers (claude-opus-5-thinking-max edge-case, gemini-3.1-pro edge-case).
comfy_cli/command/project.py:289 — 🟡 Medium — The removed inline writer let an os.fsync failure propagate before os.replace, aborting the push so the lock was never published; atomic_write_text swallows fsync errors (except OSError: pass) and renames anyway. An EIO/ENOSPC writeback failure now silently overwrites a known-good asset lock with possibly-undurable content and reports success, contradicting the tier-3 durability rationale in the comment right above. Consider a strict-fsync option for tier-3 callers. Raised by 4 of 8 reviewers (gpt-5.6-sol-max adversarial, gpt-5.6-sol-max edge-case, gemini-3.1-pro adversarial, claude-opus-5-thinking-max edge-case).
comfy_cli/file_utils.py:136 — 🟡 Medium — The mode is re-applied with os.chmod(tmp_name, dest_mode) by path after the fd is closed, and os.chmod follows symlinks. In a world-writable destination directory an attacker who observes the mkstemp name can unlink it and drop a symlink in that window, chmod-ing an arbitrary file owned by the invoking user — which defeats the "non-symlink-following fd" claim in the comment above. Use os.fchmod(f.fileno(), dest_mode) inside the with block instead, which also makes the mode durable before the fsync. Raised by 2 of 8 reviewers (claude-opus-5-thinking-max adversarial, kimi-k2.7-code edge-case).
comfy_cli/file_utils.py:132 — 🟡 Medium — umask = os.umask(0); os.umask(umask) is a non-atomic read-modify-write of process-global state, and it isn't in a try/finally — a KeyboardInterrupt between the two calls leaves the umask at 0 for the rest of the process, and any concurrent thread creating a file in that window (telemetry flush in tracking.py, the OAuth callback server, launch stream redirectors) gets 0666/0777. Wrap the probe in try/finally at minimum, or avoid mutating the umask by deriving the mode from the tmp file that mkstemp already created. Raised by 4 of 8 reviewers (gpt-5.6-sol-max adversarial, claude-opus-5-thinking-max adversarial, kimi-k2.7-code adversarial, kimi-k2.7-code edge-case).
comfy_cli/command/templates.py:148 — 🟡 Medium — The gallery cache moves from mkstemp's fixed 0600 to a umask-derived mode, so under a permissive umask (0002/0000, common in containers and CI images) index.json becomes group- or world-writable. _load_gallery feeds the parsed rows into template resolution and into _enforce_spend_gate's paid-node detection with no integrity check, so a local user could strip those signals and weaken the credit-spend consent gate. The comment's rationale ("the payload is the public template-gallery index") addresses confidentiality but not tampering. Raised by 2 of 8 reviewers (claude-opus-5-thinking-max adversarial, gpt-5.6-sol-max adversarial).
comfy_cli/file_utils.py:116 — 🟢 Low — If os.fdopen(fd, "wb") itself raises, the fd from mkstemp is leaked: the cleanup path only unlinks the tmp file and never closes the raw fd. Guard the fdopen call so the descriptor is closed on that failure. Raised by 1 of 8 reviewers (kimi-k2.7-code edge-case).
comfy_cli/command/templates.py:145 — 🟢 Low — The comment claims the helper "restores the umask-derived mode", but _atomic_write only falls back to 0666 & ~umask when os.stat(path) fails; when the destination already exists it reuses the destination's current mode. Cache files already on disk at 0600 from the old mkstemp path therefore stay 0600 forever, so the intended relaxation applies only to fresh installs — and test_atomic_write_bytes_new_file_uses_umask_default covers only the fresh-file case. Raised by 1 of 8 reviewers (claude-opus-5-thinking-max edge-case).
comfy_cli/file_utils.py:130 — 🟢 Low — Preferring the destination's existing mode means the helper faithfully re-applies whatever bits are already there, including group/world-writable and setuid/setgid/sticky bits that stat.S_IMODE preserves. A file pre-created by a local user in a shared directory stays attacker-writable across every subsequent atomic rewrite instead of being healed, and os.stat follows symlinks so the mode can be sourced from an unrelated file. Raised by 2 of 8 reviewers (claude-opus-5-thinking-max adversarial, kimi-k2.7-code adversarial).
tests/comfy_cli/command/test_templates.py:548 — 🟢 Low — monkeypatch.setattr(file_utils.tempfile, "mkstemp", ...) mutates the attribute on the shared stdlib tempfile module object rather than on file_utils, so mkstemp raises OSError process-wide for the entire runner.invoke. The test then passes even if _persist_cache/atomic_write_bytes is never reached, masking a regression in the best-effort write path it is meant to pin. Raised by 1 of 8 reviewers (claude-opus-5-thinking-max adversarial).
tests/comfy_cli/command/test_node_deps.py:834 — 🟢 Low — The new assertion reduces to X.encode("utf-8") == X.encode("utf-8") for a pure-ASCII X (json.dumps escapes café under the default ensure_ascii=True), so it holds under any ASCII-compatible codec and cannot detect the locale-picked text-mode write the docstring says it guards — weaker than the encoding == "utf-8" assertion it replaces. Compare against a payload with a literal non-ASCII byte (e.g. ensure_ascii=False) or assert the encode step directly. Raised by 1 of 8 reviewers (claude-opus-5-thinking-max edge-case).
(Inline comments could not be anchored to the diff; listed above instead.)
|
Your base, #595, is now merged — I reviewed it in depth and landed it ( That squash-merge is exactly what has flipped this PR to Two things from the #595 review that are worth folding into this PR while you're in there, since it's the one that writes down the policy:
Ping me once it's rebased and I'll pick it straight up — the |
|
🤖 The reviews loop filed Linear follow-up ticket(s) for review thread(s) deferred as out of scope for this PR:
The following carry
|
ELI-5
The CLI writes a bunch of small files — a job's status, a lock file listing pushed assets, a couple of caches. Writing a file isn't instant, so if the CLI is killed halfway through you can end up with a half-written file that nothing can read. The fix everyone uses is: write to a scratch file first, then rename it over the real one in a single step that can't be interrupted. Ten places in this codebase had each written that same dance by hand.
#595 built one shared helper and moved five of them onto it. This PR moves the last three, and — more importantly — writes down the rule for which ones are allowed to use the shared helper at all. Two writers deliberately stay hand-written because they can contain passwords or signed URLs, and they need stricter file permissions than the shared helper gives. Now there's a note in the code explaining exactly why, so the next person doing a cleanup pass doesn't "helpfully" consolidate a secrets file into the generic path.
What changed
New helper.
atomic_write_bytes(path, data, *, fsync=False)joinsatomic_write_text. Both delegate to one private_atomic_writethat operates on bytes; the text variant encodes UTF-8 and forwards. Nomode=parameter — that was considered and rejected, because a writer whose payload needs 0600 needs more than a mode (anO_EXCLopen at that mode, a 0700 parent, an fsync), and that's clearer spelled out at its own call site.Three migrations.
command/project.py_write_assets_lockatomic_write_text(..., fsync=True)command/outdated.py_save_cacheatomic_write_text(..., fsync=False)try/except OSError: pass; the helper'smkdirreplaces the explicitpath.parent.mkdir.command/templates.py_persist_cacheatomic_write_bytes(..., fsync=False)The write policy, as a comment block adjacent to the helpers in
file_utils.py: four tiers (secrets → bespoke 0600; secret-adjacent state → bespoke 0600; cross-process state →fsync=True; regenerable caches →fsync=False), each naming its members, plus the two recorded invariants (whyJobStatefiles are umask-mode, and why nobody fsyncs the parent directory afteros.replace).auth/store._write_allanddownload_state.write_patheach gained a one-line pointer at that note so the next dedup sweep doesn't re-flag them.Permissions
No writer's permissions change, with one deliberate exception called out in the ticket and in the code: the templates gallery cache. Its old inline
tempfile.mkstempleft it at mode 0600 incidentally (mkstemp hardcodes 0600 andos.replacecarries that onto the destination); the shared helper restores the umask-derived mode instead. That relaxation is intended — the payload is the public template-gallery index, not user data. There's a test pinning the new mode.Judgment calls
This is stacked on #595, not a rebase-and-land of it. The ticket's precondition said to rebase #595 onto
main, resolve the conflict, and land it first. I resolved the conflict but could not land it (merging is not mine to do), and rebasing would have required a force-push. So instead I pushed the conflict resolution to #595's branch as an ordinary merge commit — a fast-forward, no history rewritten — which took #595 fromCONFLICTINGtoMERGEABLE, and stacked this work on top. That also keeps this PR's diff to just my own commit rather than double-counting all ofmain. The conflict itself was one import block incommand/workflow.pywhere both sides had added an import; resolved by keeping both.The shared implementation writes in binary mode.
atomic_write_textnow encodes UTF-8 itself and the shared_atomic_writeopens"wb", where it previously opened"w", encoding="utf-8". On Windows that drops the automatic\n→\r\ntranslation, so files written by all seven text call sites now get LF line endings there instead of CRLF. This is safe and I'd argue more correct: every reader of these files goes throughread_text()orjson.loads(), both of which normalize newlines, so the round-trip is unchanged; no test asserts exact bytes for any of them; and it makes the two variants byte-identical in behavior. Flagging it explicitly because I can't exercise Windows locally — thewindows-latestCI leg covers it.Two existing tests were rewritten, not deleted. Both asserted the old implementation, not behavior, and could not survive the migration:
test_save_cache_pins_the_write_encodingspied onPath.write_textto checkencoding="utf-8"was named. The helper doesn't usePath.write_text. Rewritten to assert the same contract one layer down and rather more directly — that the bytes reaching the filesystem are the payload encoded UTF-8 by us, so the host locale never gets a vote.test_readonly_cache_dir_still_serves_fetched_datapatchedtemplates_cmd.tempfile.mkstempto force a write failure;templates.pyno longer importstempfileat module scope. Repointed atfile_utils.tempfile.mkstemp, which keeps the test end-to-end through the real_persist_cache→atomic_write_bytespath rather than stubbing the helper out.Negative-claim falsification: not triggered. This diff denies no capability — it adds no
not supported/unavailable/STOPstrings, no throw/deny dead-end, and flips no test to assert a dead-end. It is a pure consolidation: every migrated writer keeps its existing success and failure behavior, and one (_write_assets_lock) gains cleanup it previously lacked.Testing
pytest: 3756 passed, 37 skipped (full suite).ruff check .andruff format --check .: clean, run against v0.15.15, the version pinned in.pre-commit-config.yaml. Worth knowing for anyone reproducing locally: a plainuv syncresolves ruff 0.12.7, which reports 17 pre-existing errors and 1 format diff onmainuntouched by this PR — version skew, not real findings.atomic_write_bytesunit tests mirroring the text-variant cases: create-with-parents, overwrite, verbatim payload (non-UTF-8 bytes and bare LFs survive), fsync path, cleanup-with-destination-untouched on a failed rename, and the umask-default mode that the templates cache now relies on.