Skip to content

fix(specialist): make overwrites atomic - #757

Open
PierrunoYT wants to merge 10 commits into
Gitlawb:mainfrom
PierrunoYT:fix/issue-753-atomic-specialist-overwrite
Open

fix(specialist): make overwrites atomic#757
PierrunoYT wants to merge 10 commits into
Gitlawb:mainfrom
PierrunoYT:fix/issue-753-atomic-specialist-overwrite

Conversation

@PierrunoYT

@PierrunoYT PierrunoYT commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Summary

  • write specialist overwrites to a sibling temporary file with mode 0600
  • sync and close temporary content before atomically replacing the destination
  • reject symlink destinations and clean up temporary files on failure
  • add regression coverage for replacement, permissions, symlink safety, and cleanup

Verification

  • go vet ./...n- go test ./...n- go run ./cmd/zero-release buildn- go run ./cmd/zero-release smoken- git diff HEAD --check`n

Environment limitations

  • go test -race ./internal/specialist requires CGO, which is disabled in this environment
  • pinned golangci-lint and govulncheck currently build with Go 1.25 and cannot analyze this Go 1.26.5 repository
  • make fmt-check could not run because make is unavailable; touched files were formatted with gofmt`n
    Fixes fix(specialist): overwrite is race-prone and non-atomic #753

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability of overwriting specialist files with crash-durable, concurrency-safe atomic publish behavior, including protection against symlink targets.
    • Preserves destination permissions (including Windows ACL/DACL) during forced overwrite, with safer handling when cleanup or deferred commit fails.
    • Prevents loading partially-written manifests by serializing file visibility during replacement.
    • CLI output now surfaces manifest warnings to stderr for human output and includes them in JSON output.
  • Tests

    • Expanded coverage for atomic replacement, Windows retry/rollback behavior, sync error handling, and guaranteed cleanup of temporary overwrite artifacts.
  • Documentation

    • Added guidance for recovering interrupted --force overwrites.

Copilot AI review requested due to automatic review settings July 19, 2026 13:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens specialist manifest overwrites by switching from in-place truncation writes to a write-to-temp + atomic replace flow, addressing the race/non-atomic overwrite risks described in issue #753.

Changes:

  • Implement atomic overwrite via sibling temp file creation (0600), sync/close, then rename into place.
  • Add symlink rejection to prevent overwriting a symlinked specialist destination.
  • Add regression tests covering atomic replacement behavior, permissions, symlink safety, and temp cleanup.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
internal/specialist/storage.go Reworks overwrite path to write to a synced temp file and atomically replace the destination, rejecting symlink destinations.
internal/specialist/storage_test.go Adds regression coverage for overwrite replacement semantics, permissions, and temp-file cleanup.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/specialist/storage.go Outdated
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Specialist storage now uses exclusive creation for new files and temporary-file replacement for overwrites. Platform-specific replacement handles retries, rollback, metadata preservation, and cleanup. CLI and tool output surface manifest warnings.

Changes

Specialist overwrite safety

