Files
zero/internal/fsutil/replace_windows_test.go
30f6d8f591 fix(specialist): make overwrites atomic (#757)
* fix(specialist): make overwrites atomic

* fix(specialist): harden atomic replacement

* fix(specialist): replace through a Windows-atomic, DACL-preserving primitive

Addresses both P1 findings on #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.

* fix(fsutil): stop ignoring ACL merge errors, recover partial ReplaceFileW

Addresses jatmn's two #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) <[email protected]>

* fix(fsutil): preserve Windows replacement backups

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 <[email protected]>

* fix(specialist): report committed cleanup warnings

Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8

Co-authored-by: Pierre Bruno <[email protected]>

* docs(specialist): describe what a failed overwrite actually leaves behind

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) <[email protected]>

* fix(specialist): serialize managed loads during replacement

Amp-Thread-ID: https://ampcode.com/threads/T-019faf74-99d4-75cf-ac7a-661c308240ef
Co-authored-by: Amp <[email protected]>

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Co-authored-by: Amp <[email protected]>
2026-08-10 09:49:32 +08:00

560 lines
20 KiB
Go

//go:build windows
package fsutil
import (
"errors"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"golang.org/x/sys/windows"
)
// TestReplaceWithRetryPreservesDestinationCreationTime proves the publish goes
// through ReplaceFileW and not os.Rename: ReplaceFileW carries selected metadata,
// including creation time, from the destination to the replacement. A rename
// would carry the temporary file's creation time over instead.
func TestReplaceWithRetryPreservesDestinationCreationTime(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)
}
assertNoReplaceBackups(t, dir)
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)
}
assertNoReplaceBackups(t, dir)
}
// 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)
}
assertNoReplaceBackups(t, dir)
}
// 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)
}
}
// TestReplaceFileFlagsDoNotIgnoreMergeErrors is the regression test for jatmn's
// #757 P1 finding on flags: the call used to pass 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
// one set, a call lacking WRITE_DAC "succeeds but the ACLs are not preserved" —
// so passing 0x2 let a --force overwrite silently publish the temporary file's
// inherited directory DACL over a restricted specialist and expose its system
// prompt.
//
// This asserts the flag word directly rather than a live denied merge because a
// denied merge is not constructible for a file this process owns: Windows grants
// an object's owner READ_CONTROL and WRITE_DAC implicitly, so no DACL a test can
// apply to its own temp file can withhold WRITE_DAC from it. Pinning the flags is
// what actually prevents the regression — re-adding either bit fails here.
func TestReplaceFileFlagsDoNotIgnoreMergeErrors(t *testing.T) {
const (
ignoreMergeErrors = 0x00000002
ignoreACLErrors = 0x00000004
)
if replaceFileFlags&ignoreMergeErrors != 0 {
t.Error("REPLACEFILE_IGNORE_MERGE_ERRORS must not be set: it makes ReplaceFileW succeed WITHOUT preserving ACLs when it cannot obtain WRITE_DAC")
}
if replaceFileFlags&ignoreACLErrors != 0 {
t.Error("REPLACEFILE_IGNORE_ACL_ERRORS must not be set: it makes ReplaceFileW succeed WITHOUT preserving ACLs when it cannot obtain WRITE_DAC")
}
}
func TestReplaceExistingRejectsUnsupportedDACLReplacement(t *testing.T) {
for _, tc := range []struct {
name string
code syscall.Errno
}{
{name: "ERROR_INVALID_FUNCTION", code: errorInvalidFunction},
{name: "ERROR_NOT_SUPPORTED", code: errorNotSupported},
} {
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 err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
err := replaceExistingWith(src, dst, func(replaced, replacement, backup string) error {
if replaced != dst || replacement != src {
t.Fatalf("ReplaceFileW paths = (%q, %q), want (%q, %q)", replaced, replacement, dst, src)
}
if filepath.Dir(backup) != dir {
t.Fatalf("backup directory = %q, want sibling directory %q", filepath.Dir(backup), dir)
}
return tc.code
}, nil)
if !errors.Is(err, tc.code) {
t.Fatalf("error = %v, want unsupported error %v", err, tc.code)
}
if !strings.Contains(err.Error(), "DACL-preserving replacement is not supported") {
t.Fatalf("error = %v, want a DACL-preserving-replacement explanation", err)
}
assertFileContent(t, dst, "old")
assertFileContent(t, src, "new")
assertNoReplaceBackups(t, dir)
})
}
}
func TestReplaceExistingCleansManagedBackup(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 := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
var backupPath string
err := replaceExistingWith(src, dst, func(replaced, replacement, backup string) error {
backupPath = backup
if matched, matchErr := filepath.Match(replaceBackupPattern, filepath.Base(backup)); matchErr != nil || !matched {
t.Fatalf("backup path = %q, want pattern %q (match error: %v)", backup, replaceBackupPattern, matchErr)
}
if _, statErr := os.Lstat(backup); !os.IsNotExist(statErr) {
t.Fatalf("backup path must be vacant before ReplaceFileW: %v", statErr)
}
if renameErr := os.Rename(replaced, backup); renameErr != nil {
t.Fatalf("stage backup: %v", renameErr)
}
// Windows refuses to remove a read-only file. The cleanup path must clear
// that attribute without changing the backup's DACL, then remove it.
if chmodErr := os.Chmod(backup, 0o400); chmodErr != nil {
t.Fatalf("make backup read-only: %v", chmodErr)
}
return os.Rename(replacement, replaced)
}, nil)
if err != nil {
t.Fatalf("replaceExistingWith: %v", err)
}
assertFileContent(t, dst, "new")
if _, err := os.Lstat(src); !os.IsNotExist(err) {
t.Fatalf("replacement source should be consumed: %v", err)
}
if _, err := os.Lstat(backupPath); !os.IsNotExist(err) {
t.Fatalf("managed backup should be removed: %v", err)
}
assertNoReplaceBackups(t, dir)
}
func TestReplaceWithRetryDoesNotRetryCommittedReplacementWhenBackupCleanupFails(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.Fatal(err)
}
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
attempts := 0
cleanupErr := &os.PathError{Op: "remove", Path: "managed backup", Err: syscall.Errno(32)}
var backupPath string
err := ReplaceWithRetry(src, dst, func(src, dst string) error {
attempts++
return replaceExistingWithCleanup(src, dst, func(replaced, replacement, backup string) error {
backupPath = backup
if err := os.Rename(replaced, backup); err != nil {
return err
}
return os.Rename(replacement, replaced)
}, nil, func(got string) error {
if got != backupPath {
t.Fatalf("cleanup backup path = %q, want %q", got, backupPath)
}
return cleanupErr
})
})
var outcome *CommittedReplacementCleanupError
if !errors.As(err, &outcome) {
t.Fatalf("error = %v, want committed cleanup outcome", err)
}
if outcome.BackupPath != backupPath || !errors.Is(outcome, syscall.Errno(32)) {
t.Fatalf("outcome = %#v, want backup %q and sharing violation", outcome, backupPath)
}
if attempts != 1 {
t.Fatalf("replacement attempts = %d, want 1", attempts)
}
assertFileContent(t, dst, "new")
assertFileContent(t, backupPath, "old")
}
func TestReplaceExistingKeeps1176FilesAtOriginalNames(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 := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
err := replaceExistingWith(src, dst, func(_, _, _ string) error {
// With a backup name, Microsoft documents that 1176 leaves the replaced
// and replacement files under their original names.
return errorUnableToMoveReplacement
}, nil)
if !errors.Is(err, errorUnableToMoveReplacement) {
t.Fatalf("error = %v, want %v", err, errorUnableToMoveReplacement)
}
assertFileContent(t, dst, "old")
assertFileContent(t, src, "new")
assertNoReplaceBackups(t, dir)
}
func TestReplaceExistingRollsBack1177AndRetriesTransientRestore(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 := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
var backupPath string
restoreAttempts := 0
err := replaceExistingWith(src, dst, func(replaced, _, backup string) error {
backupPath = backup
if renameErr := os.Rename(replaced, backup); renameErr != nil {
t.Fatalf("stage documented 1177 backup: %v", renameErr)
}
return errorUnableToMoveReplacement2
}, func(backup, destination string) error {
restoreAttempts++
if restoreAttempts < 3 {
return &os.PathError{Op: "rename", Path: backup, Err: syscall.Errno(32)}
}
return os.Rename(backup, destination)
})
if !errors.Is(err, errorUnableToMoveReplacement2) {
t.Fatalf("error = %v, want %v", err, errorUnableToMoveReplacement2)
}
if restoreAttempts != 3 {
t.Fatalf("restore attempts = %d, want transient lock failures retried", restoreAttempts)
}
assertErrorDoesNotAdvertiseReplacement(t, err, src)
assertFileContent(t, dst, "old")
assertFileContent(t, src, "new")
if _, err := os.Lstat(backupPath); !os.IsNotExist(err) {
t.Fatalf("backup should be consumed by rollback: %v", err)
}
assertNoReplaceBackups(t, dir)
}
func TestReplaceExistingPreserves1177BackupWhenRollbackFails(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 := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
var backupPath string
replaceAttempts := 0
err := ReplaceWithRetry(src, dst, func(src, dst string) error {
replaceAttempts++
return replaceExistingWith(src, dst, func(replaced, _, backup string) error {
backupPath = backup
if renameErr := os.Rename(replaced, backup); renameErr != nil {
t.Fatalf("stage documented 1177 backup: %v", renameErr)
}
return errorUnableToMoveReplacement2
}, func(_, _ string) error {
return &os.PathError{Op: "rename", Path: backupPath, Err: syscall.Errno(32)}
})
})
if !errors.Is(err, errorUnableToMoveReplacement2) {
t.Fatalf("error = %v, want %v", err, errorUnableToMoveReplacement2)
}
if replaceAttempts != 1 {
t.Fatalf("replace attempts = %d, want no retry after a partial failure", replaceAttempts)
}
if os.IsPermission(err) || isWindowsSharingOrLockViolation(err) {
t.Fatalf("partial-failure error exposes the rollback lock error to the outer retry loop: %v", err)
}
if !strings.Contains(err.Error(), backupPath) {
t.Fatalf("error = %v, want retained backup path %q", err, backupPath)
}
// The terminal state an operator has to fix by hand: the original exists only
// under the backup name and nothing is at dst, so the error has to name both
// and say what to do, not just report the Windows code.
if !strings.Contains(err.Error(), dst) {
t.Fatalf("error = %v, want the destination %q an operator must restore to", err, dst)
}
if !strings.Contains(err.Error(), "moved back by hand") {
t.Fatalf("error = %v, want an explicit instruction to move the backup back", err)
}
assertErrorDoesNotAdvertiseReplacement(t, err, src)
if _, err := os.Lstat(dst); !os.IsNotExist(err) {
t.Fatalf("destination should remain absent after failed rollback: %v", err)
}
assertFileContent(t, backupPath, "old")
assertFileContent(t, src, "new")
}
func TestReplaceExistingRejectsSymlinkDestination(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target.md")
dst := filepath.Join(dir, "manifest.md")
src := filepath.Join(dir, ".manifest.tmp")
if err := os.WriteFile(target, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if err := os.Symlink(target, dst); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
called := false
err := replaceExistingWith(src, dst, func(_, _, _ string) error {
called = true
return nil
}, nil)
if err == nil || !strings.Contains(err.Error(), "refusing to replace symlink") {
t.Fatalf("symlink replacement error = %v", err)
}
if called {
t.Fatal("ReplaceFileW callback was called for a symlink destination")
}
assertFileContent(t, target, "old")
assertFileContent(t, src, "new")
if info, err := os.Lstat(dst); err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("destination symlink was changed: info=%v err=%v", info, err)
}
assertNoReplaceBackups(t, dir)
}
func TestRecoverPartialReplaceLeavesIntactStatesAlone(t *testing.T) {
for _, tc := range []struct {
name string
code syscall.Errno
}{
{name: "ERROR_UNABLE_TO_REMOVE_REPLACED", code: errorUnableToRemoveReplaced},
{name: "ERROR_ACCESS_DENIED", code: syscall.Errno(5)},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, ".manifest.tmp")
dst := filepath.Join(dir, "manifest.md")
backup := filepath.Join(dir, ".zero-replace-test.backup")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
if err := recoverPartialReplace(tc.code, dst, backup, nil); !errors.Is(err, tc.code) {
t.Fatalf("error = %v, want the original %v unchanged", err, tc.code)
}
assertFileContent(t, dst, "old")
assertFileContent(t, src, "new")
if _, err := os.Lstat(backup); !os.IsNotExist(err) {
t.Fatalf("unexpected backup residue: %v", err)
}
})
}
}
// assertErrorDoesNotAdvertiseReplacement guards the messaging fixed for jatmn's
// #757 P3 finding: recovery errors used to say 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. Recovery is always about the original,
// so an operator must never be sent looking for the replacement.
func assertErrorDoesNotAdvertiseReplacement(t *testing.T, err error, replacement string) {
t.Helper()
if err == nil {
t.Fatal("expected a partial-replacement error")
}
if strings.Contains(err.Error(), replacement) {
t.Fatalf("error = %v, must not point an operator at the replacement %q, which its caller deletes", err, replacement)
}
}
func assertFileContent(t *testing.T, path, want string) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile %s: %v", path, err)
}
if string(data) != want {
t.Fatalf("content of %s = %q, want %q", path, data, want)
}
}
func assertNoReplaceBackups(t *testing.T, dir string) {
t.Helper()
matches, err := filepath.Glob(filepath.Join(dir, replaceBackupPattern))
if err != nil {
t.Fatalf("Glob replacement backups: %v", err)
}
if len(matches) != 0 {
t.Fatalf("replacement backups remain: %v", matches)
}
}
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
}