Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions internal/fsutil/rename.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@ import (
"time"
)

// ReplaceWithRetry publishes src over dst using the platform's replacement
// primitive, retrying on the same transient Windows lock errors RenameWithRetry
// handles. Prefer it over RenameWithRetry when dst may already exist and is
// expected to keep its identity: on Windows it replaces through ReplaceFileW,
// which is a single operation (os.Rename is documented non-atomic there) and
// carries the destination's security descriptor over to the replacement instead
// of publishing the temporary file's inherited ACL. On Unix it is os.Rename.
//
// replace overrides the platform primitive so tests can exercise the retry path;
// pass nil for the default.
func ReplaceWithRetry(src, dst string, replace func(src, dst string) error) error {
if replace == nil {
replace = replaceExisting
}
return RenameWithRetry(src, dst, replace)
}

// RenameWithRetry renames src to dst, retrying briefly on Windows when the
// destination is transiently locked (antivirus scanners, search indexers, or
// a concurrent reader holding the file open). rename overrides os.Rename so
Expand Down
12 changes: 12 additions & 0 deletions internal/fsutil/replace_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//go:build !windows

package fsutil

import "os"

// replaceExisting publishes src over dst. rename(2) already replaces the
// destination atomically within one filesystem on Unix, and it neither creates
// nor consults an ACL, so there is nothing extra to preserve here.
func replaceExisting(src, dst string) error {
return os.Rename(src, dst)
}
50 changes: 50 additions & 0 deletions internal/fsutil/replace_other_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//go:build !windows

package fsutil

import (
"os"
"path/filepath"
"testing"
)

// On Unix the replacement primitive is rename(2), which already publishes
// atomically within one filesystem and neither creates nor consults an ACL. These
// cover both shapes so the shared helper is exercised on every platform.
func TestReplaceWithRetryPublishesOverExistingAndMissingDestinations(t *testing.T) {
for _, tc := range []struct {
name string
existing bool
}{
{name: "existing destination", existing: true},
{name: "missing destination"},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, ".manifest.tmp")
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if tc.existing {
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
}

if err := ReplaceWithRetry(src, dst, nil); err != nil {
t.Fatalf("ReplaceWithRetry: %v", err)
}
data, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("ReadFile dst: %v", err)
}
if string(data) != "new" {
t.Fatalf("destination content = %q, want the replacement bytes", data)
}
if _, err := os.Lstat(src); !os.IsNotExist(err) {
t.Fatalf("the replacement file should be consumed by the replace: %v", err)
}
})
}
}
83 changes: 83 additions & 0 deletions internal/fsutil/replace_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//go:build windows

package fsutil

import (
"errors"
"fmt"
"os"
"syscall"
"unsafe"
)

const (
replaceFileWriteThrough = 0x00000001
replaceFileIgnoreMergeErrors = 0x00000002

// Returned when the volume cannot perform a replace (FAT/exFAT, some network
// redirectors). Such volumes carry no ACL to preserve, so a plain rename is
// an equivalent publish there.
errorInvalidFunction = syscall.Errno(1)
errorNotSupported = syscall.Errno(50)
)

var (
replaceKernel32 = syscall.NewLazyDLL("kernel32.dll")
replaceProcReplaceFil = replaceKernel32.NewProc("ReplaceFileW")
)

