Skip to content

fix(paths): refuse a read-only destination in the replace fallback - #3670

Open
cwjechw98-lang wants to merge 1 commit into
Graphify-Labs:v8from
cwjechw98-lang:fix/readonly-destination-windows
Open

cwjechw98-lang wants to merge 1 commit into
Graphify-Labs:v8from
cwjechw98-lang:fix/readonly-destination-windows

Conversation

@cwjechw98-lang

Copy link
Copy Markdown

What

On Windows, os_replace_with_fallback defeats the read-only protection that os.replace
itself has just enforced — and leaks a backup file while doing it.

Why it happens

  1. os.replace(tmp, dst) raises PermissionError (WinError 5) because dst is read-only,
    so the protection works as intended.
  2. The fallback takes over. Windows permits renaming a read-only file, so
    os.rename(dst, backup) moves the protected file aside without complaint.
  3. os.rename(tmp_copy, dst) then succeeds — the read-only file has been replaced, silently.
  4. That backup is the original read-only file, so os.unlink(backup) fails; the error is
    swallowed by a bare except OSError: pass, leaving a .gfy-replace-bak-*.tmp next to the
    output 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:

E  AssertionError: read-only file was clobbered
E  assert 'replaced' == 'original'
FAILED tests/test_atomic_writes.py::test_write_text_atomic_refuses_a_readonly_destination_without_leaking_a_temp
1 failed, 14 passed, 2 skipped in 1.16s

Worth flagging: that test's first assertion (pytest.raises(PermissionError)) passes today for
the 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

  • Refuse a read-only destination before anything is created, so "no temp left behind" holds
    by construction rather than by cleanup.
  • The check lives inside the fallback, so POSIX behaviour is untouched: there os.replace
    succeeds and the check is never reached. That keeps the divergence the test docstring already
    documents ("Documented rather than 'fixed'") instead of changing it.
  • The backup cleanup now clears the read-only bit and retries, mirroring the idiom this file
    already uses for the .gfy-*.tmp cleanup ~30 lines below.

Verification

  • Windows 11, Python 3.10.5, graphify 0.9.64.
  • 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 --check already reports
    this file as unformatted on v8, before the change; that is untouched here.)
  • Two consecutive graphify extract runs over a live corpus overwrite graph.json and leave
    no .tmp behind.
  • The raw failure was reproduced first against an installed wheel: destination content became
    'replaced' with a leaked .gfy-replace-bak-*.tmp next to it.

Refs #3508 — same family of Windows replace quirks.

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
@cwjechw98-lang

Copy link
Copy Markdown
Author

Related in-flight work, for reviewers: #2410 also touches graphify/paths.py, but there is no
hunk overlap — it adds clear_readonly / rmtree helpers for deleting read-only trees, while
this PR only changes os_replace_with_fallback.

The two deliberately differ on read-only handling, so it is worth saying why. Clearing the bit is
right when the operation removes something the OS or a sync client marked read-only (OneDrive, as
#2410 documents). It is wrong when the destination is a file the user marked read-only: that
marking is the user's protection, and os.replace already refuses to touch it. This PR makes the
fallback respect that refusal instead of routing around it — which is also what
tests/test_atomic_writes.py already asserts, and what the test docstring already calls "the
defensible behaviour".

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 POSIXgraphify/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 targetsgraphify/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-onlygraphify/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 — impact
  • tests/test_agents_platform.py — impact
  • tests/test_analyze.py — impact
  • tests/test_atomic_canvas_export.py — impact
  • tests/test_atomic_version_stamp.py — impact
  • tests/test_atomic_writes.py — impact
  • tests/test_benchmark.py — impact
  • tests/test_benchmark_raw_graph.py — impact
  • tests/test_build.py — impact
  • tests/test_build_merge_dedup_scope.py — impact
  • tests/test_build_merge_hyperedges_and_prune.py — impact
  • tests/test_build_merge_shrink_guard.py — impact
  • tests/test_cache.py — impact
  • tests/test_callflow_html.py — impact
  • tests/test_carried_hyperedge_remap.py — impact
  • tests/test_charmap_encoding.py — impact
  • tests/test_chunking.py — impact
  • tests/test_cli_export.py — impact
  • tests/test_cluster.py — impact
  • tests/test_codebuddy.py — impact
  • tests/test_community_labels_skill.py — impact
  • tests/test_confidence.py — impact
  • tests/test_corrupt_graph_json.py — impact
  • tests/test_cpp_objc_cross_file_calls.py — impact
  • tests/test_cross_extension_reexport_self_cycle.py — impact
  • tests/test_dedup.py — impact
  • tests/test_dedup_remaps_hyperedges.py — impact
  • tests/test_definition_file_portability.py — impact
  • tests/test_detect.py — impact
  • tests/test_devin.py — impact
  • tests/test_duplicate_annotation_edges.py — impact
  • tests/test_evidence_binding.py — impact
  • tests/test_explain_cli.py — impact
  • tests/test_export.py — impact
  • tests/test_export_control_characters.py — impact
  • tests/test_export_path_length.py — impact
  • tests/test_external_stub_endpoints.py — impact
  • tests/test_extract.py — impact
  • tests/test_extract_cache_location.py — impact
  • tests/test_extract_cli.py — impact
  • tests/test_falkordb_integration.py — impact
  • tests/test_file_label_disambiguation.py — impact
  • tests/test_global_add_tag_inference.py — impact
  • tests/test_global_graph.py — impact
  • tests/test_go_qualified_resolution.py — impact
  • tests/test_god_nodes_cli.py — impact
  • tests/test_god_nodes_exclude_hubs.py — impact
  • tests/test_hollow_chunks_arm_shrink_guard.py — impact
  • tests/test_hook_out_of_project_paths.py — impact
  • tests/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).

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.

1 participant