Layer / File(s) Summary
Cross-platform replacement primitives
internal/fsutil/rename.go, internal/fsutil/replace_*.go
ReplaceWithRetry combines retry handling with Unix rename or Windows ReplaceFileW, including backup cleanup and partial-replacement recovery.
Atomic specialist write path
internal/specialist/storage.go, internal/specialist/manifest.go
Storage.Create creates destination directories, preserves exclusive creation, synchronizes create/load/delete operations, and uses synced 0600 temporary files with symlink validation and replacement.
Replacement behavior validation
internal/fsutil/*_test.go
Tests cover publication, retries, Windows identity and DACL preservation, replacement flags, symlink rejection, cleanup, and rollback.
Storage and warning output validation
internal/specialist/*_test.go, internal/cli/*, internal/cli/*_test.go, docs/SPECIALISTS.md
Tests and documentation cover cleanup handling, committed-cleanup warnings, directory-sync behavior, manifest filtering, interrupted recovery, and warning formatting in human, JSON, and tool output.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant StorageCreate
  participant writeSpecialistReplacement
  participant ReplaceWithRetry
  participant FileSystem
  StorageCreate->>writeSpecialistReplacement: format and write specialist content
  writeSpecialistReplacement->>FileSystem: create, sync, and close temporary file
  writeSpecialistReplacement->>ReplaceWithRetry: publish temporary file
  ReplaceWithRetry->>FileSystem: replace destination with platform-specific semantics
  FileSystem-->>ReplaceWithRetry: replacement result
  writeSpecialistReplacement->>FileSystem: sync destination directory
Loading

Suggested reviewers: jatmn, anandh8x, gnan1990

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: making specialist overwrites atomic.
Linked Issues check ✅ Passed The PR implements temp-file replace, no-follow validation, sync/close, and Windows-safe replacement behavior required by #753.
Out of Scope Changes check ✅ Passed The added tests, docs, warning propagation, and synchronization changes are all tied to specialist overwrite safety and recovery.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/specialist/storage.go (1)

124-128: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

No directory fsync after rename — rename durability isn't guaranteed on crash.

os.Rename at Line 124 is not followed by an fsync on the parent directory. On some filesystems (notably ext4 without data=journal), a rename's directory-entry update is not guaranteed durable until the containing directory is fsynced — a crash immediately after a successful rename can leave the destination reverted to its pre-rename state. This is directly relevant to the PR's stated goal of avoiding files left "empty or partially written after disk, process, or I/O failures."

♻️ Proposed fix: fsync the parent directory after rename
 	if err := os.Rename(tempPath, path); err != nil {
 		return fmt.Errorf("replace specialist file: %w", err)
 	}
+	if dir, derr := os.Open(filepath.Dir(path)); derr == nil {
+		_ = dir.Sync()
+		_ = dir.Close()
+	}
 	return nil
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/specialist/storage.go` around lines 124 - 128, Update the atomic
replacement flow containing os.Rename to open the destination’s parent
directory, fsync the directory after a successful rename, and close it while
propagating any open, sync, or close errors. Preserve the existing wrapped
rename error and nil success behavior, and ensure cleanup handles failures
without masking the primary error.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/specialist/storage.go`:
- Around line 117-126: Update writeSpecialistAtomic to provide a
Windows-specific replacement path that guarantees atomic, crash-safe file
replacement instead of relying solely on os.Rename; isolate platform-specific
behavior behind appropriate build-specific helpers while preserving the existing
symlink checks and error handling.

---

Nitpick comments:
In `@internal/specialist/storage.go`:
- Around line 124-128: Update the atomic replacement flow containing os.Rename
to open the destination’s parent directory, fsync the directory after a
successful rename, and close it while propagating any open, sync, or close
errors. Preserve the existing wrapped rename error and nil success behavior, and
ensure cleanup handles failures without masking the primary error.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 48974b34-0cac-4aa8-8a9a-e20b6875d0f7

📥 Commits

Reviewing files that changed from the base of the PR and between ce4a996 and c0ba649.

📒 Files selected for processing (2)
  • internal/specialist/storage.go
  • internal/specialist/storage_test.go

Comment thread internal/specialist/storage.go Outdated

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Use an atomic replacement primitive on Windows
    internal/specialist/storage.go:131
    This still reaches os.Rename through RenameWithRetry; retries only handle sharing violations and do not change the replacement operation. Go explicitly documents that os.Rename is not atomic on non-Unix platforms, even for sibling paths, so a Windows --force overwrite can expose a missing/intermediate manifest if it is interrupted or observed concurrently—the exact failure this change is meant to eliminate. Add a Windows-specific replacement path that provides the required atomic/crash-safe semantics (and test that operation); the current Windows test only proves a retry followed by the same non-atomic rename.

  • [P1] Preserve the existing Windows DACL when publishing the replacement
    internal/specialist/storage.go:101
    Overwriting now publishes a newly created temporary file, so its inherited directory ACL replaces the destination file's ACL. temp.Chmod(0o600) does not make the file private on Windows—Go only uses the owner-write bit there—whereas the old in-place truncate retained an existing restrictive DACL. Consequently, overwriting a specialist that has been explicitly restricted inside a directory readable by a group can expose its system prompt to that group. Preserve the destination security descriptor (or apply an equivalent private DACL) as part of the Windows replacement, and cover that behavior with a Windows test.

…imitive

Addresses both P1 findings on Gitlawb#757.

os.Rename — which fsutil.RenameWithRetry wraps — is documented non-atomic
outside Unix, and the retry loop only handles sharing violations. Worse, the
replacement is a freshly created temporary file, so renaming it over the
destination also publishes the directory's inherited DACL in place of the
destination's own: overwriting a specialist that had been explicitly restricted
inside a group-readable directory would expose its system prompt to that group.
temp.Chmod(0o600) cannot prevent that on Windows, where Go maps only the
owner-write bit.

New fsutil.ReplaceWithRetry publishes through a platform primitive:

- Windows uses ReplaceFileW with REPLACEFILE_WRITE_THROUGH, one operation that
  carries the replaced file's security descriptor, attributes, and streams over
  to the replacement. REPLACEFILE_IGNORE_ACL_ERRORS is deliberately not passed —
  silently losing the descriptor is the failure being fixed — so a merge failure
  surfaces and leaves the destination untouched. A missing destination falls back
  to a rename (nothing to replace or preserve), as do volumes that cannot perform
  a replace at all (FAT/exFAT, some network redirectors), which carry no ACL
  either. The transient-lock retry is unchanged.
- Unix keeps rename(2), which is already atomic within one filesystem.

Tests: Windows coverage asserts the destination's creation time survives (a
rename would not, proving the call goes through ReplaceFileW) and that a
protected owner-only DACL survives both at the fsutil layer and through the real
`Create --force` path; a plain rename was verified to replace that DACL with the
directory's inherited ACEs, so the assertion has teeth. POSIX covers both
destination shapes.
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Pushed c26fe90 (on a merge of current main). Both P1s are addressed by one new primitive, fsutil.ReplaceWithRetry.

[P1] Use an atomic replacement primitive on Windows — the publish no longer goes through os.Rename. On Windows it calls ReplaceFileW with REPLACEFILE_WRITE_THROUGH: a single replace operation, flushed before it returns, rather than a rename Go explicitly documents as non-atomic outside Unix. The transient-lock retry loop is unchanged around it, so the antivirus/indexer handling you and @copilot asked for earlier is still there — it now retries the replace instead of a bare rename. On Unix the primitive stays rename(2), which is already atomic within one filesystem.

[P1] Preserve the existing Windows DACL — this is the same API, and it is why ReplaceFileW is the right choice rather than MoveFileEx: it carries the replaced file's security descriptor, attributes, and streams over to the replacement, so the temporary file's inherited DACL never lands on the destination. REPLACEFILE_IGNORE_ACL_ERRORS is deliberately not passed — silently dropping the descriptor is the exact failure being fixed — so an ACL merge failure surfaces as an error with the destination untouched and the temp file cleaned up.

Two narrow fallbacks to os.Rename, both cases where there is nothing to preserve: a missing destination (ReplaceFileW requires one to replace) and volumes that cannot perform a replace at all (ERROR_INVALID_FUNCTION/ERROR_NOT_SUPPORTED — FAT/exFAT, some network redirectors), which carry no ACLs either.

Tests, and their teeth

  • internal/fsutil/replace_windows_test.go: the destination's creation time survives the replace — a rename would carry the temp file's over, so this pins that the call really goes through ReplaceFileW; a protected owner-only DACL (D:P(A;;FA;;;OW)) survives; the missing-destination fallback; and the transient ERROR_SHARING_VIOLATION retry.
  • internal/specialist/storage_dacl_windows_test.go: the same DACL assertion through the real Create --force path, as requested.
  • internal/fsutil/replace_other_test.go: both destination shapes on POSIX.

I checked the DACL test actually has teeth by running the old rename-based publish against it. It replaces D:PAI(A;;FA;;;OW) with the directory's inherited ACEs:

before        = "D:PAI(A;;FA;;;OW)"
after rename  = "D:AI(A;ID;0x1301bf;;;S-1-5-21-…-1004)(A;ID;…)(A;ID;FA;;;SY)(A;ID;FA;;;BA)…"

That is the exposure you described, and ReplaceWithRetry keeps the first form.

@coderabbitai's open thread on storage.go (os.Rename atomicity on Windows) — same root cause, resolved by the above. The earlier directory-fsync-after-rename nitpick was already applied in c346ba3 (syncSpecialistDir, non-Windows).

Validation (Windows host, Go 1.26.5): go build ./..., go vet ./..., go test ./... -count=1 all pass — the Windows ReplaceFileW path, including the real DACL assertions, runs natively here; GOOS=linux|darwin go vet ./internal/fsutil ./internal/specialist clean, with the POSIX test executing in the ubuntu/macOS CI jobs; gofmt clean on every changed file.

Note that ReplaceWithRetry lives in internal/fsutil, so other call sites that publish state over an existing file can pick it up later; this PR only switches the specialist writer.

@jatmn @Vasanthdev2004 @gnanam1990 — ready for another look. I can't use the reviewer-request button on this repo, hence the mention.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

@PierrunoYT, reviewing the updated Windows replacement primitive and its cross-platform behavior now.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@PierrunoYT
PierrunoYT requested a review from jatmn July 25, 2026 13:26

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/fsutil/replace_windows.go`:
- Around line 50-56: Update the destination validation in the Windows
replacement flow around os.Lstat(dst) to inspect the returned FileInfo and
reject destinations where info.Mode()&os.ModeSymlink is nonzero before invoking
ReplaceFileW. Preserve the existing not-found rename behavior and return
non-symlink stat errors unchanged, while maintaining the caller’s existing
symlink-safety error behavior.
- Around line 45-48: Update the ReplaceFileW invocation in the
destination-existing branch to pass only replaceFileWriteThrough, removing
replaceFileIgnoreMergeErrors. Preserve the behavior for new destinations and
ensure ACL merge failures are returned as errors without silently dropping the
destination DACL.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 2926c881-1b80-4db8-93ef-cf8745bdc833

📥 Commits

Reviewing files that changed from the base of the PR and between c346ba3 and c26fe90.

📒 Files selected for processing (7)
  • internal/fsutil/rename.go
  • internal/fsutil/replace_other.go
  • internal/fsutil/replace_other_test.go
  • internal/fsutil/replace_windows.go
  • internal/fsutil/replace_windows_test.go
  • internal/specialist/storage.go
  • internal/specialist/storage_dacl_windows_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/specialist/storage.go

Comment thread internal/fsutil/replace_windows.go Outdated
Comment thread internal/fsutil/replace_windows.go Outdated

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/fsutil/replace_windows_test.go`:
- Around line 93-98: Remove REPLACEFILE_IGNORE_MERGE_ERRORS from the
ReplaceFileW flags in ReplaceWithRetry so metadata merge failures propagate
instead of allowing replacement to succeed with incorrect ACLs. Preserve the
fail-closed DACL behavior, and add a regression test covering merge failure
rather than leaving a TODO when the ACL failure path cannot be exercised.

In `@internal/fsutil/replace_windows.go`:
- Around line 65-82: Update the ReplaceFileW failure handling in the replace
flow around replaceProcReplaceFil.Call to detect
ERROR_UNABLE_TO_MOVE_REPLACEMENT when the replacement remains at src, then move
that surviving file back to dst before returning. Preserve the existing fallback
handling for unsupported functions and other errors, and ensure the recovery
result is propagated appropriately.
- Line 69: Update the ReplaceFileW flag argument in the replacement path to
remove both replaceFileWriteThrough and replaceFileIgnoreMergeErrors, and delete
their now-unused constant definitions. Preserve normal ACL/attribute merge
failure propagation; do not claim write-through durability unless an explicitly
supported mechanism is implemented.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c81ef329-b822-4b3c-bbc8-7e5dd60fd65c

📥 Commits

Reviewing files that changed from the base of the PR and between c346ba3 and c26fe90.

📒 Files selected for processing (7)
  • internal/fsutil/rename.go
  • internal/fsutil/replace_other.go
  • internal/fsutil/replace_other_test.go
  • internal/fsutil/replace_windows.go
  • internal/fsutil/replace_windows_test.go
  • internal/specialist/storage.go
  • internal/specialist/storage_dacl_windows_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/specialist/storage.go

Comment thread internal/fsutil/replace_windows_test.go
Comment thread internal/fsutil/replace_windows.go
Comment thread internal/fsutil/replace_windows.go Outdated

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Do not suppress the DACL merge failure
    internal/fsutil/replace_windows.go:69
    The call passes REPLACEFILE_IGNORE_MERGE_ERRORS (0x2), despite the comment and PR description saying that an ACL merge failure is fail-closed. Microsoft documents that this flag makes ReplaceFileW succeed without preserving ACLs when it lacks WRITE_DAC, so a --force overwrite can publish the temp file's inherited directory DACL over a restricted specialist and expose its system prompt. Remove this flag and cover the denied-merge path; the current DACL tests only exercise a successful merge.

  • [P1] Recover the documented partial ReplaceFileW failure
    internal/fsutil/replace_windows.go:82
    With no backup path, ERROR_UNABLE_TO_MOVE_REPLACEMENT (1176) means Windows has already removed dst while leaving the replacement at src. This returns that error unchanged, after which writeSpecialistAtomicWith removes src in its deferred cleanup. A failed specialist create --force can therefore delete both the old manifest and the only surviving new copy. Detect this state and restore or otherwise preserve the source before allowing cleanup to run, with a regression test.

…ileW

Addresses jatmn's two Gitlawb#757 review findings:

- The call passed REPLACEFILE_IGNORE_MERGE_ERRORS (0x2) while the
  comment claimed ACL failures were fail-closed. Microsoft documents
  0x2 and REPLACEFILE_IGNORE_ACL_ERRORS (0x4) identically: with
  either set, a call lacking WRITE_DAC "succeeds but the ACLs are not
  preserved". Passing 0x2 therefore let a --force overwrite silently
  publish the temp file's inherited directory DACL over a restricted
  specialist and expose its system prompt. Pass no flags at all.

- With no backup file, ERROR_UNABLE_TO_MOVE_REPLACEMENT (1176) and
  ERROR_UNABLE_TO_MOVE_REPLACEMENT_2 (1177) leave the destination
  deleted (1176) or renamed to an unreachable name (1177) while the
  replacement survives only under its temporary name. Returning those
  unrepaired let the caller's deferred temp-file cleanup remove the
  sole surviving copy, so one failed 'specialist create --force'
  destroyed both the old manifest and the new one. recoverPartialReplace
  now moves the replacement into place and still reports the error,
  since the destination's descriptor died with the destination.

Also drops the doc claim that REPLACEFILE_WRITE_THROUGH (0x1) flushes
before returning: Microsoft documents that value as "not supported".

The two fsutil regression tests are verified to fail against the
pre-fix code. The specialist-side test guards the caller half of the
recovery contract and does not fail pre-fix; its comment says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Pushed 32140de, addressing both P1 findings. I checked the ReplaceFileW documentation rather than reasoning from memory, and it confirms both findings verbatim — plus a third inaccuracy in my own comment.

[P1] Do not suppress the DACL merge failure (internal/fsutil/replace_windows.go:69)

Confirmed exactly as described. My comment claimed REPLACEFILE_IGNORE_ACL_ERRORS was "deliberately NOT passed", which was true of 0x4 — but the call passed REPLACEFILE_IGNORE_MERGE_ERRORS (0x2), and Microsoft documents the two with identical consequences:

REPLACEFILE_IGNORE_MERGE_ERRORS — Ignores errors that occur while merging information (such as attributes and ACLs) from the replaced file to the replacement file. Therefore, if you specify this flag and do not have WRITE_DAC access, the function succeeds but the ACLs are not preserved.

So omitting only 0x4 bought nothing: ACL merging is part of the metadata merge 0x2 covers, and a --force overwrite could silently publish the temp file's inherited directory DACL over a restricted specialist. Fixed by passing no flags at all (replaceFileFlags = 0) — the strictest, fail-closed configuration.

Bonus, from the same doc page: REPLACEFILE_WRITE_THROUGH (0x1) is documented as "This value is not supported." My comment asserted it "flushes it before returning", which is simply wrong — so I dropped both the flag and the claim. The single-operation swap is ReplaceFileW's own documented behavior, not that flag's.

On covering the denied-merge path: I could not build an honest live test for it. Windows grants an object's owner READ_CONTROL and WRITE_DAC implicitly, so no DACL a test can apply to a temp file it owns can withhold WRITE_DAC from itself — the denied-merge branch is not reachable for a file this process created. Rather than fake it, TestReplaceFileFlagsDoNotIgnoreMergeErrors asserts the flag word directly, which is what actually prevents the regression (re-adding either bit fails the test). I've documented that reasoning in the test comment instead of leaving it implicit.

[P1] Recover the documented partial ReplaceFileW failure (internal/fsutil/replace_windows.go:82)

Confirmed. For 1176 with no backup, the docs say "the replaced file no longer exists and the replacement file exists under its original name" — so returning it unrepaired let writeSpecialistAtomicWith's deferred os.Remove(tempPath) delete the sole surviving copy. Both the old manifest and the new one gone from one failed --force.

I also handled 1177, which the finding didn't name but has the same shape: the replaced file "still exists with a different name" — a name only lpBackupFileName would have reported, so with no backup the destination path is equally empty.

recoverPartialReplace now moves the replacement into the (now-free) destination path and still returns the error. Not swallowing it is deliberate: the destination's original security descriptor died with the destination, so the published file carries the temp file's inherited DACL — reporting success would be the exact silent ACL widening the first finding is about. 1175 and all other codes pass through untouched, since the docs guarantee both files keep their original names there and the caller's cleanup is then correct.

Tests:

  • TestRecoverPartialReplaceRescuesTheReplacement (1176 + 1177) — verified to fail against the pre-fix behavior: with the repair removed, the destination is missing and the assertion fires.
  • TestRecoverPartialReplaceLeavesIntactStatesAlone (1175 + a generic ERROR_ACCESS_DENIED) — the negative control, so the recovery can't grow into clobbering intact states.
  • TestWriteSpecialistAtomicKeepsAReplacementRecoveredByAFailedReplace — guards the caller half of the contract (its cleanup must not undo the recovery). This one does not fail pre-fix — it asserts a property of the caller, which the fix didn't change — and its comment says so explicitly rather than implying regression coverage it doesn't provide.

Validation:

  • go build/vet/test ./... on Windows (this host): all pass.
  • GOOS=linux/GOOS=darwin cross-builds: clean.
  • go test ./internal/fsutil/... ./internal/specialist/... -count=25 on Windows: pass.
  • go test -race ./internal/fsutil/... ./internal/specialist/... -count=5 via WSL: pass, plus go build/go vet ./... under WSL — clean.
  • gofmt clean on all three changed files (verified against LF-normalized copies; this checkout is CRLF via core.autocrlf, which makes plain gofmt -l flag every file in the repo).

@jatmn ready for another look — particularly on whether returning an error after a successful recovery is the behavior you want. The alternative is reporting success, but that would hide the lost descriptor, so I chose the noisier option.

@PierrunoYT
PierrunoYT requested a review from jatmn July 26, 2026 13:20

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/fsutil/replace_windows.go`:
- Around line 109-152: Update ReplaceFileW and recoverPartialReplace in
internal/fsutil/replace_windows.go to provide and track a managed backup path,
especially for ERROR_UNABLE_TO_MOVE_REPLACEMENT_2 (1177), so any renamed
original is known and removed after publishing the replacement or during
recovery failure; preserve error reporting and avoid orphaned content. In
internal/fsutil/replace_windows_test.go lines 166-212, update the affected tests
to cover backup cleanup and recovery behavior for 1177.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: bf8644c1-48e4-4ec1-82a8-087cd07f6f49

📥 Commits

Reviewing files that changed from the base of the PR and between c26fe90 and 32140de.

📒 Files selected for processing (3)
  • internal/fsutil/replace_windows.go
  • internal/fsutil/replace_windows_test.go
  • internal/specialist/storage_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/specialist/storage_test.go

Comment thread internal/fsutil/replace_windows.go Outdated

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Do not fall back to a non-atomic rename when ReplaceFileW is unsupported
    internal/fsutil/replace_windows.go:100
    ERROR_INVALID_FUNCTION and ERROR_NOT_SUPPORTED take the plain os.Rename path, which is precisely the non-atomic Windows replacement the change is intended to eliminate. A specialist create --force on one of those volumes can again expose a missing/intermediate manifest; it also publishes the temp file's inherited DACL on an ACL-capable redirector rather than the restricted destination DACL. Return the unsupported error for an existing destination, or use a replacement operation that supplies the promised atomic and descriptor-preserving semantics.

  • [P1] Preserve the temporary replacement when partial-failure recovery cannot rename it
    internal/fsutil/replace_windows.go:149
    After 1176/1177, the old destination is gone and src is the only new copy. If this recovery os.Rename fails (for example, a transient share lock), the error is returned with the recovery error only interpolated, so ReplaceWithRetry does not retry it; writeSpecialistAtomicWith then unconditionally removes tempPath in its deferred cleanup. The failed --force operation therefore deletes both the old manifest and the only surviving new one. Retry/preserve the recovery source, or make the caller retain it when recovery fails.

  • [P2] Track and clean up the original file in the 1177 recovery path
    internal/fsutil/replace_windows.go:89
    ERROR_UNABLE_TO_MOVE_REPLACEMENT_2 leaves the replacement at src and the original at a different name. Because this passes a null backup name, that original name is unobservable; recovery only moves src to dst, leaving an orphaned old specialist (including its prior system prompt) behind after a failed overwrite. Supply a managed sibling backup path and define cleanup/rollback for it; the current test fabricates only an absent destination and cannot exercise the retained original.

Use a managed ReplaceFileW backup so partial failures can restore the original file and DACL. Fail closed when atomic replacement is unsupported, retry rollback and backup cleanup, and retain a known backup if rollback cannot complete.\n\nCover unsupported volumes, 1176/1177 semantics, cleanup, rollback retries, and symlink rejection.

Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-be8e-75eb-b274-b7e22af373a0

Co-authored-by: Pierre Bruno <pierrebruno@hotmail.ch>
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Final follow-up on current head c2a07a1:

  • removed the non-atomic Windows fallback for unsupported replacement operations
  • added managed ReplaceFileW backups with documented 1176 handling and recoverable 1177 rollback
  • preserves/reports the backup if rollback cannot complete
  • added immediate symlink rejection and comprehensive backup/rollback/retry/cleanup coverage
  • race tests, Windows-targeted validation, full validation, and all GitHub checks pass

The PR is mergeable. PierrunoYT requests fresh reviews to supersede the prior CHANGES_REQUESTED decisions.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 27, 2026
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Follow-up on head f15f8dc after merging the latest upstream/main normally.

Fresh review audit

I re-read every submitted review and every inline thread against the current code. There are now no unresolved review threads, and CodeRabbit approved the new head with no actionable comments.

Current behavior covers every still-valid request:

  • Windows existing-file publication uses ReplaceFileW, never the non-atomic os.Rename fallback.
  • ERROR_INVALID_FUNCTION / ERROR_NOT_SUPPORTED fail closed for an existing destination.
  • replaceFileFlags is zero, so neither ACL/metadata merge failures nor the unsupported write-through flag are suppressed.
  • The destination DACL/identity is preserved and tested through both fsutil and the real specialist --force path.
  • A managed sibling backup gives the documented 1176/1177 states known recovery paths: 1176 leaves both original names intact; 1177 retries rollback from the known backup; successful replacement/rollback cleans the backup; an exhausted rollback preserves and reports the original backup rather than losing it or hiding an orphan.
  • Windows symlink destinations are rejected before ReplaceFileW.

The three previously unresolved threads were outdated, not skipped as invalid: the old plain-rename retry finding is superseded by ReplaceWithRetry; the ignored-merge-errors finding is superseded by flags 0; and the no-backup 1176 data-loss finding is superseded by the managed backup. I resolved those stale threads after verification. No still-valid request was skipped.

Validation on f15f8dc

Local (Go 1.26.5):

  • make fmt-check
  • go vet ./...
  • go test ./...
  • go test -race ./internal/fsutil ./internal/specialist
  • GOOS=windows GOARCH=amd64 go vet ./internal/fsutil ./internal/specialist
  • Windows test-binary cross-compilation for internal/fsutil and internal/specialist
  • go run ./cmd/zero-release build
  • go run ./cmd/zero-release smoke
  • make lint-static — 0 issues
  • make vulncheck — no vulnerabilities
  • git diff HEAD --check

GitHub checks all pass, including native Smoke (windows-latest), macOS/Ubuntu smoke, Security & code health, Performance Smoke, Zero Review, and CodeRabbit.

The only remaining PR blocker is the stale human CHANGES_REQUESTED review state; a fresh maintainer approval is needed to supersede it.

@PierrunoYT
PierrunoYT requested a review from jatmn July 27, 2026 23:42

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Rechecked head f15f8dc against the earlier P1 round. The core overwrite work looks solid: existing destinations publish through ReplaceFileW with flags 0, unsupported volumes fail closed, managed backup covers 1176/1177, and DACL preservation is tested at both the fsutil and Create --force layers. What remains is mostly post-success caller contract hygiene and catastrophic partial-failure polish.

Findings

  • [P2] Successful overwrite is reported as failure when backup cleanup fails
    internal/fsutil/replace_windows.go:171-176 and internal/specialist/storage.go:138-139
    After ReplaceFileW succeeds, cleanupReplaceBackup returns a hard error if removeReplaceBackup exhausts its retries ("replacement succeeded, but backup … could not be removed"). That is intentional so ReplaceWithRetry does not rerun the replace, but writeSpecialistAtomicWith still surfaces it as replace specialist file: …, so Storage.Create(..., Overwrite: true) returns (Manifest{}, err) even though the destination already holds the new manifest. A leftover .zero-replace-*.backup file in the specialist directory can also retain the pre-overwrite body until manual removal. Please treat a committed replace as success (surface backup cleanup separately or best-effort) so --force does not false-fail after publish.

  • [P2] Directory sync failure after a successful replace reports failure while the file is already updated
    internal/specialist/storage.go:138-143
    On non-Windows platforms, fsutil.ReplaceWithRetry runs before syncSpecialistDir. If the parent-directory sync fails, writeSpecialistAtomicWith returns sync specialist directory: … and Create returns (Manifest{}, err) with no rollback, even though path already contains the new bytes. This is a new failure mode introduced by the post-rename directory fsync; the old truncate path did not have it. Please align with the sessions store pattern: best-effort directory sync after a successful replace, or return success with a distinct cleanup warning rather than a full overwrite failure.

  • [P2] syncSpecialistDir spuriously fails after a successful replace when the directory cannot be opened
    internal/specialist/storage.go:151-153
    After a successful replace, syncSpecialistDir returns any os.Open(dir) error. The shared syncDir helper in internal/sessions/store.go:934-936 treats an open failure as best-effort (return nil), so specialist overwrite is stricter than other atomic-write paths and can return a full failure solely because the directory handle could not be opened after the manifest was already published. Please match the sessions best-effort behavior or fold this into the post-success hygiene fix above.

  • [P3] Catastrophic 1177 rollback failure leaves the specialist off its canonical path
    internal/fsutil/replace_windows.go:214-218, internal/specialist/storage.go:106-109, and internal/specialist/manifest.go:479-480
    If ReplaceFileW returns ERROR_UNABLE_TO_MOVE_REPLACEMENT_2 and rollback cannot move the managed backup back to dst, recovery preserves the original at the opaque .zero-replace-*.backup path and dst is absent. loadDirectory only reads *.md, so the specialist is invisible to normal load/list until manual restore. writeSpecialistAtomicWith’s deferred os.Remove(tempPath) also deletes the attempted new bytes on this path, so the error text that the replacement remains at src is misleading. This is an extreme edge case and the old content is not lost, but consider a specialist-layer integration test and clearer operator guidance (or an automatic restore attempt) for this terminal state.

  • [P3] Residual symlink TOCTOU between the check and ReplaceFileW
    internal/specialist/storage.go:124-138 and internal/fsutil/replace_windows.go:78-94
    Symlink rejection is still a point-in-time os.Lstat before prepareReplaceBackup and ReplaceFileW. A concurrent same-user actor could swap the destination in that window. This is much better than the pre-PR Lstat + O_TRUNC path and replaceExistingWith re-checks at publish time, but it is the same audit class as issue #753. Optional hardening if you want TOCTOU-free symlink defense; not a regression on the P1 atomicity/DACL fixes.

@PierrunoYT

Copy link
Copy Markdown
Contributor Author

PierrunoYT addressed the committed-overwrite result semantics in 38105c0.

  • A successful ReplaceFileW is never retried or reported as failed solely because backup cleanup failed.
  • Cleanup failure is returned as a structured nonfatal outcome carrying the retained backup path/cause.
  • Specialist creation succeeds and surfaces that warning in human CLI output, JSON, and generated-tool output.
  • Post-commit directory sync/open remains best-effort.

Validation: affected CLI, specialist, and fsutil suites pass with -race; Windows packages cross-compile; formatting and diff checks pass. Ready for re-review.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 28, 2026
@PierrunoYT
PierrunoYT requested a review from jatmn July 28, 2026 14:06

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Findings

  • [P3] Align 1177 rollback-failure messaging with what the caller actually leaves on disk
    internal/fsutil/replace_windows.go:209-222 and internal/specialist/storage.go:117-120
    On failed ERROR_UNABLE_TO_MOVE_REPLACEMENT_2 rollback, recoverPartialReplace reports that the replacement remains at the temp path for caller cleanup, but writeSpecialistAtomicWith always runs os.Remove(tempPath) in its deferred cleanup before the error returns. The old manifest is not lost when the managed backup exists, but the error text is misleading for anyone trying to recover from the failure. Please either skip temp removal on these recovery paths or stop claiming the replacement survives.

  • [P3] Document the 1177 rollback-failure terminal state for operators
    internal/fsutil/replace_windows.go:216-220, internal/specialist/manifest.go:479, and internal/specialist/storage.go:80-86
    If ReplaceFileW returns 1177 and restoring the managed backup to dst exhausts its retries, the original survives only at the opaque .zero-replace-*.backup path while dst stays absent. loadDirectory ingests only *.md, so the specialist disappears from normal list/load until manual restore. This is the same extreme edge case from the prior review; the hardening is much better than before, but a short operator note in the error text or docs would help.

Optional follow-ups (not merge blockers)

  • GenerateSpecialist Meta vs Output: warnings land in text Output (matching the PR claim) but not in Meta. Only matters if a consumer reads Meta alone; no current tools.Result warnings field exists.
  • Orphan .zero-replace-*.backup siblings: repeated cleanup failures can leave multiple backups; each failure already warns once. A sweep on later writes would be nice hygiene.
  • Unix mode on --force: atomic replace publishes a new 0600 inode, whereas the old in-place O_TRUNC path preserved an existing mode on POSIX. The PR description and tests already expect 0600; this only affects manually widened permissions.

…hind

The Windows partial-replace recovery errors told an operator the replacement
"remains at" the caller's temporary path, but the only caller removes that file
in a deferred cleanup before the error ever surfaces — it named a file that was
already gone. Recovery from a failed overwrite is always about the original, so
the errors now describe only what the replace itself left on disk: the state of
the destination and of the managed backup. The replacement path is no longer a
parameter, since nothing reports it.

The one terminal state that needs a human — 1177 whose rollback exhausted its
retries — now says so outright. The original then exists only under an opaque
.zero-replace-*.backup name, and the loader reads *.md, so the specialist stays
invisible until somebody moves it back. The error names both paths and the
action; docs/SPECIALISTS.md walks through it; and loadDirectory records why the
non-.md siblings an interrupted overwrite can leave are skipped rather than
guessed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Addressed both findings in 05358e8.

[P3] Rollback-failure messaging now matches what is actually on disk. I took the second of your two options — stopped claiming the replacement survives — because the temporary file is the wrong thing to point an operator at either way: it holds the content they were trying to write, which re-running the command reproduces, while the original is the only irreplaceable copy. recoverPartialReplace no longer takes the replacement path at all (nothing reports it), and the three messages now describe only what the replace itself left behind: the state of dst and of the managed backup. writeSpecialistAtomicWith's deferred cleanup carries a comment recording that the temp never survives on any path, so this doesn't drift back.

[P3] The 1177 rollback-failure terminal state is spelled out. The error now reads:

rolling the original back failed: <cause>; <dst> no longer exists and the original survives only as <backup>, which must be moved back by hand to restore it

Three places back it up:

  • docs/SPECIALISTS.md gained a "Recovering an Interrupted Overwrite" section — what 1177 is, why the backup is invisible to zero specialist list (loader reads *.md), the Move-Item to recover, and the separate benign case of a leftover backup after a successful overwrite that is reported as a warning.
  • loadDirectory records why the two siblings an interrupted overwrite can leave (.specialist-*.tmp, .zero-replace-*.backup) are skipped rather than guessed at, and where the recovery is documented.
  • TestReplaceExistingPreserves1177BackupWhenRollbackFails now asserts the error names the destination and the instruction, and a shared assertErrorDoesNotAdvertiseReplacement helper asserts no recovery error mentions the caller's temp path — applied to both the rollback-success and rollback-failure paths so the misleading wording can't come back.

I left the three optional follow-ups alone as you flagged them non-blocking. On the Unix --force mode one: agreed it's a real behavior change, and it's the intended one here — a specialist manifest holds a system prompt, so publishing 0600 rather than inheriting a manually widened mode is the safer default.

Validation

  • go build ./..., go vet ./..., plus GOOS=linux/GOOS=windows builds and vets
  • go test ./...
  • go test ./internal/fsutil ./internal/specialist -count=2 (Windows host, so the replace_windows_test.go suite actually runs)
  • git diff --check clean; gofmt -l clean for the touched files (internal/fsutil/rename_test.go is flagged on this branch already and I left it alone)

@PierrunoYT
PierrunoYT requested a review from jatmn July 28, 2026 20:09

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/SPECIALISTS.md`:
- Around line 144-152: Update the Windows overwrite documentation near the
ERROR_UNABLE_TO_MOVE_REPLACEMENT_2 (1177) description to also cover error 1176,
including Zero’s recovery behavior and whether users need to take action. Ensure
the documented outcome matches the replacement contract for partial replacement
failures.
- Around line 162-165: Update the recovery instruction in the specialist
documentation to explicitly state that Zero’s error message includes both the
backup path and the destination path, replacing the ambiguous wording while
preserving the existing lock-closing and rename guidance.

In `@internal/specialist/manifest.go`:
- Around line 479-486: Update the explanatory comment near the specialist
manifest filtering logic to say “the two kinds of sibling files an interrupted
overwrite can leave,” correcting the malformed wording while preserving the
existing explanation about ignored recovery artifacts.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f44afbc9-8ca2-47ed-b496-3d4ef68513a8

📥 Commits

Reviewing files that changed from the base of the PR and between 38105c0 and 05358e8.

📒 Files selected for processing (5)
  • docs/SPECIALISTS.md
  • internal/fsutil/replace_windows.go
  • internal/fsutil/replace_windows_test.go
  • internal/specialist/manifest.go
  • internal/specialist/storage.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • internal/fsutil/replace_windows.go
  • internal/specialist/storage.go
  • internal/fsutil/replace_windows_test.go

Comment thread docs/SPECIALISTS.md Outdated
Comment thread docs/SPECIALISTS.md Outdated
Comment thread internal/specialist/manifest.go
jatmn
jatmn previously approved these changes Jul 29, 2026

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for this, and sorry it sat a while. The Unix half is a genuine fix: temp file plus rename(2) really does close the torn-read window from #753. I am requesting changes because the Windows half does not do what the PR says it does, and the PR says it in six places including the docs.

ReplaceFileW is not atomic for an observer

ReplaceFileW renames the destination aside, renames the replacement in, then deletes the original. Between the first two steps the destination path does not exist. I measured the bare primitive on Windows 11 / NTFS, no code from this PR involved, one writer and one observer:

ReplaceFileW: publishes=2017 failures=0 | observer stats=188450 ABSENT=13996  (7.4%)
os.Rename   : publishes=8806            | observer stats=144857 ABSENT=984    (0.68%)

Eleven times the exposure of a plain rename, with every publish succeeding. lpBackupFileName was NULL, so this is not about the managed backup this PR adds.

The MSDN line the comments lean on says ReplaceFile "combines several steps within a single function" and then enumerates those steps. A single function, not a single operation.

Why it matters here rather than being academic

loadDirectory (internal/specialist/manifest.go:465) enumerates with os.ReadDir before reading each *.md. During the window the entry is simply absent from the listing, so the specialist drops out of the load result with no warning at all rather than reaching the "skipped unreadable specialist manifest" path at :497. There is no lock anywhere in internal/specialist, and generate_tool.go writes specialists mid-session, so a single process can hit this against itself.

A crash inside the window leaves no *.md at the destination at all; the original survives only as .zero-replace-*.backup.

What I would like

  1. Correct the atomicity claims at internal/fsutil/replace_windows.go:28-29 and :59, internal/fsutil/rename.go:34, and internal/specialist/storage.go:153.
  2. Fix docs/SPECIALISTS.md:144-146. It states the Windows safety property as holding on the ordinary success path, which is where it fails. A wrong guarantee in docs is worse than no guarantee.
  3. Either accept the window and say so plainly, or use MoveFileEx with MOVEFILE_REPLACE_EXISTING, which is what os.Rename already does and what the control above measures.

Two smaller things

TestStorageCreateForceAtomicallyReplacesFile (internal/specialist/storage_test.go:99) passes verbatim against the fully reverted pre-PR code at the merge base, so on Windows it does not discriminate against the bug it exists for. It asserts final content, final mode and absence of temp files, none of which the old in-place write violated.

On Unix, --force discards the destination's mode: storage.go:112 creates via os.CreateTemp and :128 hard-sets 0600, and the Lstat at :141 feeds only the symlink check. A specialist file the user had chmod'd comes back 0600.

Happy to look again quickly once the Windows story is either fixed or described accurately. The Unix fix is worth landing either way.

@PierrunoYT
PierrunoYT requested a review from jatmn July 29, 2026 12:43
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

PierrunoYT addressed the latest Windows atomicity review in 532472f.

The maintainer's measurement is valid: ReplaceFileW combines multiple filesystem steps and can briefly leave the destination path absent to an external observer, even on a successful replacement. The implementation and documentation no longer claim otherwise.

Changes:

  • Retained ReplaceFileW specifically for destination DACL and selected-metadata preservation; it is no longer described as observer-atomic.
  • Added package-wide synchronization across Zero-managed specialist Load, Create, and Delete operations. A load in the same process cannot observe the Windows replacement gap. External processes and editors remain outside that synchronization boundary, which is now documented explicitly.
  • Renamed misleading atomic helpers and tests to describe replacement behavior instead.
  • Restored Unix overwrite behavior by preserving the existing destination permission bits rather than always publishing mode 0600.
  • Added a deterministic replacement-gap regression that verifies a concurrent managed load waits and then sees the replacement.
  • Updated the Windows 1176/1177 recovery documentation and remaining DACL-related terminology.

Validation:

  • go test ./...
  • go vet ./...
  • go test -race ./internal/specialist ./internal/fsutil under WSL/Linux
  • concurrent load regression: 100 consecutive runs
  • release build and smoke test
  • static lint: 0 issues
  • govulncheck: no vulnerabilities
  • touched-file formatting and git diff --check

Ready for CI and maintainer re-review.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/specialist/storage_test.go`:
- Around line 134-206: Run TestStorageCreateForceSerializesConcurrentLoad with
Go’s race detector and CGO enabled, using the repository’s standard test command
or an equivalent targeted command. Verify the concurrent specialistFilesMu
regression test passes without race reports before merging.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a05f4fc5-4d20-4f63-9252-6934770b23d0

📥 Commits

Reviewing files that changed from the base of the PR and between 05358e8 and 532472f.

📒 Files selected for processing (8)
  • docs/SPECIALISTS.md
  • internal/fsutil/rename.go
  • internal/fsutil/replace_windows.go
  • internal/fsutil/replace_windows_test.go
  • internal/specialist/manifest.go
  • internal/specialist/storage.go
  • internal/specialist/storage_dacl_windows_test.go
  • internal/specialist/storage_test.go
🚧 Files skipped from review as they are similar to previous changes (6)
  • docs/SPECIALISTS.md
  • internal/fsutil/rename.go
  • internal/specialist/storage_dacl_windows_test.go
  • internal/fsutil/replace_windows.go
  • internal/specialist/storage.go
  • internal/fsutil/replace_windows_test.go

Comment thread internal/specialist/storage_test.go

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. You went further than I asked and the result is better than what my review implied.

I asked you to either fix the Windows window or describe it accurately. Serializing in-process loads against mutations is the third answer and the right one: ReplaceFileW cannot be made observer-atomic, so closing the gap for the case Zero controls and saying plainly that external editors are not covered is honest in a way "we fixed it" would not have been. The docs now say exactly that, and dropping "atomic" from the names and comments means nobody re-derives the old claim from the code.

Mutation checked the part that matters: removing the RLock in Load fails TestStorageCreateForceSerializesConcurrentLoad with the loader observing the replacement gap. That is a real guarantee now, not a described one.

The Unix mode preservation and the symlink refusal I did not ask for are both good. I could not verify the mode assertion on this machine, since the production code and the test are both gated on non-Windows, so treat that one as reviewed by reading only.

Renaming TestStorageCreateForceAtomicallyReplacesFile was the right call too. It was the test whose name promised more than it checked.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. You went further than I asked and the result is better than what my review implied.

I asked you to either fix the Windows window or describe it accurately. Serializing in-process loads against mutations is the third answer and the right one: ReplaceFileW cannot be made observer-atomic, so closing the gap for the case Zero controls and saying plainly that external editors are not covered is honest in a way "we fixed it" would not have been. The docs now say exactly that, and dropping "atomic" from the names and comments means nobody re-derives the old claim from the code.

Mutation checked the part that matters: removing the RLock in Load fails TestStorageCreateForceSerializesConcurrentLoad with the loader observing the replacement gap. That is a real guarantee now, not a described one.

The Unix mode preservation and the symlink refusal I did not ask for are both good. I could not verify the mode assertion on this machine, since the production code and the test are both gated on non-Windows, so treat that one as reviewed by reading only.

Renaming TestStorageCreateForceAtomicallyReplacesFile was the right call too. It was the test whose name promised more than it checked.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator

@jatmn your approval here was dismissed by @PierrunoYT's fix push, not by anything you reviewed changing shape. This is at two of three now, so it needs you back plus one more.

Worth knowing what moved since you looked. The Windows publish is no longer described as atomic anywhere, because it is not: ReplaceFileW renames the destination aside before renaming the replacement in, so an observer can see the path absent. I measured that on NTFS at 7.4 percent of samples against 0.68 for a plain rename. Rather than claim otherwise, he serialized in-process loads against mutations with an RWMutex and documented plainly that external editors are not covered. I mutation-checked it: removing the RLock in Load fails the new concurrency test with the loader observing the gap.

Also in: Unix mode preservation on --force, a symlink refusal, and the "atomic" naming dropped from the functions and tests so the old claim cannot be re-derived from the code.

@kevincodex1 not ready for you yet, flagging so you can skip it until the approvals land.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

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.

fix(specialist): overwrite is race-prone and non-atomic

5 participants