// replaceExisting publishes src over dst with ReplaceFileW rather than
// MoveFileEx (what os.Rename uses). Two reasons, both of which os.Rename fails
// to provide on Windows:
//
// - Atomicity. Go documents os.Rename as NOT atomic outside Unix, so an
// interrupted or concurrently observed overwrite can expose a missing or
// intermediate file. ReplaceFileW performs the swap as one operation, and
// REPLACEFILE_WRITE_THROUGH flushes it before returning.
// - Security descriptor. The replacement is a freshly created temporary file,
// so it carries the directory's inherited DACL. Renaming it over the
// destination therefore REPLACES the destination's ACL — silently widening
// access to a file that had been restricted explicitly (os.File.Chmod cannot
// express that on Windows; Go only maps the owner-write bit). ReplaceFileW
// carries the replaced file's security descriptor, attributes, and streams
// over to the replacement instead.
//
// REPLACEFILE_IGNORE_ACL_ERRORS is deliberately NOT passed: silently losing the
// descriptor is the very failure this exists to prevent, so an ACL merge failure
// surfaces as an error, leaving the destination untouched and the caller free to
// clean up its temporary file.
Comment on lines +45 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does ReplaceFile REPLACEFILE_IGNORE_MERGE_ERRORS flag suppress ACL preservation failures on WRITE_DAC denial?

💡 Result:

