fix(paths): refuse a read-only destination in the replace fallback - #3670
cwjechw98-lang wants to merge 1 commit into
Conversation
On Windows `os.replace` refuses to overwrite a read-only destination (WinError 5), and
`os_replace_with_fallback` then defeats that refusal: Windows permits RENAMING a read-only file
aside, so the fallback moves the original to a backup, puts the copy in place, and the read-only
file is silently replaced. That backup is read-only too, so the `os.unlink(backup)` that follows
fails and its error is swallowed, leaving a `.gfy-replace-bak-*.tmp` in the output directory.
`tests/test_atomic_writes.py::test_write_text_atomic_refuses_a_readonly_destination_without_leaking_a_temp`
already pins the intended behaviour and fails on the current tree:
AssertionError: read-only file was clobbered
assert 'replaced' == 'original'
Its first assertion (`pytest.raises(PermissionError)`) passes today for the wrong reason: the
exception escapes from `os.unlink(src)` at the end of the fallback, after the destination has
already been replaced.
The check sits inside the fallback, so POSIX semantics are untouched — there `os.replace`
succeeds and the check is never reached — keeping the divergence the test docstring documents
rather than changing it. Refusing before anything is created is what makes "no temp left behind"
hold by construction instead of by cleanup. The backup cleanup now clears the read-only bit and
retries, mirroring the idiom this file already uses for `.gfy-*.tmp`.
Verified on Windows with Python 3.10.5: the pinned test goes from failing to passing, the other
14 cases in that file stay green, `ruff check` passes, and two consecutive `graphify extract`
runs over a live corpus overwrite `graph.json` leaving no `.tmp` behind.
Refs Graphify-Labs#3508
|
Related in-flight work, for reviewers: #2410 also touches The two deliberately differ on read-only handling, so it is worth saying why. Clearing the bit is |
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 3 advisory finding(s) below merit a look before merge.
Formal verification. No changes could be formally verified in this run.
Graphify review — findings
Refuses the cross-drive/permission fallback path in os_replace_with_fallback up front when the destination is read-only, raising PermissionError before any temp file is created so a rejected write leaves nothing behind (Windows would otherwise rename the read-only original aside and silently replace it). Also clears the read-only bit and retries when unlinking the swap backup, preventing a leaked .gfy-replace-bak-*.tmp beside the output.
Worth a look
- os_replace_with_fallback now raises PermissionError for read-only destinations that previously succeeded on POSIX —
graphify/paths.py:68· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Read-only guard breaks legitimate same-file replace and cross-drive moves to read-only targets —
graphify/paths.py:68· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Fallback now rejects replaceable symlink destinations when the symlink target is read-only —
graphify/paths.py:69· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 2213 functions depend on the 29 functions this change touches.
Health — this change adds coupling hotspots:
- new:
extract()— 649 callers, 45 callees - new:
_rebuild_code()— 137 callers, 54 callees - new:
build_from_json()— 218 callers, 20 callees - new:
build_merge()— 76 callers, 14 callees - new:
save_semantic_cache()— 63 callers, 9 callees - new:
to_obsidian()— 38 callers, 14 callees - new:
save_manifest()— 40 callers, 11 callees - new:
to_json()— 58 callers, 7 callees - …and 58 more — each is listed as a finding
Verification — 2213 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 1237 function(s) in the blast radius were not formally verified this run
Test selection
Test selection
113 of 286 test file(s) selected (40%) via static blast radius.
tests/test_affected_cli.py— impacttests/test_agents_platform.py— impacttests/test_analyze.py— impacttests/test_atomic_canvas_export.py— impacttests/test_atomic_version_stamp.py— impacttests/test_atomic_writes.py— impacttests/test_benchmark.py— impacttests/test_benchmark_raw_graph.py— impacttests/test_build.py— impacttests/test_build_merge_dedup_scope.py— impacttests/test_build_merge_hyperedges_and_prune.py— impacttests/test_build_merge_shrink_guard.py— impacttests/test_cache.py— impacttests/test_callflow_html.py— impacttests/test_carried_hyperedge_remap.py— impacttests/test_charmap_encoding.py— impacttests/test_chunking.py— impacttests/test_cli_export.py— impacttests/test_cluster.py— impacttests/test_codebuddy.py— impacttests/test_community_labels_skill.py— impacttests/test_confidence.py— impacttests/test_corrupt_graph_json.py— impacttests/test_cpp_objc_cross_file_calls.py— impacttests/test_cross_extension_reexport_self_cycle.py— impacttests/test_dedup.py— impacttests/test_dedup_remaps_hyperedges.py— impacttests/test_definition_file_portability.py— impacttests/test_detect.py— impacttests/test_devin.py— impacttests/test_duplicate_annotation_edges.py— impacttests/test_evidence_binding.py— impacttests/test_explain_cli.py— impacttests/test_export.py— impacttests/test_export_control_characters.py— impacttests/test_export_path_length.py— impacttests/test_external_stub_endpoints.py— impacttests/test_extract.py— impacttests/test_extract_cache_location.py— impacttests/test_extract_cli.py— impacttests/test_falkordb_integration.py— impacttests/test_file_label_disambiguation.py— impacttests/test_global_add_tag_inference.py— impacttests/test_global_graph.py— impacttests/test_go_qualified_resolution.py— impacttests/test_god_nodes_cli.py— impacttests/test_god_nodes_exclude_hubs.py— impacttests/test_hollow_chunks_arm_shrink_guard.py— impacttests/test_hook_out_of_project_paths.py— impacttests/test_hook_strict.py— impact- … and 63 more
Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.
Formal verification
Could not verify: Could not verify os\_replace\_with\_fallback.
The verifier did not have enough to check os\_replace\_with\_fallback, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: no capturable inputs from the test suite; property tier: parameter `src` is annotated `'str | Path'` — outside the synthesizable primitive/collection set
· 66 more finding(s) on lines outside this diff (see the check run).
What
On Windows,
os_replace_with_fallbackdefeats the read-only protection thatos.replaceitself has just enforced — and leaks a backup file while doing it.
Why it happens
os.replace(tmp, dst)raisesPermissionError(WinError 5) becausedstis read-only,so the protection works as intended.
os.rename(dst, backup)moves the protected file aside without complaint.os.rename(tmp_copy, dst)then succeeds — the read-only file has been replaced, silently.os.unlink(backup)fails; the error isswallowed by a bare
except OSError: pass, leaving a.gfy-replace-bak-*.tmpnext to theoutput on every such write.
The existing test already pins this
tests/test_atomic_writes.py::test_write_text_atomic_refuses_a_readonly_destination_without_leaking_a_temp(Windows-only) fails on the current tree:
Worth flagging: that test's first assertion (
pytest.raises(PermissionError)) passes today forthe wrong reason — the exception escapes from
os.unlink(src)at the very end of the fallback,after the destination has already been replaced. Only the other two assertions catch the bug.
The change
by construction rather than by cleanup.
os.replacesucceeds and the check is never reached. That keeps the divergence the test docstring already
documents ("Documented rather than 'fixed'") instead of changing it.
already uses for the
.gfy-*.tmpcleanup ~30 lines below.Verification
pytest tests/test_atomic_writes.py -q— before:1 failed, 14 passed, 2 skipped;after:
15 passed, 2 skipped.ruff check graphify/paths.py— All checks passed. (ruff format --checkalready reportsthis file as unformatted on
v8, before the change; that is untouched here.)graphify extractruns over a live corpus overwritegraph.jsonand leaveno
.tmpbehind.'replaced'with a leaked.gfy-replace-bak-*.tmpnext to it.Refs #3508 — same family of Windows replace quirks.