-
Notifications
You must be signed in to change notification settings - Fork 119
fix(specialist): make overwrites atomic #757
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c0ba649
c346ba3
ce0b070
c26fe90
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| } |
| 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) | ||
| } | ||
| }) | ||
| } | ||
| } |
| 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. | ||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 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 || trueRepository: 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.")
PYRepository: Gitlawb/zero Length of output: 4801 Reject symbolic-link destinations before calling
🤖 Prompt for AI Agents |
||||||
| 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), | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 || trueRepository: Gitlawb/zero Length of output: 50368 🌐 Web query:
💡 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:
💡 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 Minimum flag correction- uintptr(replaceFileWriteThrough|replaceFileIgnoreMergeErrors),
+ 0, // Do not ignore metadata merge failures.📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 || trueRepository: Gitlawb/zero Length of output: 12128 🌐 Web query:
💡 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:
💡 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 With 🤖 Prompt for AI Agents |
||||||
| } | ||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 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
Proposed fix- uintptr(replaceFileWriteThrough|replaceFileIgnoreMergeErrors),
+ uintptr(replaceFileWriteThrough),🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| // 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 | ||
| } | ||
There was a problem hiding this comment.
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:
Repository: Gitlawb/zero
Length of output: 613
🏁 Script executed:
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_ERRORStellsReplaceFileWto ignore errors that happen while merging metadata (like attributes and ACLs) from the replaced file into the replacement file. If you use it withoutWRITE_DAC, the call can still succeed, but ACLs won’t be preserved. [1]Do not pass
REPLACEFILE_IGNORE_MERGE_ERRORS.At line 69,
replaceFileIgnoreMergeErrorsis passed, but that flag also suppresses ACL-merge failures: ifReplaceFileWcannot applyWRITE_DAC, it succeeds and drops the destination’s DACL. This defeats the comment’s guarantee that ACL failures surface as errors. Pass onlyreplaceFileWriteThroughwhen the destination exists so ACL preservation failures are returned byReplaceFileW.Also applies to lines 65-72.
🤖 Prompt for AI Agents