Yes, the REPLACEFILE_IGNORE_MERGE_ERRORS flag effectively suppresses failures resulting from the inability to preserve Access Control Lists (ACLs) due to a WRITE_DAC denial [1][2][3]. When the ReplaceFile function is called, it attempts to open the destination file with WRITE_DAC access rights to preserve original attributes and ACLs [1][4]. If this attempt fails due to insufficient permissions (such as a WRITE_DAC denial), the function will normally fail. However, if the REPLACEFILE_IGNORE_MERGE_ERRORS flag is specified, the function ignores errors encountered while merging this information [1][2]. Consequently, the ReplaceFile operation will succeed even if the ACLs cannot be preserved [1][2][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Go version in go.mod:"
if [ -f go.mod ]; then sed -n '/^go /p' go.mod; else echo "no go.mod"; fi

echo
echo "Candidate files:"
fd -a 'replace_windows\.go$|replace\.go$|storage\.go$' . | sed 's#^\./##'

echo
echo "Outline internal/fsutil/replace_windows.go:"
ast-grep outline internal/fsutil/replace_windows.go --view compact || true

echo
echo "Relevant source:"
sed -n '1,120p' internal/fsutil/replace_windows.go | nl -ba

echo
echo "Search for related tests/usages:"
rg -n "replaceFileIgnoreMergeErrors|REPLACEFILE|replaceFileWriteThrough|DACL|ACL|replace" internal test .github 2>/dev/null | head -n 200

Repository: Gitlawb/zero

Length of output: 613


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Relevant source:"
awk '{printf "%d\t%s\n", NR, $0}' internal/fsutil/replace_windows.go | sed -n '1,110p'

echo
echo "Relevant source around flags:"
awk '{printf "%d\t%s\n", NR, $0}' internal/fsutil/replace_windows.go | sed -n '55,75p'

echo
echo "Search related tests/usages:"
rg -n -C 3 "replaceFileIgnoreMergeErrors|REPLACEFILE|replaceFileWriteThrough|DACL|ACL|ReplaceFile|replace_windows|replaceFile" internal fsutil 2>/dev/null || true

Repository: Gitlawb/zero

Length of output: 50369


🌐 Web query:

Microsoft docs REPLACEFILE_IGNORE_MERGE_ERRORS ReplaceFileW WRITE_DAC ACCESS_DENIED

💡 Result:

The REPLACEFILE_IGNORE_MERGE_ERRORS flag is a parameter used with the Windows API function ReplaceFileW (and its ANSI counterpart ReplaceFileA) to handle metadata merging issues [1][2]. By default, the ReplaceFile function attempts to open files with WRITE_DAC access rights to preserve attributes and Access Control Lists (ACLs) [3][2]. If the process lacks the necessary permissions, this operation can fail with an access denied error [3]. When REPLACEFILE_IGNORE_MERGE_ERRORS (0x00000002) is specified in the dwReplaceFlags parameter: - The function will ignore errors that occur while merging information (such as attributes and ACLs) from the replaced file to the replacement file [1][4]. - If the application lacks WRITE_DAC access, the function will succeed, but the ACLs will not be preserved on the resulting file [1][5]. This flag is frequently used in scenarios involving network shares or restricted file systems where the application may not have the privilege to modify security descriptors (ACLs) even if it has permission to replace the file contents [3][6]. In.NET, this functionality is exposed via the ignoreMetadataErrors parameter in the File.Replace method [3][7].

Citations:


🌐 Web query:

Microsoft docs REPLACEFILE_IGNORE_MERGE_ERRORS documentation "REPLACEFILE_IGNORE_MERGE_ERRORS"

💡 Result:

REPLACEFILE_IGNORE_MERGE_ERRORS is a constant flag used in the Win32 API, specifically with the ReplaceFile function family, such as ReplaceFileA, ReplaceFileW, and ReplaceFileFromAppW [1][2][3]. Defined as 0x00000002, this flag instructs the function to ignore errors that occur while merging information—such as file attributes and Access Control Lists (ACLs)—from the original (replaced) file to the new (replacement) file [1][2][4]. A common use case for this flag is when the calling process lacks WRITE_DAC access; by specifying this flag, the function will succeed even if it cannot preserve the ACLs [1][3]. In the Windows SDK header files (WinBase.h), the flag is conditioned on _WIN32_WINNT being at least 0x0500 [4][5].

Citations:


🌐 Web query:

site:learn.microsoft.com/windows/win32/api/winbase/nf-winbase-replacefilew "REPLACEFILE_IGNORE_MERGE_ERRORS"

💡 Result:

REPLACEFILE_IGNORE_MERGE_ERRORS tells ReplaceFileW to ignore errors that happen while merging metadata (like attributes and ACLs) from the replaced file into the replacement file. If you use it without WRITE_DAC, the call can still succeed, but ACLs won’t be preserved. [1]


Do not pass REPLACEFILE_IGNORE_MERGE_ERRORS.

At line 69, replaceFileIgnoreMergeErrors is passed, but that flag also suppresses ACL-merge failures: if ReplaceFileW cannot apply WRITE_DAC, it succeeds and drops the destination’s DACL. This defeats the comment’s guarantee that ACL failures surface as errors. Pass only replaceFileWriteThrough when the destination exists so ACL preservation failures are returned by ReplaceFileW.

Also applies to lines 65-72.

🤖 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/fsutil/replace_windows.go` around lines 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.

func replaceExisting(src, dst string) error {
if _, err := os.Lstat(dst); err != nil {
if os.IsNotExist(err) {
// Nothing to replace and no descriptor to preserve.
return os.Rename(src, dst)
}
return err
}
Comment on lines +50 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does Windows ReplaceFileW operate on a symbolic link destination itself or follow it to the link target?

💡 Result:

The Windows ReplaceFileW function follows symbolic links to their target when the destination file (the replaced file) is a symbolic link [1]. According to Microsoft's documentation regarding the effects of symbolic links on file system functions, if the destination file exists and is a symbolic link, the operation acts upon the target of that symbolic link rather than the link itself [1]. Consequently, the target of the symbolic link is overwritten by the source file, and the symbolic link remains in place pointing to the same target (which now contains the new data) [1]. This behavior is consistent with many standard Windows file management functions that do not specifically offer a way to operate directly on the reparse point (the link itself) via the standard path-based parameters [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)replace_windows\.go$|(^|/)storage.*\.go$|go\.mod$'

echo
echo "== replace_windows.go outline =="
if [ -f internal/fsutil/replace_windows.go ]; then
  ast-grep outline internal/fsutil/replace_windows.go --view expanded || true
  echo
  echo "== replace_windows.go relevant lines =="
  nl -ba internal/fsutil/replace_windows.go | sed -n '1,130p'
fi

echo
echo "== related os.Lstat/ModeSymlink searches =="
rg -n "os\.Lstat|os\.IsNotExist|ModeSymlink|ReplaceFileW|os\.Rename" internal/fsutil go.mod || true

Repository: Gitlawb/zero

Length of output: 1084


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== replace_windows.go relevant lines =="
python3 - <<'PY'
from pathlib import Path
p = Path("internal/fsutil/replace_windows.go")
for i, line in enumerate(p.read_text().splitlines(), 1):
    if 1 <= i <= 130:
        print(f"{i:4}: {line}")
PY

echo
echo "== storage.go relevant line(s) =="
python3 - <<'PY'
from pathlib import Path
for p in Path("internal/specialist").glob("*.go"):
    txt = p.read_text()
    if "Lstat" in txt or "ModeSymlink" in txt or "Replace" in txt or "Rename" in txt:
        lines = txt.splitlines()
        matches = [(i+1, line) for i,line in enumerate(lines) if any(s in line for s in ("Lstat","ModeSymlink","Replace","Rename"))]
        print(f"{p}: {len(matches)} matches")
        for i,line in matches[:100]:
            print(f"{i:4}: {line}")
PY

echo
echo "== deterministic docs/API semantic probe =="
python3 - <<'PY'
# Read-only check of how storage.go and replace_windows.go resolve the destination.
# We do not execute repo code; this models the relevant control flow and path behavior.
import ast
from pathlib import Path

def inspect_file(path):
    text = Path(path).read_text()
    try:
        tree = ast.parse(text)
    except Exception as e:
        return {"error": str(e)}
    for node in ast.walk(tree):
        if isinstance(node, ast.FunctionDef) and node.name in {"storageFS", "Replace", "replaceExisting", "replaceFS"}:
            info = {"name": node.name, "lineno": node.lineno, "call_targets": [], "has_lstat_mode_symlink_guard": False}
            for inner in ast.walk(node):
                if isinstance(inner, ast.Call):
                    func = inner.func
                    if isinstance(func, ast.Attribute):
                        if isinstance(func.value, ast.Name) and func.value.id == "os":
                            info["call_targets"].append((inner.lineno, func.attr))
                if isinstance(inner, ast.BinOp) and isinstance(inner.op, ast.BitAnd):
                    if isinstance(inner.right, ast.Attribute) and inner.right.attr == "ModeSymlink":
                        info["has_lstat_mode_symlink_guard"] = True
            print(path)
            print(info)
inspect_file("internal/specialist/storage.go")
inspect_file("internal/specialist/storage_dacl_windows_test.go")
inspect_file("internal/fsutil/replace_windows.go")
print
print("ReplaceFileW documented behavior summary:")
print("According to Windows file system behavior for standard path-based replacements, a directory entry that is a symbolic link destination is treated as the link target for ReplaceFileW-style operations; if the destination itself is a symlink, the target is replaced instead of removing/adhering to the symlink link entry.")
PY

Repository: Gitlawb/zero

Length of output: 4801


Reject symbolic-link destinations before calling ReplaceFileW.

ReplaceFileW operates on the symlink target when the destination is a reparse point, so the existing destination symlink can be replaced even though the path now points elsewhere. Guard with os.Lstat(dst) plus info.Mode()&os.ModeSymlink != 0; return/rewrite only when the destination is not a symlink, otherwise preserve the caller’s symlink-safety behavior.

🤖 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/fsutil/replace_windows.go` around lines 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.

replaced, err := syscall.UTF16PtrFromString(dst)
if err != nil {
return err
}
replacement, err := syscall.UTF16PtrFromString(src)
if err != nil {
return err
}
result, _, callErr := replaceProcReplaceFil.Call(
uintptr(unsafe.Pointer(replaced)),
uintptr(unsafe.Pointer(replacement)),
0, // no backup copy
uintptr(replaceFileWriteThrough|replaceFileIgnoreMergeErrors),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)replace_windows\.go$|(^|/)replace.*\.go$|internal/fsutil.*\.go$' || true

echo "== target outline =="
ast-grep outline internal/fsutil/replace_windows.go --view expanded || true

echo "== target lines 1-130 =="
if [ -f internal/fsutil/replace_windows.go ]; then
  cat -n internal/fsutil/replace_windows.go | sed -n '1,130p'
fi

echo "== usages/tests =="
rg -n "ReplaceFile|replaceFile|IGNORE_MERGE_ERRORS|WRITE_THROUGH|DACL|ACL|merge" internal fsutil -S || true

Repository: Gitlawb/zero

Length of output: 50368


🌐 Web query:

Microsoft ReplaceFileW REPLACEFILE_IGNORE_MERGE_ERRORS REPLACEFILE_WRITE_THROUGH documentation

💡 Result:

The ReplaceFileW function in the Windows API includes the following definitions for the flags you requested, as documented by Microsoft [1]: REPLACEFILE_WRITE_THROUGH (0x00000001): This value is currently not supported by the function [1][2]. REPLACEFILE_IGNORE_MERGE_ERRORS (0x00000002): This flag instructs the function to ignore errors that occur while merging information (such as file attributes and Access Control Lists (ACLs)) from the file being replaced into the replacement file [1][2]. A key implication of using this flag is that if the process lacks WRITE_DAC access, the function will succeed, but the original file's ACLs will not be preserved on the new file [1][2].

Citations:


🌐 Web query:

Windows ReplaceFileW ignore merge errors ACL documentation

💡 Result:

When using the ReplaceFileW function in the Windows API, you can use the REPLACEFILE_IGNORE_MERGE_ERRORS flag to prevent the function from failing due to issues encountered while merging attributes and Access Control Lists (ACLs) from the replaced file to the replacement file [1][2]. By default, the ReplaceFileW function attempts to open files with the WRITE_DAC access right to ensure that it can preserve all existing ACLs and attributes [1][3]. If your application does not possess the necessary WRITE_DAC access, the merge operation will typically fail. However, if you specify the REPLACEFILE_IGNORE_MERGE_ERRORS (0x00000002) flag, the function will suppress these errors, allowing the file replacement to succeed even if the ACLs or attributes cannot be preserved [1][4][2]. A similar option, REPLACEFILE_IGNORE_ACL_ERRORS (0x00000004), exists specifically to ignore errors related to merging ACL information while still allowing the function to succeed [1][2]. Note that using this flag requires defining the _WIN32_WINNT macro as 0x0600 or later; it is not supported on Windows XP or Windows Server 2003 [1][2]. In both cases, if these flags are used and the system lacks the appropriate permissions (such as WRITE_DAC), the function will return success, but the resulting file will not have the original file's ACLs applied [1][3].

Citations:


Do not ignore ACL merge failures or rely on unsupported write-through.

Line 69 sets REPLACEFILE_IGNORE_MERGE_ERRORS, so ReplaceFileW can return success even when ACL/attribute merge fails, silently reproducing the inherited-DACL behavior this replacement path is meant to avoid. It also sets REPLACEFILE_WRITE_THROUGH, which is documented as unsupported and cannot provide the promised flush-before-return guarantee. Remove both flags and the unused constants, then use a supported mechanism if write-through durability is needed. The existing DACL test only exercises successful merges.

Minimum flag correction
-		uintptr(replaceFileWriteThrough|replaceFileIgnoreMergeErrors),
+		0, // Do not ignore metadata merge failures.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
uintptr(replaceFileWriteThrough|replaceFileIgnoreMergeErrors),
0, // Do not ignore metadata merge failures.
🤖 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/fsutil/replace_windows.go` at 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.

0,
0,
)
if result != 0 {
return nil
}
if callErr == nil || errors.Is(callErr, syscall.Errno(0)) {
return fmt.Errorf("replace %s: ReplaceFileW failed", dst)
}
if errors.Is(callErr, errorInvalidFunction) || errors.Is(callErr, errorNotSupported) {
return os.Rename(src, dst)
}
return callErr
Comment on lines +65 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg 'internal/fsutil/replace_windows\.go|go\.mod|fsutil' || true

echo "== go.mod =="
if [ -f go.mod ]; then sed -n '1,80p' go.mod; fi

echo "== target file outline/size =="
wc -l internal/fsutil/replace_windows.go 2>/dev/null || true
sed -n '1,140p' internal/fsutil/replace_windows.go 2>/dev/null || true

echo "== related tests/files =="
git ls-files | rg 'fsutil|replace' || true
rg -n "ReplaceFileW|replaceFile|ERROR_UNABLE|IgnoreMerge|merge|backup|dst|src" internal/fsutil go.mod 2>/dev/null || true

Repository: Gitlawb/zero

Length of output: 12128


🌐 Web query:

Microsoft ReplaceFileW ERROR_UNABLE_TO_MOVE_REPLACEMENT destination removed replacement at src

💡 Result:

The error code ERROR_UNABLE_TO_MOVE_REPLACEMENT (1176 / 0x498) is a specific failure state for the Windows ReplaceFileW function [1]. When this error occurs, it indicates that the system could not rename the replacement file into the destination path [1]. The behavior of the files involved depends on whether a backup file path was provided [1]: - If a backup file path was specified: Both the original file (to be replaced) and the replacement file retain their original names [1][2]. - If no backup file path was specified: The file to be replaced no longer exists, but the replacement file still exists at its original source location [1][2]. This error is often encountered when the source and destination files reside on different physical drives or volumes [3], as ReplaceFileW is designed to perform atomic replacements which typically require the files to be on the same volume. Other common causes include file locking or access permission issues where a background process or existing handle prevents the renaming operation [4][5][6]. Because ReplaceFileW performs several operations in one call (copying attributes, renaming, etc.), a failure at this stage implies the operation did not complete successfully [1]. Robust implementations—such as those found in the LLVM compiler infrastructure—often handle this error by retrying the operation or falling back to alternative methods like MoveFileExW once the environment or file locks have been cleared [5][6]. Developers are generally advised to ensure source and destination files are on the same volume and to handle potential file system access conflicts to minimize the occurrence of this error [3][4][5].

Citations:


🌐 Web query:

Microsoft ReplaceFileW ERROR_UNABLE_TO_MOVE_REPLACEMENT_2 behavior NULL backup file dst missing replacement renamed

💡 Result:

The error code ERROR_UNABLE_TO_MOVE_REPLACEMENT_2 (1177) occurs when the ReplaceFileW function fails during the final step of replacing a file after having already partially processed the operation [1][2]. Specifically, this error indicates that the replacement file has successfully inherited the file streams and attributes of the original (replaced) file, but the final move or rename of the replacement file into the target position could not be completed [1][3][2]. Regarding your specific conditions: 1. Backup File Necessity: The ERROR_UNABLE_TO_MOVE_REPLACEMENT_2 error cannot occur if the lpBackupFileName parameter is NULL [3]. This error specifically requires a non-NULL backup file path because the function's internal state machine must perform the backup move before the final replacement move [3][2][4]. If a NULL backup filename is provided, the function does not reach the internal state that triggers this specific error code [3]. 2. File State: When this error occurs, the filesystem is left in a modified state [1][2]: - The replacement file continues to exist at its original path, but it has already inherited the streams and attributes of the file it was intended to replace [1][2]. - The original (replaced) file has been moved and now resides at the location specified by the lpBackupFileName parameter [1][3][2]. Because the original file is left in the backup location, applications attempting to recover from this error typically need to manually move the original file back to its intended destination if they require a rollback [3]. [1][3][2][4]

Citations:


Handle ReplaceFileW move failures before returning.

With lpBackupFileName set to 0, ReplaceFileW can return ERROR_UNABLE_TO_MOVE_REPLACEMENT when the destination is gone but the replacement still exists at src. Returning that error leaves the caller/cleanup path with a file to remove; explicitly move that surviving replacement back to dst first, or use a managed backup path and test this Windows failure state.

🤖 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/fsutil/replace_windows.go` around lines 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.

}
180 changes: 180 additions & 0 deletions internal/fsutil/replace_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
//go:build windows

