225 lines
8.3 KiB
Go
225 lines
8.3 KiB
Go
//go:build windows
|
|
|
|
package safeio
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/aritmolab/thothii/tools/thothctl/internal/testsupport"
|
|
"golang.org/x/sys/windows"
|
|
)
|
|
|
|
const expectedWindowsRetainedHandleShareMode = windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE
|
|
const expectedWindowsOutputHandleShareMode = 0
|
|
|
|
// Keep this contract compile-enforced so Windows cross-test compilation catches a future
|
|
// FILE_SHARE_DELETE regression even when the tests are compiled on a non-Windows host.
|
|
var _ [windowsRetainedHandleShareMode - expectedWindowsRetainedHandleShareMode]struct{}
|
|
var _ [expectedWindowsRetainedHandleShareMode - windowsRetainedHandleShareMode]struct{}
|
|
var _ [windowsOutputHandleShareMode - expectedWindowsOutputHandleShareMode]struct{}
|
|
var _ [expectedWindowsOutputHandleShareMode - windowsOutputHandleShareMode]struct{}
|
|
|
|
func TestValidateCanonicalPathRejectsWindowsNamespacesAndAlternateStreams(t *testing.T) {
|
|
for _, path := range []string{`C:\dir\existing.txt:candidate`, `\\?\C:\dir\candidate`, `\\.\pipe\candidate`, `\Device\HarddiskVolume1\candidate`, `\??\C:\candidate`, `C:\dir\NUL`, `C:\dir\nul.txt`, `C:\dir\COM1`, `C:\dir\LPT9.log`, `C:\dir\CONIN$`, `C:\dir\candidate.yaml.`, `C:\dir\candidate.yaml `} {
|
|
if err := ValidateCanonicalPath(path); !errors.Is(err, ErrUnsafeFile) {
|
|
t.Errorf("ValidateCanonicalPath(%q) = %v, want ErrUnsafeFile", path, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpenWindowsComponentBlocksMutationWhileHandleIsRetained(t *testing.T) {
|
|
t.Run("parent rename", func(t *testing.T) {
|
|
parent := filepath.Join(t.TempDir(), "parent")
|
|
if err := os.Mkdir(parent, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(parent, "secret"), []byte("secret"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
handle, err := openWindowsComponent(parent, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
renamed := parent + "-renamed"
|
|
if err := os.Rename(parent, renamed); err == nil {
|
|
windows.CloseHandle(handle)
|
|
t.Fatal("parent rename succeeded while its safe-I/O handle was retained")
|
|
}
|
|
if err := windows.CloseHandle(handle); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Rename(parent, renamed); err != nil {
|
|
t.Fatalf("parent rename after closing its safe-I/O handle: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("final delete", func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "secret")
|
|
if err := os.WriteFile(path, []byte("secret"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
handle, err := openWindowsComponent(path, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Remove(path); err == nil {
|
|
windows.CloseHandle(handle)
|
|
t.Fatal("final-file deletion succeeded while its safe-I/O handle was retained")
|
|
}
|
|
if err := windows.CloseHandle(handle); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Remove(path); err != nil {
|
|
t.Fatalf("final-file deletion after closing its safe-I/O handle: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestWindowsStageHandleDeniesReadRenameDeleteAndHardlink(t *testing.T) {
|
|
root := filepath.Join(t.TempDir(), "parent")
|
|
if err := os.Mkdir(root, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
securityDescriptor, securityAttributes, err := ownerOnlySecurityAttributes()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = securityDescriptor
|
|
stagePath := filepath.Join(root, ".thothctl-candidate-test")
|
|
h, err := windows.CreateFile(windows.StringToUTF16Ptr(stagePath), windows.GENERIC_WRITE|windows.DELETE, 0, securityAttributes, windows.CREATE_NEW, windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
closed := false
|
|
defer func() {
|
|
if !closed {
|
|
_ = windows.CloseHandle(h)
|
|
}
|
|
}()
|
|
if _, err := windows.CreateFile(windows.StringToUTF16Ptr(stagePath), windows.GENERIC_READ, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0); err == nil {
|
|
t.Fatal("stage read succeeded while zero-share handle was open")
|
|
}
|
|
if err := os.Rename(stagePath, stagePath+"-renamed"); err == nil {
|
|
t.Fatal("stage rename succeeded while handle was open")
|
|
}
|
|
if err := os.Link(stagePath, filepath.Join(root, "stolen")); err == nil {
|
|
t.Fatal("stage hardlink succeeded while handle was open")
|
|
}
|
|
if err := os.Remove(stagePath); err == nil {
|
|
t.Fatal("stage delete succeeded while handle was open")
|
|
}
|
|
if err := deleteWindowsHandle(h); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := windows.CloseHandle(h); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
closed = true
|
|
if _, err := os.Stat(stagePath); !os.IsNotExist(err) {
|
|
t.Fatalf("disposed stage remains: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriteCanonicalExclusiveClassifiesDispositionFailureAsIndeterminate(t *testing.T) {
|
|
root := t.TempDir()
|
|
path := filepath.Join(root, "candidate.yaml")
|
|
if err := os.WriteFile(path, []byte("existing"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
oldDelete, oldClose := windowsDeleteHandle, windowsCloseStage
|
|
t.Cleanup(func() { windowsDeleteHandle, windowsCloseStage = oldDelete, oldClose })
|
|
windowsDeleteHandle = func(windows.Handle) error { return errors.New("injected disposition failure") }
|
|
if err := writeCanonicalExclusive(path, []byte("candidate"), 0o600); !errors.Is(err, ErrIndeterminateFile) {
|
|
t.Fatalf("disposition failure = %v, want ErrIndeterminateFile", err)
|
|
}
|
|
}
|
|
|
|
func TestWriteCanonicalExclusiveClassifiesCloseFailureAsIndeterminate(t *testing.T) {
|
|
root := t.TempDir()
|
|
path := filepath.Join(root, "candidate.yaml")
|
|
if err := os.WriteFile(path, []byte("existing"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
oldDelete, oldClose := windowsDeleteHandle, windowsCloseStage
|
|
t.Cleanup(func() { windowsDeleteHandle, windowsCloseStage = oldDelete, oldClose })
|
|
windowsCloseStage = func(*os.File) error { return errors.New("injected close failure") }
|
|
if err := writeCanonicalExclusive(path, []byte("candidate"), 0o600); !errors.Is(err, ErrIndeterminateFile) {
|
|
t.Fatalf("close failure = %v, want ErrIndeterminateFile", err)
|
|
}
|
|
}
|
|
|
|
func TestWriteCanonicalExclusiveRequiresRestrictiveMode(t *testing.T) {
|
|
if err := writeCanonicalExclusive(`C:\\tmp\\thothctl-output.yaml`, []byte("x"), 0o640); err == nil {
|
|
t.Fatal("accepted non-restrictive output mode")
|
|
}
|
|
}
|
|
|
|
func TestWriteCanonicalExclusiveCreatesProtectedOwnerOnlyDACL(t *testing.T) {
|
|
root, err := filepath.EvalSymlinks(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
path := filepath.Join(root, "candidate.yaml")
|
|
if err := writeCanonicalExclusive(path, []byte("x"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
control, _, err := sd.Control()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if control&windows.SE_DACL_PROTECTED == 0 {
|
|
t.Fatalf("output DACL control = %#x, want protected", control)
|
|
}
|
|
acl, _, err := sd.DACL()
|
|
if err != nil || acl == nil || acl.AceCount != 1 {
|
|
t.Fatalf("output DACL = %#v, err=%v; want one owner ACE", acl, err)
|
|
}
|
|
}
|
|
|
|
func TestWriteCanonicalExclusiveAllowsOwnerOnlyWriteAndIdentityRecheck(t *testing.T) {
|
|
root, err := filepath.EvalSymlinks(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
path := filepath.Join(root, "candidate.yaml")
|
|
if err := writeCanonicalExclusive(path, []byte("candidates: []\n"), 0o600); err != nil {
|
|
t.Fatalf("owner-only output write/recheck failed: %v", err)
|
|
}
|
|
contents, err := os.ReadFile(path)
|
|
if err != nil || string(contents) != "candidates: []\n" {
|
|
t.Fatalf("output = %q, err=%v", contents, err)
|
|
}
|
|
if err := writeCanonicalExclusive(path, []byte("replacement\n"), 0o600); !errors.Is(err, ErrUnsafeFile) {
|
|
t.Fatalf("existing output replacement = %v, want ErrUnsafeFile", err)
|
|
}
|
|
}
|
|
|
|
func TestWriteCanonicalExclusiveRejectsReparseParent(t *testing.T) {
|
|
root, err := filepath.EvalSymlinks(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
realParent := filepath.Join(root, "real-parent")
|
|
if err := os.Mkdir(realParent, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
linkedParent := filepath.Join(root, "linked-parent")
|
|
testsupport.SymlinkOrSkip(t, realParent, linkedParent)
|
|
path := filepath.Join(linkedParent, "candidate.yaml")
|
|
if err := writeCanonicalExclusive(path, []byte("unsafe\n"), 0o600); !errors.Is(err, ErrUnsafeFile) {
|
|
t.Fatalf("reparse parent output = %v, want ErrUnsafeFile", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(realParent, "candidate.yaml")); !os.IsNotExist(err) {
|
|
t.Fatalf("reparse parent write created target: stat err=%v", err)
|
|
}
|
|
}
|