diff --git a/cmd/dbc/registry_test.go b/cmd/dbc/registry_test.go index d4233dee..ab368e1c 100644 --- a/cmd/dbc/registry_test.go +++ b/cmd/dbc/registry_test.go @@ -27,13 +27,15 @@ import ( tea "charm.land/bubbletea/v2" "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/winregroot" "github.com/stretchr/testify/suite" "golang.org/x/sys/windows/registry" ) // This test suite is only run when the "test_registry" build tag is set. -// Only run these tests if you're able to modify the windows registry and won't be broken -// if the ADBC registry keys are cleared/modified/etc. +// The Windows test main sets dbc's User/System registry roots and driver roots +// to process-private temporary fixtures. These tests do not exercise real +// Program Files permissions or the machine registry's ACLs. type RegistryTestSuite struct { suite.Suite @@ -62,7 +64,7 @@ func (s *RegistryTestSuite) run(m tea.Model) string { func (s *RegistryTestSuite) clearRegistry() { // Clear out any existing ADBC registry keys to ensure a clean slate. - k, err := registry.OpenKey(registry.CURRENT_USER, "SOFTWARE\\ADBC\\Drivers", registry.ALL_ACCESS) + k, err := registry.OpenKey(winregroot.UserRoot(), "SOFTWARE\\ADBC\\Drivers", registry.ALL_ACCESS) if errors.Is(err, registry.ErrNotExist) { return } @@ -100,7 +102,7 @@ func (s *RegistryTestSuite) TestInstallDriver() { out := s.run(m) s.Equal("\nInstalled test-driver-1 1.1.0 to "+s.cfgUserPath, out) - k, err := registry.OpenKey(registry.CURRENT_USER, "SOFTWARE\\ADBC\\Drivers\\test-driver-1", registry.READ) + k, err := registry.OpenKey(winregroot.UserRoot(), "SOFTWARE\\ADBC\\Drivers\\test-driver-1", registry.READ) s.Require().NoError(err) defer k.Close() diff --git a/cmd/dbc/subcommand_windows_test.go b/cmd/dbc/subcommand_windows_test.go index 8a7909b8..3e3ed158 100644 --- a/cmd/dbc/subcommand_windows_test.go +++ b/cmd/dbc/subcommand_windows_test.go @@ -22,20 +22,21 @@ import ( "os" "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/winregroot" "golang.org/x/sys/windows/registry" ) func (suite *SubcommandTestSuite) TearDownTest() { - // Clean up the registry and filesystem after each test + // Clean up the process-private registry and filesystem fixtures after each test. _, user := os.LookupEnv("DBC_TEST_LEVEL_USER") _, system := os.LookupEnv("DBC_TEST_LEVEL_SYSTEM") if user { - suite.Require().NoError(deleteRegistryKeyRecursive(registry.CURRENT_USER, "SOFTWARE\\ADBC\\Drivers")) + suite.Require().NoError(deleteRegistryKeyRecursive(winregroot.UserRoot(), "SOFTWARE\\ADBC\\Drivers")) suite.Require().NoError(os.RemoveAll(config.Get()[config.ConfigUser].Location)) } if system { - suite.Require().NoError(deleteRegistryKeyRecursive(registry.LOCAL_MACHINE, "SOFTWARE\\ADBC\\Drivers")) + suite.Require().NoError(deleteRegistryKeyRecursive(winregroot.SystemRoot(), "SOFTWARE\\ADBC\\Drivers")) suite.Require().NoError(os.RemoveAll(config.Get()[config.ConfigSystem].Location)) } } diff --git a/cmd/dbc/test_isolation_windows_test.go b/cmd/dbc/test_isolation_windows_test.go new file mode 100644 index 00000000..2684173a --- /dev/null +++ b/cmd/dbc/test_isolation_windows_test.go @@ -0,0 +1,275 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package main + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/winregroot" + "github.com/columnar-tech/dbc/internal/wintest" + "golang.org/x/sys/windows/registry" +) + +const ( + windowsTestIsolationChildEnv = "DBC_WINDOWS_TEST_ISOLATION_CHILD" + windowsTestIsolationUserEnv = "DBC_WINDOWS_TEST_ISOLATION_PARENT_USER" + windowsTestIsolationSysEnv = "DBC_WINDOWS_TEST_ISOLATION_PARENT_SYSTEM" + windowsTestIsolationDriverID = "dbc-process-isolation-sentinel" + windowsTestIsolationRelative = ".dbc-package-g-process-isolation/driver.dll" +) + +func TestMain(m *testing.M) { + isolation, err := wintest.Setup() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "set up isolated Windows CLI tests: %v\n", err) + os.Exit(1) + } + if err := verifyWindowsCLIConfigRoots(isolation); err != nil { + cleanupErr := isolation.Cleanup() + _, _ = fmt.Fprintf(os.Stderr, "verify isolated Windows CLI roots: %v\n", errors.Join(err, cleanupErr)) + os.Exit(1) + } + + code := m.Run() + if err := isolation.Cleanup(); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "clean up isolated Windows CLI tests: %v\n", err) + code = 1 + } + os.Exit(code) +} + +func verifyWindowsCLIConfigRoots(isolation *wintest.Isolation) error { + if got, want := filepath.Clean(config.ConfigUser.ConfigLocation()), filepath.Clean(isolation.UserRoot()); !strings.EqualFold(got, want) { + return fmt.Errorf("ConfigUser root = %q, want private root %q", got, want) + } + if got, want := filepath.Clean(config.ConfigSystem.ConfigLocation()), filepath.Clean(isolation.SystemRoot()); !strings.EqualFold(got, want) { + return fmt.Errorf("ConfigSystem root = %q, want private root %q", got, want) + } + return nil +} + +func TestWindowsTestIsolationAcrossProcesses(t *testing.T) { + if os.Getenv(windowsTestIsolationChildEnv) == "1" { + runWindowsTestIsolationChild(t) + return + } + + parentUserRoot := config.ConfigUser.ConfigLocation() + parentSystemRoot := config.ConfigSystem.ConfigLocation() + parentUserPath := windowsIsolationPayload(parentUserRoot) + parentSystemPath := windowsIsolationPayload(parentSystemRoot) + parentUserBytes := []byte("parent user registry and payload sentinel") + parentSystemBytes := []byte("parent system registry and payload sentinel") + writeIsolationPayload(t, parentUserPath, parentUserBytes) + writeIsolationPayload(t, parentSystemPath, parentSystemBytes) + writeIsolationRegistration(t, winregroot.UserRoot(), parentUserPath) + writeIsolationRegistration(t, winregroot.SystemRoot(), parentSystemPath) + t.Setenv("ADBC_DRIVER_PATH", parentUserRoot) + t.Setenv("VIRTUAL_ENV", parentSystemRoot) + t.Setenv("CONDA_PREFIX", parentSystemRoot) + t.Cleanup(func() { + var cleanupErr error + cleanupErr = errors.Join(cleanupErr, deleteRegistryKeyRecursive(winregroot.UserRoot(), "SOFTWARE\\ADBC\\Drivers\\"+windowsTestIsolationDriverID)) + cleanupErr = errors.Join(cleanupErr, deleteRegistryKeyRecursive(winregroot.SystemRoot(), "SOFTWARE\\ADBC\\Drivers\\"+windowsTestIsolationDriverID)) + cleanupErr = errors.Join(cleanupErr, os.RemoveAll(filepath.Dir(parentUserPath))) + cleanupErr = errors.Join(cleanupErr, os.RemoveAll(filepath.Dir(parentSystemPath))) + if cleanupErr != nil { + t.Errorf("remove parent isolation sentinels: %v", cleanupErr) + } + }) + + child := exec.Command(os.Args[0], "-test.run=^TestWindowsTestIsolationAcrossProcesses$") + child.Env = append(os.Environ(), + windowsTestIsolationChildEnv+"=1", + windowsTestIsolationUserEnv+"="+parentUserRoot, + windowsTestIsolationSysEnv+"="+parentSystemRoot, + ) + output, err := child.CombinedOutput() + if err != nil { + t.Fatalf("isolated child process failed: %v\n%s", err, output) + } + + assertIsolationPayload(t, parentUserPath, parentUserBytes) + assertIsolationPayload(t, parentSystemPath, parentSystemBytes) + assertIsolationRegistration(t, winregroot.UserRoot(), parentUserPath) + assertIsolationRegistration(t, winregroot.SystemRoot(), parentSystemPath) +} + +func runWindowsTestIsolationChild(t *testing.T) { + for _, name := range []string{"ADBC_DRIVER_PATH", "VIRTUAL_ENV", "CONDA_PREFIX"} { + if value, present := os.LookupEnv(name); present { + t.Fatalf("child inherited unsafe %s value %q", name, value) + } + } + assertLocalHTTPRoundTrip(t) + + parentUserRoot := os.Getenv(windowsTestIsolationUserEnv) + parentSystemRoot := os.Getenv(windowsTestIsolationSysEnv) + childUserRoot := config.ConfigUser.ConfigLocation() + childSystemRoot := config.ConfigSystem.ConfigLocation() + if sameWindowsPath(childUserRoot, parentUserRoot) || sameWindowsPath(childSystemRoot, parentSystemRoot) { + t.Fatalf("child fixture roots are not private: User=%q parent=%q; System=%q parent=%q", childUserRoot, parentUserRoot, childSystemRoot, parentSystemRoot) + } + + childUserPath := windowsIsolationPayload(childUserRoot) + childSystemPath := windowsIsolationPayload(childSystemRoot) + writeIsolationPayload(t, childUserPath, []byte("child user payload")) + writeIsolationPayload(t, childSystemPath, []byte("child system payload")) + writeIsolationRegistration(t, winregroot.UserRoot(), childUserPath) + writeIsolationRegistration(t, winregroot.SystemRoot(), childSystemPath) + + if err := deleteRegistryKeyRecursive(winregroot.UserRoot(), "SOFTWARE\\ADBC\\Drivers"); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(childUserRoot); err != nil { + t.Fatalf("remove child User driver root: %v", err) + } + assertIsolationRegistrationRemoved(t, winregroot.UserRoot()) + assertIsolationPathRemoved(t, childUserRoot) + assertIsolationPayload(t, childSystemPath, []byte("child system payload")) + assertIsolationRegistration(t, winregroot.SystemRoot(), childSystemPath) + + if err := deleteRegistryKeyRecursive(winregroot.SystemRoot(), "SOFTWARE\\ADBC\\Drivers"); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(childSystemRoot); err != nil { + t.Fatalf("remove child System driver root: %v", err) + } + assertIsolationRegistrationRemoved(t, winregroot.SystemRoot()) + assertIsolationPathRemoved(t, childSystemRoot) +} + +func assertLocalHTTPRoundTrip(t *testing.T) { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "local loopback works") + })) + defer server.Close() + + client := server.Client() + client.Timeout = 5 * time.Second + response, err := client.Get(server.URL) + if err != nil { + t.Fatalf("GET local httptest server: %v", err) + } + body, readErr := io.ReadAll(response.Body) + closeErr := response.Body.Close() + if err := errors.Join(readErr, closeErr); err != nil { + t.Fatalf("read local httptest response: %v", err) + } + if response.StatusCode != http.StatusOK || string(body) != "local loopback works" { + t.Fatalf("local HTTP response = %d %q", response.StatusCode, body) + } +} + +func windowsIsolationPayload(root string) string { + return filepath.Join(root, filepath.FromSlash(windowsTestIsolationRelative)) +} + +func writeIsolationPayload(t *testing.T, path string, contents []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, contents, 0o600); err != nil { + t.Fatal(err) + } +} + +func writeIsolationRegistration(t *testing.T, root registry.Key, driver string) { + t.Helper() + key, _, err := registry.CreateKey(root, "SOFTWARE\\ADBC\\Drivers\\"+windowsTestIsolationDriverID, registry.ALL_ACCESS) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := key.Close(); err != nil { + t.Errorf("close isolation registration: %v", err) + } + }() + for name, value := range map[string]string{ + "driver": driver, + "name": "Process Isolation Sentinel", + "version": "1.0.0", + } { + if err := key.SetStringValue(name, value); err != nil { + t.Fatal(err) + } + } +} + +func assertIsolationRegistration(t *testing.T, root registry.Key, want string) { + t.Helper() + key, err := registry.OpenKey(root, "SOFTWARE\\ADBC\\Drivers\\"+windowsTestIsolationDriverID, registry.READ) + if err != nil { + t.Fatal(err) + } + got, _, readErr := key.GetStringValue("driver") + closeErr := key.Close() + if err := errors.Join(readErr, closeErr); err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("isolation registration driver = %q, want %q", got, want) + } +} + +func assertIsolationRegistrationRemoved(t *testing.T, root registry.Key) { + t.Helper() + key, err := registry.OpenKey(root, "SOFTWARE\\ADBC\\Drivers\\"+windowsTestIsolationDriverID, registry.READ) + if errors.Is(err, registry.ErrNotExist) { + return + } + if err != nil { + t.Fatalf("check removed isolation registration: %v", err) + } + closeErr := key.Close() + t.Fatalf("isolation registration remains after cleanup (close error: %v)", closeErr) +} + +func assertIsolationPayload(t *testing.T, path string, want []byte) { + t.Helper() + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read isolation payload %q: %v", path, err) + } + if string(got) != string(want) { + t.Fatalf("isolation payload %q = %q, want %q", path, got, want) + } +} + +func assertIsolationPathRemoved(t *testing.T, path string) { + t.Helper() + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("isolation path %q remains after cleanup or could not be checked: %v", path, err) + } +} + +func sameWindowsPath(left, right string) bool { + return strings.EqualFold(filepath.Clean(left), filepath.Clean(right)) +} diff --git a/config/dirs_windows.go b/config/dirs_windows.go index 5805fee5..a20b5ff7 100644 --- a/config/dirs_windows.go +++ b/config/dirs_windows.go @@ -24,6 +24,8 @@ import ( "slices" "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc/internal/systempath" + "github.com/columnar-tech/dbc/internal/winregroot" "golang.org/x/sys/windows/registry" ) @@ -39,9 +41,9 @@ func init() { func (c ConfigLevel) key() registry.Key { switch c { case ConfigSystem: - return registry.LOCAL_MACHINE + return winregroot.SystemRoot() case ConfigUser: - return registry.CURRENT_USER + return winregroot.UserRoot() default: return 0 } @@ -62,7 +64,7 @@ func (c ConfigLevel) ConfigLocation() string { var prefix string switch c { case ConfigSystem: - prefix = "C:\\Program Files" + prefix = systempath.ProgramFilesRoot() case ConfigUser: prefix, _ = os.UserConfigDir() case ConfigEnv: diff --git a/config/test_isolation_windows_test.go b/config/test_isolation_windows_test.go new file mode 100644 index 00000000..be1308cd --- /dev/null +++ b/config/test_isolation_windows_test.go @@ -0,0 +1,58 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/columnar-tech/dbc/internal/wintest" +) + +func TestMain(m *testing.M) { + isolation, err := wintest.Setup() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "set up isolated Windows config tests: %v\n", err) + os.Exit(1) + } + if err := verifyWindowsConfigRoots(isolation); err != nil { + cleanupErr := isolation.Cleanup() + _, _ = fmt.Fprintf(os.Stderr, "verify isolated Windows config roots: %v\n", errors.Join(err, cleanupErr)) + os.Exit(1) + } + + code := m.Run() + if err := isolation.Cleanup(); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "clean up isolated Windows config tests: %v\n", err) + code = 1 + } + os.Exit(code) +} + +func verifyWindowsConfigRoots(isolation *wintest.Isolation) error { + if got, want := filepath.Clean(ConfigUser.ConfigLocation()), filepath.Clean(isolation.UserRoot()); !strings.EqualFold(got, want) { + return fmt.Errorf("ConfigUser root = %q, want private root %q", got, want) + } + if got, want := filepath.Clean(ConfigSystem.ConfigLocation()), filepath.Clean(isolation.SystemRoot()); !strings.EqualFold(got, want) { + return fmt.Errorf("ConfigSystem root = %q, want private root %q", got, want) + } + return nil +} diff --git a/internal/systempath/program_files_windows.go b/internal/systempath/program_files_windows.go new file mode 100644 index 00000000..8e095c6d --- /dev/null +++ b/internal/systempath/program_files_windows.go @@ -0,0 +1,50 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +// Package systempath provides the Windows system installation root. +package systempath + +import "sync" + +const defaultProgramFilesRoot = `C:\Program Files` + +var ( + programFilesRootMu sync.RWMutex + programFilesRoot = defaultProgramFilesRoot +) + +// ProgramFilesRoot returns the root used for system-level driver files. +func ProgramFilesRoot() string { + programFilesRootMu.RLock() + defer programFilesRootMu.RUnlock() + return programFilesRoot +} + +// SetProgramFilesRootForTests temporarily overrides the system driver root. +// Windows test binaries call this once before running tests and restore it +// after their test suite completes. +func SetProgramFilesRootForTests(root string) func() { + programFilesRootMu.Lock() + previous := programFilesRoot + programFilesRoot = root + programFilesRootMu.Unlock() + + return func() { + programFilesRootMu.Lock() + programFilesRoot = previous + programFilesRootMu.Unlock() + } +} diff --git a/internal/winregroot/roots_windows.go b/internal/winregroot/roots_windows.go new file mode 100644 index 00000000..444b5e13 --- /dev/null +++ b/internal/winregroot/roots_windows.go @@ -0,0 +1,62 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +// Package winregroot provides the registry roots used by dbc's Windows +// registration code. Test overrides affect only callers of this package and do +// not change the registry view seen by Windows system libraries. +package winregroot + +import ( + "sync" + + "golang.org/x/sys/windows/registry" +) + +var ( + rootsMu sync.RWMutex + userRoot = registry.CURRENT_USER + systemRoot = registry.LOCAL_MACHINE +) + +// UserRoot returns the root used for per-user ADBC registration. +func UserRoot() registry.Key { + rootsMu.RLock() + defer rootsMu.RUnlock() + return userRoot +} + +// SystemRoot returns the root used for machine-wide ADBC registration. +func SystemRoot() registry.Key { + rootsMu.RLock() + defer rootsMu.RUnlock() + return systemRoot +} + +// SetRootsForTests replaces the roots used by dbc's own registry accesses and +// returns a function that restores the previous pair. Windows system APIs keep +// using their normal registry view. +func SetRootsForTests(user, system registry.Key) func() { + rootsMu.Lock() + previousUser, previousSystem := userRoot, systemRoot + userRoot, systemRoot = user, system + rootsMu.Unlock() + + return func() { + rootsMu.Lock() + userRoot, systemRoot = previousUser, previousSystem + rootsMu.Unlock() + } +} diff --git a/internal/wintest/isolation_windows.go b/internal/wintest/isolation_windows.go new file mode 100644 index 00000000..a1e75821 --- /dev/null +++ b/internal/wintest/isolation_windows.go @@ -0,0 +1,254 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +// Package wintest contains Windows-only test fixture support. The fixture +// supplies private roots to dbc's own registry and filesystem code without +// changing the registry view used by Windows system APIs. +package wintest + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "unsafe" + + "github.com/columnar-tech/dbc/internal/systempath" + "github.com/columnar-tech/dbc/internal/winregroot" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +const regProcessAppKey = 0x00000001 + +var clearForTestsEnvironment = []string{"ADBC_DRIVER_PATH", "VIRTUAL_ENV", "CONDA_PREFIX"} + +var ( + advapi32DLL = windows.NewLazySystemDLL("advapi32.dll") + regLoadAppKeyWProc = advapi32DLL.NewProc("RegLoadAppKeyW") +) + +// Isolation supplies separate private registry handles and filesystem roots to +// dbc's own User/System registration code. It does not change Windows system +// APIs' registry view or exercise real Program Files/HKLM ACLs. +type Isolation struct { + tempDir string + appData string + systemRoot string + priorAppData string + hadAppData bool + priorEnv []environmentValue + appHive registry.Key + userHive registry.Key + systemHive registry.Key + restoreRoot func() + restoreRegistryRoot func() +} + +type environmentValue struct { + name string + value string + present bool +} + +// Setup creates the process-private Windows test fixture. Any setup failure +// rolls back all state before returning, so callers can fail closed. +func Setup() (*Isolation, error) { + isolation := &Isolation{} + isolation.priorAppData, isolation.hadAppData = os.LookupEnv("APPDATA") + for _, name := range clearForTestsEnvironment { + value, present := os.LookupEnv(name) + isolation.priorEnv = append(isolation.priorEnv, environmentValue{name: name, value: value, present: present}) + } + + dir, err := os.MkdirTemp("", "dbc-windows-tests-") + if err != nil { + return nil, fmt.Errorf("create private Windows test directory: %w", err) + } + isolation.tempDir = dir + if err := isolation.setup(); err != nil { + return nil, errors.Join(err, isolation.Cleanup()) + } + return isolation, nil +} + +func (isolation *Isolation) setup() error { + for _, name := range clearForTestsEnvironment { + if err := os.Unsetenv(name); err != nil { + return fmt.Errorf("clear %s for isolated Windows tests: %w", name, err) + } + } + + isolation.appData = filepath.Join(isolation.tempDir, "user") + isolation.systemRoot = filepath.Join(isolation.tempDir, "system") + for _, path := range []string{isolation.appData, isolation.systemRoot} { + if err := os.MkdirAll(path, 0o700); err != nil { + return fmt.Errorf("create private Windows test root %q: %w", path, err) + } + } + if err := os.Setenv("APPDATA", isolation.appData); err != nil { + return fmt.Errorf("set private APPDATA: %w", err) + } + isolation.restoreRoot = systempath.SetProgramFilesRootForTests(isolation.systemRoot) + + userConfigRoot, err := os.UserConfigDir() + if err != nil { + return fmt.Errorf("resolve private User config root: %w", err) + } + wantUserRoot := filepath.Join(isolation.appData, "ADBC", "Drivers") + wantSystemRoot := filepath.Join(isolation.systemRoot, "ADBC", "Drivers") + if got := filepath.Join(userConfigRoot, "ADBC", "Drivers"); !samePath(got, wantUserRoot) { + return fmt.Errorf("private User driver root = %q, want %q", got, wantUserRoot) + } + if got := filepath.Join(systempath.ProgramFilesRoot(), "ADBC", "Drivers"); !samePath(got, wantSystemRoot) { + return fmt.Errorf("private System driver root = %q, want %q", got, wantSystemRoot) + } + + hivePath := filepath.Join(isolation.tempDir, "registry.hiv") + if _, err := os.Lstat(hivePath); err == nil || !errors.Is(err, os.ErrNotExist) { + if err == nil { + err = errors.New("private registry hive path already exists") + } + return fmt.Errorf("verify private registry hive path: %w", err) + } + isolation.appHive, err = loadPrivateRegistryHive(hivePath) + if err != nil { + return err + } + isolation.userHive, _, err = registry.CreateKey(isolation.appHive, "User", registry.ALL_ACCESS) + if err != nil { + return fmt.Errorf("create private HKCU hive root: %w", err) + } + isolation.systemHive, _, err = registry.CreateKey(isolation.appHive, "System", registry.ALL_ACCESS) + if err != nil { + return fmt.Errorf("create private HKLM hive root: %w", err) + } + isolation.restoreRegistryRoot = winregroot.SetRootsForTests(isolation.userHive, isolation.systemHive) + if err := verifyRegistryRoot(winregroot.UserRoot(), isolation.userHive, "user"); err != nil { + return err + } + if err := verifyRegistryRoot(winregroot.SystemRoot(), isolation.systemHive, "system"); err != nil { + return err + } + return nil +} + +// UserRoot returns the private ConfigUser-compatible driver directory. +func (isolation *Isolation) UserRoot() string { + return filepath.Join(isolation.appData, "ADBC", "Drivers") +} + +// SystemRoot returns the private ConfigSystem-compatible driver directory. +func (isolation *Isolation) SystemRoot() string { + return filepath.Join(isolation.systemRoot, "ADBC", "Drivers") +} + +// Cleanup restores dbc's registry roots and APPDATA, closes all hive handles, +// restores the production system root, and removes the fixture. +func (isolation *Isolation) Cleanup() error { + var cleanupErr error + if isolation.restoreRegistryRoot != nil { + isolation.restoreRegistryRoot() + isolation.restoreRegistryRoot = nil + } + if isolation.systemHive != 0 { + cleanupErr = errors.Join(cleanupErr, isolation.systemHive.Close()) + isolation.systemHive = 0 + } + if isolation.userHive != 0 { + cleanupErr = errors.Join(cleanupErr, isolation.userHive.Close()) + isolation.userHive = 0 + } + if isolation.appHive != 0 { + cleanupErr = errors.Join(cleanupErr, isolation.appHive.Close()) + isolation.appHive = 0 + } + if isolation.restoreRoot != nil { + isolation.restoreRoot() + isolation.restoreRoot = nil + } + if isolation.hadAppData { + cleanupErr = errors.Join(cleanupErr, os.Setenv("APPDATA", isolation.priorAppData)) + } else { + cleanupErr = errors.Join(cleanupErr, os.Unsetenv("APPDATA")) + } + if isolation.tempDir != "" { + cleanupErr = errors.Join(cleanupErr, os.RemoveAll(isolation.tempDir)) + isolation.tempDir = "" + } + for _, prior := range isolation.priorEnv { + if prior.present { + cleanupErr = errors.Join(cleanupErr, os.Setenv(prior.name, prior.value)) + } else { + cleanupErr = errors.Join(cleanupErr, os.Unsetenv(prior.name)) + } + } + return cleanupErr +} + +func loadPrivateRegistryHive(path string) (registry.Key, error) { + filename, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, fmt.Errorf("encode private registry hive path: %w", err) + } + var hive uintptr + status, _, _ := regLoadAppKeyWProc.Call( + uintptr(unsafe.Pointer(filename)), + uintptr(unsafe.Pointer(&hive)), + uintptr(registry.ALL_ACCESS), + regProcessAppKey, + 0, + ) + if status != 0 { + return 0, fmt.Errorf("RegLoadAppKeyW: %w", windows.Errno(status)) + } + return registry.Key(hive), nil +} + +func verifyRegistryRoot(root, privateRoot registry.Key, name string) error { + probePath := "Software\\dbc-test-isolation-probe" + want := "private-" + name + privateKey, _, err := registry.CreateKey(privateRoot, probePath, registry.ALL_ACCESS) + if err != nil { + return fmt.Errorf("create private %s probe: %w", name, err) + } + writeErr := privateKey.SetStringValue("probe", want) + closeErr := privateKey.Close() + if err := errors.Join(writeErr, closeErr); err != nil { + return fmt.Errorf("write private %s probe: %w", name, err) + } + viewKey, err := registry.OpenKey(root, probePath, registry.READ) + if err != nil { + return fmt.Errorf("read private %s probe through dbc registry root: %w", name, err) + } + got, _, readErr := viewKey.GetStringValue("probe") + closeErr = viewKey.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return fmt.Errorf("read private %s probe through dbc registry root: %w", name, err) + } + if got != want { + return fmt.Errorf("%s isolation probe = %q, want %q", name, got, want) + } + if err := registry.DeleteKey(privateRoot, probePath); err != nil { + return fmt.Errorf("remove private %s probe: %w", name, err) + } + return nil +} + +func samePath(left, right string) bool { + return strings.EqualFold(filepath.Clean(left), filepath.Clean(right)) +}