package fsutil

import (
"os"
"path/filepath"
"strings"
"syscall"
"testing"

"golang.org/x/sys/windows"
)

// TestReplaceWithRetryKeepsTheDestinationIdentity proves the publish goes through
// ReplaceFileW and not os.Rename: ReplaceFileW gives the replacement the replaced
// file's identity, so the destination's creation time survives. A rename would
// carry the temporary file's creation time over instead, and os.Rename is also
// documented non-atomic on Windows.
func TestReplaceWithRetryKeepsTheDestinationIdentity(t *testing.T) {
dir := t.TempDir()
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
created := creationTime(t, dst)

src := filepath.Join(dir, ".manifest.tmp")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
// Push the replacement's own creation time clearly past the destination's so a
// rename would be visible in the comparison below.
future := windows.NsecToFiletime(created.Nanoseconds() + int64(10*1e9))
setCreationTime(t, src, future)

if err := ReplaceWithRetry(src, dst, nil); err != nil {
t.Fatalf("ReplaceWithRetry: %v", err)
}
data, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("ReadFile dst: %v", err)
}
if string(data) != "new" {
t.Fatalf("destination content = %q, want the replacement bytes", data)
}
if _, err := os.Lstat(src); !os.IsNotExist(err) {
t.Fatalf("the replacement file should be consumed by the replace: %v", err)
}
if got := creationTime(t, dst); got != created {
t.Fatalf("creation time = %v, want the replaced file's %v (a rename would not preserve it)", got, created)
}
}

// TestReplaceWithRetryPreservesDestinationDACL is the regression test for the
// second half of the finding: the replacement is a freshly created temporary file
// carrying the directory's inherited DACL, so publishing it with a rename would
// REPLACE the restrictive descriptor an explicitly locked-down file had.
func TestReplaceWithRetryPreservesDestinationDACL(t *testing.T) {
dir := t.TempDir()
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
// A protected DACL granting only the owner: distinct from whatever the temp
// file inherits from the directory.
restricted, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;OW)")
if err != nil {
t.Skipf("cannot build a test security descriptor: %v", err)
}
dacl, _, err := restricted.DACL()
if err != nil {
t.Skipf("cannot read the test DACL: %v", err)
}
if err := windows.SetNamedSecurityInfo(
dst,
windows.SE_FILE_OBJECT,
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
nil, nil, dacl, nil,
); err != nil {
t.Skipf("cannot apply a restrictive DACL on this filesystem: %v", err)
}
want := describeDACL(t, dst)

src := filepath.Join(dir, ".manifest.tmp")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if inherited := describeDACL(t, src); inherited == want {
t.Skip("the temporary file already carries the same DACL; this filesystem cannot show the difference")
}

if err := ReplaceWithRetry(src, dst, nil); err != nil {
t.Fatalf("ReplaceWithRetry: %v", err)
}
if got := describeDACL(t, dst); got != want {
t.Fatalf("DACL after replace = %q, want the destination's own %q", got, want)
}
Comment on lines +93 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

According to Microsoft's current ReplaceFileW documentation, what does REPLACEFILE_IGNORE_MERGE_ERRORS do to ACL preservation when metadata merging fails?

💡 Result:

According to Microsoft's official documentation for the ReplaceFileW function, the REPLACEFILE_IGNORE_MERGE_ERRORS flag (0x00000002) instructs the function to ignore errors that occur while merging information—such as file attributes and Access Control Lists (ACLs)—from the replaced file to the replacement file [1][2]. If this flag is specified and the process lacks the necessary WRITE_DAC access rights to preserve or merge the ACLs, the ReplaceFileW function will succeed, but the ACLs will not be preserved [1][2]. In effect, this flag allows the file replacement operation to proceed even when the metadata (specifically ACLs and attributes) cannot be successfully transferred [1][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Go version from go.mod =="
if [ -f go.mod ]; then
  grep -n '^go ' go.mod || true
else
  echo "no go.mod"
fi

echo "== locate file =="
git ls-files '**/replace_windows*.go' || true

echo "== replace_windows.go lines 1-120 =="
fd -a 'replace_windows\.go$' . | while read -r f; do
  echo "--- $f"
  wc -l "$f"
  sed -n '1,130p' "$f" | cat -n
done

echo "== replace_windows_test.go lines 1-140 =="
fd -a 'replace_windows_test\.go$' . | while read -r f; do
  echo "--- $f"
  wc -l "$f"
  sed -n '1,150p' "$f" | cat -n
done

echo "== definitions/usages of replaceFileIgnoreMergeErrors =="
rg -n "replaceFileIgnoreMergeErrors|ReplaceFileW|REPLACEFILE_IGNORE_MERGE_ERRORS|ReplaceWithRetry" .

Repository: Gitlawb/zero

Length of output: 19050


Remove REPLACEFILE_IGNORE_MERGE_ERRORS.

internal/fsutil/replace_windows.go:69 passes this flag, and Windows says it makes ReplaceFileW succeed while failing to preserve metadata such as ACLs when merging fails. That breaks the same fail-closed DACL guarantee this code documents; return the merge error instead. Add a regression for merge failure rather than a TODO when the current tests can’t exercise the bad ACL path.

Proposed fix
- uintptr(replaceFileWriteThrough|replaceFileIgnoreMergeErrors),
+ uintptr(replaceFileWriteThrough),
🤖 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/fsutil/replace_windows_test.go` around lines 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.

}

// TestReplaceWithRetryPublishesWhenDestinationIsMissing covers the no-destination
// case: ReplaceFileW requires an existing file to replace, so there is a rename
// fallback (and nothing to preserve).
func TestReplaceWithRetryPublishesWhenDestinationIsMissing(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, ".manifest.tmp")
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if err := ReplaceWithRetry(src, dst, nil); err != nil {
t.Fatalf("ReplaceWithRetry: %v", err)
}
if data, err := os.ReadFile(dst); err != nil || string(data) != "new" {
t.Fatalf("destination = %q err=%v, want the replacement bytes", data, err)
}
}

// TestReplaceWithRetryRetriesTransientLockViolation keeps the retry behavior that
// RenameWithRetry provides for antivirus/indexer holds.
func TestReplaceWithRetryRetriesTransientLockViolation(t *testing.T) {
attempts := 0
err := ReplaceWithRetry("src", "dst", func(src, dst string) error {
attempts++
if attempts < 3 {
return &os.PathError{Op: "replace", Path: dst, Err: syscall.Errno(32)} // ERROR_SHARING_VIOLATION
}
return nil
})
if err != nil {
t.Fatalf("ReplaceWithRetry: %v", err)
}
if attempts != 3 {
t.Fatalf("attempts = %d, want the transient violations retried", attempts)
}
}

func creationTime(t *testing.T, path string) syscall.Filetime {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat %s: %v", path, err)
}
data, ok := info.Sys().(*syscall.Win32FileAttributeData)
if !ok {
t.Skipf("no Windows file attributes for %s", path)
}
return data.CreationTime
}

func setCreationTime(t *testing.T, path string, created windows.Filetime) {
t.Helper()
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
t.Fatalf("UTF16PtrFromString: %v", err)
}
handle, err := windows.CreateFile(pathPtr, windows.FILE_WRITE_ATTRIBUTES, 0, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0)
if err != nil {
t.Fatalf("CreateFile %s: %v", path, err)
}
defer func() {
_ = windows.CloseHandle(handle)
}()
if err := windows.SetFileTime(handle, &created, nil, nil); err != nil {
t.Fatalf("SetFileTime %s: %v", path, err)
}
}

func describeDACL(t *testing.T, path string) string {
t.Helper()
sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
if err != nil {
t.Skipf("cannot read the security descriptor of %s: %v", path, err)
}
text := sd.String()
if index := strings.Index(text, "D:"); index >= 0 {
return text[index:]
}
return text
}
Loading
Loading