diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3ed2b194..3fbbd82c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,9 +54,9 @@ Then in another shell, run `dlv connect` and debug with dlv as you normally woul ```console $ dlv connect 127.0.0.1:2345 Type 'help' for list of commands. -(dlv) b install.go:58 +(dlv) b main.verifySignatureInStaging (dlv) c -> [Breakpoint 1] main.verifySignature() /Users/user/src/columnar-tech/dbc/cmd/dbc/install.go:58 (hits goroutine(99):1 total:1) (PC: 0x105201f88) +> [Breakpoint 1] main.verifySignatureInStaging() /Users/user/src/columnar-tech/dbc/cmd/dbc/install.go (hits goroutine(99):1 total:1) (PC: 0x105201f88) ``` When you're done, exiting the client should cause the server to exit automatically. diff --git a/client_methods.go b/client_methods.go index 4169aa81..e479d6a2 100644 --- a/client_methods.go +++ b/client_methods.go @@ -278,19 +278,17 @@ func (c *Client) Install(ctx context.Context, cfg config.Config, driverName stri } defer os.RemoveAll(filepath.Dir(f.Name())) - manifest, err := config.InstallDriver(cfg, driverName, f) + manifest, err := config.InstallPackage(ctx, cfg, driverName, f, config.InstallPackageOptions{}) if err != nil { return nil, fmt.Errorf("failed to install driver %s: %w", driverName, err) } - if err := config.CreateManifest(cfg, manifest.DriverInfo); err != nil { - return nil, fmt.Errorf("failed to create manifest for driver %s: %w", driverName, err) - } - return &manifest, nil } -// Uninstall uninstalls a driver with the given name from the specified configuration. +// Uninstall removes a driver's runtime registration from the specified +// configuration. For external registrations, the shared library remains in +// place because dbc does not own it. func (c *Client) Uninstall(cfg config.Config, driverName string) error { di, err := config.GetDriver(cfg, driverName) if err != nil { diff --git a/client_methods_test.go b/client_methods_test.go index 744a685b..cc046b9a 100644 --- a/client_methods_test.go +++ b/client_methods_test.go @@ -25,6 +25,7 @@ import ( "strings" "testing" + "github.com/Masterminds/semver/v3" "github.com/columnar-tech/dbc" "github.com/columnar-tech/dbc/config" "github.com/stretchr/testify/assert" @@ -168,6 +169,26 @@ func TestClientUninstall(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "not-installed-driver") }) + + t.Run("removes external registration and preserves shared library", func(t *testing.T) { + tmpDir := t.TempDir() + cfg := config.Config{Level: config.ConfigEnv, Location: tmpDir} + shared := filepath.Join(tmpDir, "managed-elsewhere", "driver.so") + require.NoError(t, os.MkdirAll(filepath.Dir(shared), 0o700)) + require.NoError(t, os.WriteFile(shared, []byte("external library"), 0o600)) + info := config.DriverInfo{ + ID: "external-driver", FilePath: tmpDir, Name: "External Driver", + Version: semver.MustParse("1.0.0"), Source: "external", + } + info.Driver.Shared.Set(config.PlatformTuple(), shared) + require.NoError(t, config.CreateManifest(cfg, info)) + + require.NoError(t, c.Uninstall(cfg, info.ID)) + assert.NoFileExists(t, filepath.Join(tmpDir, "external-driver.toml")) + data, err := os.ReadFile(shared) + require.NoError(t, err) + assert.Equal(t, "external library", string(data)) + }) } func TestClientDownload(t *testing.T) { diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index be06fd35..f86ce76b 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -15,6 +15,7 @@ package main import ( + "context" "encoding/json" "errors" "fmt" @@ -32,6 +33,7 @@ import ( "github.com/Masterminds/semver/v3" "github.com/columnar-tech/dbc" "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/hostpath" "github.com/columnar-tech/dbc/internal/jsonschema" ) @@ -111,13 +113,14 @@ func (c InstallCmd) GetModel() tea.Model { return c.GetModelCustom(defaultBaseModel()) } -func verifySignature(m config.Manifest, noVerify bool) error { - if m.Files.Driver == "" || noVerify { +func verifySignatureInStaging(path string, m config.Manifest, noVerify bool) error { + if m.Files.Driver == "" { + return fmt.Errorf("package manifest does not specify Files.driver") + } + if noVerify { return nil } - path := filepath.Dir(m.Driver.Shared.Get(config.PlatformTuple())) - lib, err := os.Open(filepath.Join(path, m.Files.Driver)) if err != nil { return fmt.Errorf("could not open driver file: %w", err) @@ -145,10 +148,12 @@ func verifySignature(m config.Manifest, noVerify bool) error { return nil } -type writeDriverManifestMsg struct { - DriverInfo config.DriverInfo +type packageInstallResultMsg struct { + Manifest config.Manifest } +type installVerificationStartedMsg struct{} + type localInstallMsg struct{} // alreadyInstalledChecksumMsg carries the checksum computed for an already-installed driver. @@ -241,6 +246,8 @@ type progressiveInstallModel struct { alreadyInstalledChecksum string jsonOut io.Writer jsonErrorOutput string // JSON error envelope to emit via FinalOutput + verificationStarted bool + verifyStagedPackage func(string, config.Manifest, bool) error } type driversWithRegistryError struct { @@ -257,8 +264,8 @@ func (m progressiveInstallModel) Init() tea.Cmd { return tea.Batch(m.spinner.Tick, func() tea.Msg { installDir := "." - if locs := filepath.SplitList(m.cfg.Location); len(locs) > 0 && locs[0] != "" { - installDir = locs[0] + if root := m.cfg.PrimaryLocation(); root != "" { + installDir = root } lockDir := installDir for { @@ -313,7 +320,7 @@ func (m progressiveInstallModel) FinalOutput() string { Status: "already installed", Driver: m.conflictingInfo.ID, Version: m.conflictingInfo.Version.String(), - Location: filepath.SplitList(m.cfg.Location)[0], + Location: m.cfg.PrimaryLocation(), } if m.alreadyInstalledChecksum != "" { payload.Checksum = m.alreadyInstalledChecksum @@ -334,7 +341,7 @@ func (m progressiveInstallModel) FinalOutput() string { return string(jsonOutput) } return fmt.Sprintf("\nDriver %s %s already installed at %s", - m.conflictingInfo.ID, m.conflictingInfo.Version, filepath.SplitList(m.cfg.Location)[0]) + m.conflictingInfo.ID, m.conflictingInfo.Version, m.cfg.PrimaryLocation()) } var b strings.Builder @@ -343,7 +350,7 @@ func (m progressiveInstallModel) FinalOutput() string { Status: "installed", Driver: m.Driver, Version: m.DriverPackage.Version.String(), - Location: filepath.SplitList(m.cfg.Location)[0], + Location: m.cfg.PrimaryLocation(), } if m.hasConflict() { installStatus.Conflict = fmt.Sprintf("%s (version: %s)", m.conflictingInfo.ID, m.conflictingInfo.Version) @@ -383,7 +390,11 @@ func (m progressiveInstallModel) FinalOutput() string { } if installStatus.Conflict != "" { - fmt.Fprintf(&b, "\nRemoved conflicting driver: %s", installStatus.Conflict) + label := "Shadowed active driver" + if conflictIsInInstallRoot(m.conflictingInfo, m.cfg.PrimaryLocation(), m.cfg.Level) { + label = "Replaced active driver" + } + fmt.Fprintf(&b, "\n%s: %s", label, installStatus.Conflict) } fmt.Fprintf(&b, "\nInstalled %s %s to %s", @@ -396,6 +407,45 @@ func (m progressiveInstallModel) FinalOutput() string { return b.String() } +// TODO: The install-root path identity check below duplicates logic in config; +// consider reusing it from, or moving it to, the config package. +func conflictIsInInstallRoot(conflict config.DriverInfo, installRoot string, level config.ConfigLevel) bool { + if conflict.FilePath == "" || installRoot == "" { + return false + } + + // Registry registrations identify their hive rather than a filesystem root. + // They are replaced when this install targets the same registry level. + switch strings.ToUpper(conflict.FilePath) { + case `HKCU\SOFTWARE\ADBC\DRIVERS`: + return level == config.ConfigUser + case `HKLM\SOFTWARE\ADBC\DRIVERS`: + return level == config.ConfigSystem + } + + return sameInstallRoot(conflict.FilePath, installRoot) +} + +func sameInstallRoot(a, b string) bool { + if aInfo, err := os.Stat(a); err == nil { + if bInfo, err := os.Stat(b); err == nil && os.SameFile(aInfo, bInfo) { + return true + } + } + canonicalize := func(path string) string { + absolute, err := hostpath.Abs(path) + if err != nil { + absolute = hostpath.Clean(path) + } + if resolved, err := hostpath.EvalSymlinks(absolute); err == nil { + absolute = resolved + } + return hostpath.Clean(absolute) + } + + return hostpath.Equal(canonicalize(a), canonicalize(b)) +} + func (m progressiveInstallModel) searchForDriver(list []dbc.Driver) (tea.Model, tea.Cmd) { driverName, vers, err := parseDriverConstraint(m.Driver) if err != nil { @@ -478,18 +528,24 @@ func (m progressiveInstallModel) startInstalling(downloaded *os.File) (tea.Model } } + program := prog + verifier := m.verifyStagedPackage + if verifier == nil { + verifier = verifySignatureInStaging + } return m, func() tea.Msg { - if m.conflictingInfo.ID != "" { - if err := config.UninstallDriver(m.cfg, m.conflictingInfo); err != nil { - return err - } - } - - manifest, err := config.InstallDriver(m.cfg, m.Driver, downloaded) + manifest, err := config.InstallPackage(context.Background(), m.cfg, m.Driver, downloaded, config.InstallPackageOptions{ + Verifier: func(stagingDir string, stagedManifest config.Manifest) error { + if program != nil { + program.Send(installVerificationStartedMsg{}) + } + return verifier(stagingDir, stagedManifest, m.NoVerify) + }, + }) if err != nil { return err } - return manifest + return packageInstallResultMsg{Manifest: manifest} } } @@ -547,31 +603,27 @@ func (m progressiveInstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m = m.addEvent("download.complete") m = m.addEvent("extract.start") return m.startInstalling(msg) - case config.Manifest: - if m.DriverPackage.Version == nil { - m.DriverPackage = manifestToPackageInfo(msg) + case installVerificationStartedMsg: + if m.state >= stDone || m.status != 0 || m.verificationStarted { + return m, nil } - - m.state = stVerifying - m.postInstallMessage = strings.Join(msg.PostInstall.Messages, "\n") + m.verificationStarted = true m = m.addEvent("extract.complete") m = m.addEvent("verify.start") - return m, func() tea.Msg { - if err := verifySignature(msg, m.NoVerify); err != nil { - path := filepath.Dir(msg.Driver.Shared.Get(config.PlatformTuple())) - _ = os.RemoveAll(path) - return err - } - return writeDriverManifestMsg{DriverInfo: msg.DriverInfo} + if !m.NoVerify { + m.state = stVerifying } - case writeDriverManifestMsg: - m.state = stDone - m.installedDriverInfo = msg.DriverInfo + return m, nil + case packageInstallResultMsg: + if m.DriverPackage.Version == nil { + m.DriverPackage = manifestToPackageInfo(msg.Manifest) + } + m.postInstallMessage = strings.Join(msg.Manifest.PostInstall.Messages, "\n") + m.installedDriverInfo = msg.Manifest.DriverInfo m = m.addEvent("verify.complete") m = m.addEvent("manifest.create") - return m, tea.Sequence(func() tea.Msg { - return config.CreateManifest(m.cfg, msg.DriverInfo) - }, tea.Quit) + m.state = stDone + return m, tea.Quit case error: m.status = 1 m.err = msg diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index decd17c5..a6efaee2 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -16,16 +16,24 @@ package main import ( "archive/tar" + "bytes" "compress/gzip" + "context" "encoding/json" "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" "runtime" "strings" + "testing" + "time" + tea "charm.land/bubbletea/v2" "github.com/columnar-tech/dbc" "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/hostpath" "github.com/columnar-tech/dbc/internal/jsonschema" ) @@ -83,19 +91,23 @@ func (suite *SubcommandTestSuite) TestInstallWithVersionLessSpace() { } func (suite *SubcommandTestSuite) TestReinstallUpdateVersion() { - m := InstallCmd{Driver: "test-driver-1<=1.0.0"}. + m := InstallCmd{Driver: "test-driver-1<=1.0.0", Level: suite.configLevel}. GetModelCustom(testBaseModel()) suite.validateOutput("\r[✓] searching\r\n[✓] downloading\r\n[✓] installing\r\n[✓] verifying signature\r\n", - "\nInstalled test-driver-1 1.0.0 to "+suite.tempdir, suite.runCmd(m)) + "\nInstalled test-driver-1 1.0.0 to "+suite.Dir(), suite.runCmd(m)) + oldGeneration := filepath.Dir(suite.getInstalledDriver("test-driver-1").Driver.Shared.Get(config.PlatformTuple())) - m = InstallCmd{Driver: "test-driver-1"}. + m = InstallCmd{Driver: "test-driver-1", Level: suite.configLevel}. GetModelCustom(testBaseModel()) suite.validateOutput("\r[✓] searching\r\n[✓] downloading\r\n[✓] installing\r\n[✓] verifying signature\r\n", - "\nRemoved conflicting driver: test-driver-1 (version: 1.0.0)\nInstalled test-driver-1 1.1.0 to "+suite.tempdir, + "\nReplaced active driver: test-driver-1 (version: 1.0.0)\nInstalled test-driver-1 1.1.0 to "+suite.Dir(), suite.runCmd(m)) - suite.Equal([]string{"test-driver-1.1/test-driver-1-not-valid.so", - "test-driver-1.1/test-driver-1-not-valid.so.sig", "test-driver-1.toml"}, suite.getFilesInTempDir()) + newGeneration := filepath.Dir(suite.getInstalledDriver("test-driver-1").Driver.Shared.Get(config.PlatformTuple())) + suite.NotEqual(oldGeneration, newGeneration) + suite.True(strings.HasPrefix(filepath.Base(newGeneration), fmt.Sprintf(".dbc-package-g-%d-test-driver-1-", len([]byte("test-driver-1"))))) + suite.DirExists(newGeneration) + suite.NoDirExists(oldGeneration) } func (suite *SubcommandTestSuite) TestReinstallDowngradeVersion() { @@ -104,20 +116,151 @@ func (suite *SubcommandTestSuite) TestReinstallDowngradeVersion() { suite.validateOutput("\r[✓] searching\r\n[✓] downloading\r\n[✓] installing\r\n[✓] verifying signature\r\n", "\nInstalled test-driver-1 1.1.0 to "+suite.Dir(), suite.runCmd(m)) suite.driverIsInstalledWithVersion("test-driver-1", "1.1.0", true) + oldGeneration := filepath.Dir(suite.getInstalledDriver("test-driver-1").Driver.Shared.Get(config.PlatformTuple())) m = InstallCmd{Driver: "test-driver-1<=1.0.0", Level: suite.configLevel}. GetModelCustom(testBaseModel()) suite.validateOutput("\r[✓] searching\r\n[✓] downloading\r\n[✓] installing\r\n[✓] verifying signature\r\n", - "\nRemoved conflicting driver: test-driver-1 (version: 1.1.0)\nInstalled test-driver-1 1.0.0 to "+suite.Dir(), + "\nReplaced active driver: test-driver-1 (version: 1.1.0)\nInstalled test-driver-1 1.0.0 to "+suite.Dir(), suite.runCmd(m)) - files := suite.getFilesInDir(suite.Dir()) - suite.Contains(files, "test-driver-1/test-driver-1-not-valid.so") - suite.Contains(files, "test-driver-1/test-driver-1-not-valid.so.sig") - suite.NotContains(files, "test-driver-1.1/test-driver-1-not-valid.so") + newGeneration := filepath.Dir(suite.getInstalledDriver("test-driver-1").Driver.Shared.Get(config.PlatformTuple())) + suite.NotEqual(oldGeneration, newGeneration) + suite.True(strings.HasPrefix(filepath.Base(newGeneration), fmt.Sprintf(".dbc-package-g-%d-test-driver-1-", len([]byte("test-driver-1"))))) + suite.DirExists(newGeneration) + suite.NoDirExists(oldGeneration) suite.driverIsInstalledWithVersion("test-driver-1", "1.0.0", true) } +func (suite *SubcommandTestSuite) TestInstallShadowsLowerPriorityVersion() { + secondary := filepath.Join(suite.T().TempDir(), "secondary") + suite.Require().NoError(os.MkdirAll(secondary, 0o755)) + suite.T().Setenv("ADBC_DRIVER_PATH", secondary) + + archive, err := os.Open(filepath.Join("testdata", "test-driver-1.tar.gz")) + suite.Require().NoError(err) + _, err = config.InstallPackage(context.Background(), config.Config{Level: config.ConfigEnv, Location: secondary}, "test-driver-1", archive, + config.InstallPackageOptions{Verifier: func(stagingDir string, manifest config.Manifest) error { + return verifySignatureInStaging(stagingDir, manifest, false) + }}) + suite.Require().NoError(err) + secondaryDriver, err := config.GetDriver(config.Config{Level: config.ConfigEnv, Location: secondary}, "test-driver-1") + suite.Require().NoError(err) + secondaryLibrary := secondaryDriver.Driver.Shared.Get(config.PlatformTuple()) + secondaryBytes, err := os.ReadFile(secondaryLibrary) + suite.Require().NoError(err) + + suite.T().Setenv("ADBC_DRIVER_PATH", suite.tempdir+string(os.PathListSeparator)+secondary) + primaryConfig := config.Config{Level: config.ConfigEnv, Location: suite.tempdir + string(os.PathListSeparator) + secondary} + selectedConflict, err := config.GetDriver(primaryConfig, "test-driver-1") + suite.Require().NoError(err) + suite.Equal(secondary, selectedConflict.FilePath) + suite.False(conflictIsInInstallRoot(selectedConflict, filepath.SplitList(primaryConfig.Location)[0], config.ConfigEnv)) + newInstall := InstallCmd{Driver: "test-driver-1", Level: config.ConfigEnv}. + GetModelCustom(testBaseModel()) + newInstallModel := newInstall.(progressiveInstallModel) + newInstallModel.cfg = primaryConfig + out := suite.runCmd(newInstallModel) + suite.Contains(out, "Shadowed active driver: test-driver-1 (version: 1.0.0)") + suite.NotContains(out, "Removed conflicting driver") + + active, err := config.GetDriver(config.Get()[config.ConfigEnv], "test-driver-1") + suite.Require().NoError(err) + suite.Equal("1.1.0", active.Version.String()) + activeLibrary := active.Driver.Shared.Get(config.PlatformTuple()) + relativeActiveLibrary, err := filepath.Rel(suite.tempdir, activeLibrary) + suite.Require().NoError(err) + suite.False(filepath.IsAbs(relativeActiveLibrary) || relativeActiveLibrary == ".." || + strings.HasPrefix(relativeActiveLibrary, ".."+string(os.PathSeparator)), + "active library should be inside the primary root: %s", activeLibrary) + suite.FileExists(activeLibrary) + + remainingSecondary, err := config.GetDriver(config.Config{Level: config.ConfigEnv, Location: secondary}, "test-driver-1") + suite.Require().NoError(err) + suite.Equal("1.0.0", remainingSecondary.Version.String()) + remainingBytes, err := os.ReadFile(secondaryLibrary) + suite.Require().NoError(err) + suite.Equal(secondaryBytes, remainingBytes) +} + +func TestConflictIsInInstallRoot(t *testing.T) { + root := t.TempDir() + conflict := config.DriverInfo{FilePath: root} + if !conflictIsInInstallRoot(conflict, root, config.ConfigEnv) { + t.Fatal("same install root should be reported as replaced") + } + + otherRoot := filepath.Join(t.TempDir(), "secondary") + if conflictIsInInstallRoot(conflict, otherRoot, config.ConfigEnv) { + t.Fatal("a different install root should be reported as shadowed") + } + + alias := filepath.Join(t.TempDir(), "alias") + if err := os.Symlink(root, alias); err == nil { + if !conflictIsInInstallRoot(conflict, alias, config.ConfigEnv) { + t.Fatal("symlink aliases of the same install root should be reported as replaced") + } + if !sameInstallRoot(root, alias) { + t.Fatal("symlink aliases of the same install root should have the same filesystem identity") + } + } else if !hostpath.IsWindows() { + t.Fatalf("create install root symlink: %v", err) + } + + caseRoot := filepath.Join(t.TempDir(), "CaseRoot") + if err := os.Mkdir(caseRoot, 0o755); err != nil { + t.Fatal(err) + } + caseVariant := filepath.Join(filepath.Dir(caseRoot), "caseroot") + caseInfo, caseErr := os.Stat(caseVariant) + if originalInfo, err := os.Stat(caseRoot); err == nil && caseErr == nil && os.SameFile(originalInfo, caseInfo) { + if !sameInstallRoot(caseRoot, caseVariant) { + t.Fatal("case variants identifying the same directory should have the same filesystem identity") + } + } + + if conflictIsInInstallRoot(config.DriverInfo{FilePath: `HKCU\SOFTWARE\ADBC\Drivers`}, root, config.ConfigSystem) { + t.Fatal("a user registry registration should not be reported as replaced by a system install") + } + if !conflictIsInInstallRoot(config.DriverInfo{FilePath: `HKCU\SOFTWARE\ADBC\Drivers`}, root, config.ConfigUser) { + t.Fatal("a user registry registration should be reported as replaced by a user install") + } +} + +func (suite *SubcommandTestSuite) TestInstallSameVersionOnSecondaryRootSkipsDownload() { + secondary := filepath.Join(suite.tempdir, "secondary") + suite.Require().NoError(os.MkdirAll(secondary, 0o755)) + suite.T().Setenv("ADBC_DRIVER_PATH", secondary) + + oldInstall := InstallCmd{Driver: "test-driver-1=1.0.0", Level: config.ConfigEnv}. + GetModelCustom(testBaseModel()) + suite.runCmd(oldInstall) + + suite.T().Setenv("ADBC_DRIVER_PATH", suite.tempdir+string(os.PathListSeparator)+secondary) + downloads := 0 + base := baseModel{ + getDriverRegistry: getTestDriverRegistry, + downloadPkg: func(pkg dbc.PkgInfo) (*os.File, error) { + downloads++ + return downloadTestPkg(pkg) + }, + } + m := InstallCmd{Driver: "test-driver-1=1.0.0", Level: config.ConfigEnv}. + GetModelCustom(base) + out := suite.runCmd(m) + + suite.Contains(out, "already installed") + suite.Zero(downloads, "same-version install on a secondary root should skip before download") + active, err := config.GetDriver(config.Get()[config.ConfigEnv], "test-driver-1") + suite.Require().NoError(err) + activeLibrary := active.Driver.Shared.Get(config.PlatformTuple()) + relativeActiveLibrary, err := filepath.Rel(secondary, activeLibrary) + suite.Require().NoError(err) + suite.False(filepath.IsAbs(relativeActiveLibrary) || relativeActiveLibrary == ".." || + strings.HasPrefix(relativeActiveLibrary, ".."+string(os.PathSeparator)), + "same-version install should keep using the secondary root: %s", activeLibrary) +} + func (suite *SubcommandTestSuite) TestInstallVenv() { suite.T().Setenv("ADBC_DRIVER_PATH", "") suite.T().Setenv("VIRTUAL_ENV", suite.tempdir) @@ -172,14 +315,13 @@ func (suite *SubcommandTestSuite) TestInstallCondaPrefix() { "\nInstalled test-driver-1 1.1.0 to "+filepath.Join(suite.tempdir, "etc", "adbc", "drivers"), suite.runCmd(m)) } -func (suite *SubcommandTestSuite) TestInstallManifestOnlyDriver() { - m := InstallCmd{Driver: "test-driver-manifest-only", Level: suite.configLevel}. +func (suite *SubcommandTestSuite) TestInstallRejectsManifestOnlyDriverEvenWithSharedPath() { + m := InstallCmd{Driver: "test-driver-manifest-only", Level: suite.configLevel, NoVerify: true}. GetModelCustom(testBaseModel()) - suite.validateOutput("\r[✓] searching\r\n[✓] downloading\r\n[✓] installing\r\n[✓] verifying signature\r\n", - "\nInstalled test-driver-manifest-only 1.0.0 to "+suite.Dir()+ - "\n\nMust have libtest_driver installed to load this driver", suite.runCmd(m)) - suite.driverIsInstalled("test-driver-manifest-only", false) + output := suite.runCmdErr(m) + suite.Contains(output, "does not specify Files.driver") + suite.NoFileExists(filepath.Join(suite.Dir(), "test-driver-manifest-only.toml")) } func (suite *SubcommandTestSuite) TestInstallDriverNoSignature() { @@ -188,15 +330,45 @@ func (suite *SubcommandTestSuite) TestInstallDriverNoSignature() { out := suite.runCmdErr(m) suite.Contains(out, "signature file 'test-driver-1-not-valid.so.sig' for driver is missing") - suite.Empty(suite.getFilesInTempDir()) + suite.Equal(expectedFilesWithPersistentDriverLock("test-driver-no-sig"), suite.getFilesInTempDir()) + suite.assertPersistentDriverLockFile("test-driver-no-sig") suite.NoDirExists(filepath.Join(suite.tempdir, "test-driver-no-sig")) + registryServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/index.yaml") { + _, _ = fmt.Fprint(w, installRegistryDriverIndex("test-driver-no-sig", "v1.1.0", "test-driver-no-sig.tar.gz")) + return + } + http.NotFound(w, r) + })) + defer registryServer.Close() + client, err := dbc.NewClient(dbc.WithBaseURL(registryServer.URL)) + suite.Require().NoError(err) + downloads := 0 + base := baseModel{ + getDriverRegistry: func() ([]dbc.Driver, error) { + return client.Search(context.Background(), "") + }, + downloadPkg: func(pkg dbc.PkgInfo) (*os.File, error) { + downloads++ + return downloadTestPkg(pkg) + }, + } + // Note: The UI output (first parameter) serves as documentation but isn't verified // by validateOutput due to tea.WithoutRenderer() mode. Manual verification needed. m = InstallCmd{Driver: "test-driver-no-sig", NoVerify: true}. - GetModelCustom(testBaseModel()) + GetModelCustom(base) suite.validateOutput("\r[✓] searching\r\n[✓] downloading\r\n[✓] installing\r\n[-] verifying signature\r\n", - "\nInstalled test-driver-no-sig 1.0.0 to "+suite.tempdir, suite.runCmd(m)) + "\nInstalled test-driver-no-sig 1.1.0 to "+suite.tempdir, suite.runCmd(m)) + suite.Equal(1, downloads) + + downloads = 0 + m = InstallCmd{Driver: "test-driver-no-sig"}.GetModelCustom(base) + out = suite.runCmd(m) + suite.Contains(out, "already installed") + suite.NotContains(out, "signature") + suite.Zero(downloads, "same-version install should not download or reverify the package") } func (suite *SubcommandTestSuite) TestInstallGitignoreDefaultBehavior() { @@ -326,7 +498,8 @@ func (suite *SubcommandTestSuite) TestInstallLocalPackageNoSignature() { out := suite.runCmdErr(m) suite.Contains(out, "signature file 'test-driver-1-not-valid.so.sig' for driver is missing") - suite.Empty(suite.getFilesInTempDir()) + suite.Equal(expectedFilesWithPersistentDriverLock("test-driver-no-sig"), suite.getFilesInTempDir()) + suite.assertPersistentDriverLockFile("test-driver-no-sig") suite.NoDirExists(filepath.Join(suite.tempdir, "test-driver-no-sig")) m = InstallCmd{Driver: packagePath, NoVerify: true}. @@ -581,6 +754,121 @@ func (suite *SubcommandTestSuite) TestInstall_JSONProgressStream() { suite.True(hasDownloadStart, "expected download.start event") } +func (suite *SubcommandTestSuite) TestInstall_NoVerifyJSONProgressKeepsVerificationEventsOrdered() { + m := InstallCmd{Driver: "test-driver-no-sig", Level: suite.configLevel, NoVerify: true, JsonStreamProgress: true}. + GetModelCustom(testBaseModel()) + out := suite.runCmd(m) + + startIndex := strings.Index(out, `"event":"verify.start"`) + completeIndex := strings.Index(out, `"event":"verify.complete"`) + suite.Require().GreaterOrEqual(startIndex, 0, "expected verify.start progress event") + suite.Require().GreaterOrEqual(completeIndex, 0, "expected verify.complete progress event") + suite.Less(startIndex, completeIndex, "verification progress events should stay ordered") + suite.Contains(out, `"kind":"install.status"`) +} + +func (suite *SubcommandTestSuite) TestInstallVerificationPhaseIsReportedBeforeVerifierCompletes() { + model := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel, JsonStreamProgress: true}. + GetModelCustom(testBaseModel()).(progressiveInstallModel) + verifyEntered := make(chan struct{}) + verifyRelease := make(chan struct{}) + verifyCompleted := make(chan struct{}) + verifierReleased := false + defer func() { + if !verifierReleased { + close(verifyRelease) + } + }() + model.verifyStagedPackage = func(string, config.Manifest, bool) error { + close(verifyEntered) + <-verifyRelease + close(verifyCompleted) + return nil + } + var progress bytes.Buffer + model = model.WithJSONWriter(&progress).(progressiveInstallModel) + + states := make(chan installState, 8) + observed := &installStateObservedModel{model: model, states: states} + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + output := &bytes.Buffer{} + savedProg := prog + defer func() { prog = savedProg }() + program := tea.NewProgram(observed, tea.WithInput(nil), tea.WithOutput(output), + tea.WithoutRenderer(), tea.WithContext(ctx)) + prog = program + runDone := make(chan error, 1) + go func() { + _, err := program.Run() + program.Wait() + runDone <- err + }() + + select { + case <-verifyEntered: + case <-ctx.Done(): + suite.FailNow("verifier was not entered before timeout") + } + + stateReached := false + for !stateReached { + select { + case state := <-states: + stateReached = state == stVerifying + case <-ctx.Done(): + suite.FailNow("install model did not enter the verifying state before timeout") + } + } + select { + case <-verifyCompleted: + suite.FailNow("verifier completed before the test released it") + default: + } + + close(verifyRelease) + verifierReleased = true + select { + case err := <-runDone: + suite.Require().NoError(err) + case <-ctx.Done(): + suite.FailNow("install did not finish before timeout") + } + + var events []string + progressOutput := strings.TrimSpace(progress.String()) + suite.Require().NotEmpty(progressOutput, "expected progress events") + for _, line := range strings.Split(progressOutput, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + var envelope jsonschema.Envelope + suite.Require().NoError( + json.Unmarshal([]byte(line), &envelope), + "invalid progress line: %q in %q", line, progressOutput, + ) + if envelope.Kind != "install.progress" { + continue + } + var event jsonschema.InstallProgressEvent + suite.Require().NoError(json.Unmarshal(envelope.Payload, &event)) + events = append(events, event.Event) + } + startIndex := -1 + completeIndex := -1 + for i, event := range events { + if event == "verify.start" { + startIndex = i + } + if event == "verify.complete" { + completeIndex = i + } + } + suite.Require().GreaterOrEqual(startIndex, 0, "expected verify.start progress event") + suite.Require().GreaterOrEqual(completeIndex, 0, "expected verify.complete progress event") + suite.Less(startIndex, completeIndex, "verification progress events should stay ordered") +} + // TestInstallJSON_AlreadyInstalledChecksumFailure is a regression test for the // fix that gates FinalOutput() on m.status. When the driver binary is missing // the checksum computation fails, the model exits with status 1, and @@ -629,3 +917,26 @@ func (suite *SubcommandTestSuite) TestInstallJSON_AlreadyInstalledChecksumFailur suite.Require().NoError(json.Unmarshal(errEnv.Payload, &errPayload)) suite.Equal("install_failed", errPayload.Code, "expected install_failed error code") } + +type installStateObservedModel struct { + model progressiveInstallModel + states chan installState +} + +func (m *installStateObservedModel) Init() tea.Cmd { + return m.model.Init() +} + +func (m *installStateObservedModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + previousState := m.model.state + updated, cmd := m.model.Update(msg) + m.model = updated.(progressiveInstallModel) + if m.model.state != previousState { + m.states <- m.model.state + } + return m, cmd +} + +func (m *installStateObservedModel) View() tea.View { + return m.model.View() +} diff --git a/cmd/dbc/registry_test.go b/cmd/dbc/registry_test.go index 3461eaa1..c78affdd 100644 --- a/cmd/dbc/registry_test.go +++ b/cmd/dbc/registry_test.go @@ -20,8 +20,10 @@ import ( "bytes" "context" "errors" + "fmt" "os" "path/filepath" + "strings" "testing" "time" @@ -113,7 +115,11 @@ func (s *RegistryTestSuite) TestInstallDriver() { val, _, err = k.GetStringValue("driver") s.Require().NoError(err) - s.Equal(filepath.Join(s.cfgUserPath, "test-driver-1.1", "test-driver-1-not-valid.so"), val) + s.Equal("test-driver-1-not-valid.so", filepath.Base(val)) + generation := filepath.Dir(val) + s.Equal(s.cfgUserPath, filepath.Dir(generation)) + s.True(strings.HasPrefix(filepath.Base(generation), fmt.Sprintf(".dbc-package-g-%d-test-driver-1-", len([]byte("test-driver-1"))))) + s.DirExists(generation) } func (s *RegistryTestSuite) TestPartialReinstallDriver() { diff --git a/cmd/dbc/subcommand_test.go b/cmd/dbc/subcommand_test.go index d5e00d93..bf9e4209 100644 --- a/cmd/dbc/subcommand_test.go +++ b/cmd/dbc/subcommand_test.go @@ -24,6 +24,8 @@ import ( "net/url" "os" "path/filepath" + "runtime" + "sort" "strings" "testing" "time" @@ -139,6 +141,28 @@ func (suite *SubcommandTestSuite) getFilesInDir(dir string) []string { return filelist } +func expectedFilesWithPersistentDriverLock(runtimeID string, files ...string) []string { + var want []string + want = append(want, files...) + if runtime.GOOS != "windows" && runtime.GOOS != "js" { + want = append(want, "."+runtimeID+".dbc") + } + sort.Strings(want) + return want +} + +func (suite *SubcommandTestSuite) assertPersistentDriverLockFile(runtimeID string) { + suite.T().Helper() + if runtime.GOOS == "windows" || runtime.GOOS == "js" { + return + } + path := filepath.Join(suite.tempdir, "."+runtimeID+".dbc") + info, err := os.Lstat(path) + suite.Require().NoError(err) + suite.True(info.Mode().IsRegular(), "persistent driver lock should be a regular file") + suite.Zero(info.Size(), "persistent driver lock should remain empty") +} + // Get the base directory for where drivers are installed. Use this instead of // hardcoding checks to suite.tempdir to make tests support other config levels. func (suite *SubcommandTestSuite) Dir() string { diff --git a/cmd/dbc/subcommand_unix_test.go b/cmd/dbc/subcommand_unix_test.go index 33713c10..772e9fda 100644 --- a/cmd/dbc/subcommand_unix_test.go +++ b/cmd/dbc/subcommand_unix_test.go @@ -24,6 +24,7 @@ import ( func (suite *SubcommandTestSuite) TearDownTest() { // Clean up filesystem after each test + // TODO: Isolate User/System test state before deleting real driver data. _, user := os.LookupEnv("DBC_TEST_LEVEL_USER") _, system := os.LookupEnv("DBC_TEST_LEVEL_SYSTEM") if user { diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index ab8337bc..cfc0d1ab 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -15,12 +15,12 @@ package main import ( + "context" "errors" "fmt" "io" "io/fs" "os" - "path" "path/filepath" "strings" "time" @@ -244,7 +244,6 @@ func (s syncModel) createInstallList(list DriversList) ([]installItem, error) { } type installedDrvMsg struct { - removed *config.DriverInfo info config.DriverInfo postInstall []string } @@ -256,9 +255,6 @@ type alreadyInstalledDrvMsg struct { func (s syncModel) installDriver(cfg config.Config, item installItem) tea.Cmd { return func() tea.Msg { - // TODO: Factor this out into config package, remove duplication with - // config.InstallDriver - var removedDriver *config.DriverInfo if cfg.Exists { // is driver installed already? if drv, ok := cfg.Drivers[item.Driver.Path]; ok { @@ -278,11 +274,6 @@ func (s syncModel) installDriver(cfg config.Config, item installItem) tea.Cmd { } return alreadyInstalledDrvMsg{info: drv, item: item} - } else { - if err := config.UninstallDriver(cfg, drv); err != nil { - return fmt.Errorf("failed when deleting driver %s-%s: %w", drv.ID, drv.Version, err) - } - removedDriver = &drv } } } @@ -295,45 +286,30 @@ func (s syncModel) installDriver(cfg config.Config, item installItem) tea.Cmd { return } - var loc string - if loc, err = config.EnsureLocation(cfg); err != nil { - prog.Send(fmt.Errorf("failed to ensure config location: %w", err)) - return - } - - base := strings.TrimSuffix(path.Base(item.Package.Path.Path), ".tar.gz") - finalDir := filepath.Join(loc, base) - if err := os.MkdirAll(finalDir, 0o755); err != nil { - prog.Send(fmt.Errorf("failed to create driver directory %s: %w", finalDir, err)) + if _, err := output.Seek(0, io.SeekStart); err != nil { + _ = output.Close() + prog.Send(fmt.Errorf("failed to seek downloaded driver archive: %w", err)) return } - output.Seek(0, io.SeekStart) - manifest, err := config.InflateTarball(output, finalDir) + verificationFailed := false + manifest, err := config.InstallPackage(context.Background(), cfg, item.Driver.Path, output, config.InstallPackageOptions{ + Verifier: func(stagingDir string, manifest config.Manifest) error { + err := verifySignatureInStaging(stagingDir, manifest, s.NoVerify) + verificationFailed = err != nil + return err + }, + }) if err != nil { - prog.Send(fmt.Errorf("failed to extract tarball: %w", err)) - return - } - - driverPath := filepath.Join(finalDir, manifest.Files.Driver) - - manifest.DriverInfo.ID = item.Driver.Path - manifest.DriverInfo.Source = "dbc" - manifest.DriverInfo.Driver.Shared.Set(config.PlatformTuple(), driverPath) - - if err := verifySignature(manifest, s.NoVerify); err != nil { - _ = os.RemoveAll(finalDir) - prog.Send(fmt.Errorf("failed to verify signature: %w", err)) - return - } - - if err := config.CreateManifest(cfg, manifest.DriverInfo); err != nil { - prog.Send(fmt.Errorf("failed to create driver manifest: %w", err)) + if verificationFailed { + prog.Send(fmt.Errorf("failed to verify signature: %w", err)) + return + } + prog.Send(fmt.Errorf("failed to install driver package: %w", err)) return } prog.Send(installedDrvMsg{ - removed: removedDriver, info: manifest.DriverInfo, postInstall: manifest.PostInstall.Messages, }) @@ -504,12 +480,6 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var printCmd tea.Cmd if !s.jsonOutput { printCmd = tea.Printf("%s %s-%s", checkMark, msg.info.ID, msg.info.Version) - if msg.removed != nil { - printCmd = tea.Sequence( - printCmd, - tea.Printf("%s removed %s-%s", checkMark, msg.removed.ID, msg.removed.Version), - ) - } if len(msg.postInstall) > 0 { for _, m := range msg.postInstall { diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index 38338983..6b236c8b 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -19,9 +19,11 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" "github.com/columnar-tech/dbc" + "github.com/columnar-tech/dbc/config" "github.com/columnar-tech/dbc/internal/jsonschema" ) @@ -39,11 +41,134 @@ func (suite *SubcommandTestSuite) TestSync() { suite.validateOutput("✓ test-driver-1-1.1.0\r\n\rDone!\r\n", "", suite.runCmd(m)) suite.FileExists(filepath.Join(suite.tempdir, "test-driver-1.toml")) + downloads := 0 m = SyncCmd{ Path: filepath.Join(suite.tempdir, "dbc.toml"), }.GetModelCustom( - testBaseModel()) + baseModel{ + getDriverRegistry: getTestDriverRegistry, + downloadPkg: func(pkg dbc.PkgInfo) (*os.File, error) { + downloads++ + return downloadTestPkg(pkg) + }, + }) suite.validateOutput("✓ test-driver-1-1.1.0 already installed\r\n\rDone!\r\n", "", suite.runCmd(m)) + suite.Zero(downloads, "same-version sync should not download the package") +} + +func (suite *SubcommandTestSuite) TestSyncReplacementSignatureFailurePreservesInstalledDriver() { + listPath := filepath.Join(suite.tempdir, "dbc.toml") + suite.Require().NoError(os.WriteFile(listPath, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0o644)) + + m := SyncCmd{Path: listPath, Level: suite.configLevel}.GetModelCustom(testBaseModel()) + suite.runCmd(m) + old := suite.getInstalledDriver("test-driver-1") + var oldManifest []byte + if runtime.GOOS != "windows" || suite.configLevel == config.ConfigEnv { + var err error + oldManifest, err = os.ReadFile(filepath.Join(suite.Dir(), "test-driver-1.toml")) + suite.Require().NoError(err) + } + oldLibrary := old.Driver.Shared.Get(config.PlatformTuple()) + oldLibraryBytes, err := os.ReadFile(oldLibrary) + suite.Require().NoError(err) + + suite.Require().NoError(os.WriteFile(listPath, []byte("[drivers]\n[drivers.test-driver-1]\nversion = '=1.0.0'\n"), 0o644)) + model := SyncCmd{Path: listPath, Level: suite.configLevel}.GetModelCustom(baseModel{ + getDriverRegistry: getTestDriverRegistry, + downloadPkg: func(dbc.PkgInfo) (*os.File, error) { + return os.Open(filepath.Join("testdata", "test-driver-no-sig.tar.gz")) + }, + }) + suite.Contains(suite.runCmdErr(model), "failed to verify signature") + + current := suite.getInstalledDriver("test-driver-1") + suite.Equal(old.Version, current.Version) + suite.Equal(old.Driver.Shared.Get(config.PlatformTuple()), current.Driver.Shared.Get(config.PlatformTuple())) + if runtime.GOOS != "windows" || suite.configLevel == config.ConfigEnv { + currentManifest, err := os.ReadFile(filepath.Join(suite.Dir(), "test-driver-1.toml")) + suite.Require().NoError(err) + suite.Equal(oldManifest, currentManifest) + } + currentLibraryBytes, err := os.ReadFile(current.Driver.Shared.Get(config.PlatformTuple())) + suite.Require().NoError(err) + suite.Equal(oldLibraryBytes, currentLibraryBytes) +} + +func (suite *SubcommandTestSuite) TestSyncReplacementUsesTransactionAndShadowsSecondaryRoot() { + secondary := filepath.Join(suite.tempdir, "secondary") + suite.Require().NoError(os.MkdirAll(secondary, 0o755)) + suite.T().Setenv("ADBC_DRIVER_PATH", secondary) + listPath := filepath.Join(suite.tempdir, "dbc.toml") + suite.Require().NoError(os.WriteFile(listPath, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0o644)) + + m := SyncCmd{Path: listPath, Level: config.ConfigEnv}.GetModelCustom(testBaseModel()) + suite.runCmd(m) + secondaryDriver, err := config.GetDriver(config.Get()[config.ConfigEnv], "test-driver-1") + suite.Require().NoError(err) + secondaryLibrary := secondaryDriver.Driver.Shared.Get(config.PlatformTuple()) + secondaryBytes, err := os.ReadFile(secondaryLibrary) + suite.Require().NoError(err) + + suite.T().Setenv("ADBC_DRIVER_PATH", suite.tempdir+string(os.PathListSeparator)+secondary) + suite.Require().NoError(os.WriteFile(listPath, []byte("[drivers]\n[drivers.test-driver-1]\nversion = '=1.0.0'\n"), 0o644)) + m = SyncCmd{Path: listPath, Level: config.ConfigEnv}.GetModelCustom(testBaseModel()) + out := suite.runCmd(m) + suite.NotContains(out, "removed") + + primaryDriver, err := config.GetDriver(config.Get()[config.ConfigEnv], "test-driver-1") + suite.Require().NoError(err) + suite.Equal("1.0.0", primaryDriver.Version.String()) + primaryLibrary := primaryDriver.Driver.Shared.Get(config.PlatformTuple()) + suite.True(strings.HasPrefix(filepath.Base(filepath.Dir(primaryLibrary)), fmt.Sprintf(".dbc-package-g-%d-test-driver-1-", len([]byte("test-driver-1"))))) + suite.FileExists(filepath.Join(suite.tempdir, "test-driver-1.toml")) + suite.FileExists(primaryLibrary) + primaryRel, err := filepath.Rel(suite.tempdir, primaryLibrary) + suite.Require().NoError(err) + suite.False(filepath.IsAbs(primaryRel) || primaryRel == ".." || strings.HasPrefix(primaryRel, ".."+string(os.PathSeparator)), + "primary library should be inside the primary root: %s", primaryLibrary) + remainingSecondaryBytes, err := os.ReadFile(secondaryLibrary) + suite.Require().NoError(err) + suite.Equal(secondaryBytes, remainingSecondaryBytes, "secondary-root library should remain intact when shadowed") + suite.FileExists(filepath.Join(secondary, "test-driver-1.toml")) + + lock, err := loadLockFile(filepath.Join(suite.tempdir, "dbc.lock")) + suite.Require().NoError(err) + lockInfo := lock.lockinfo["test-driver-1"] + suite.Equal("1.0.0", lockInfo.Version.String()) + primaryChecksum, err := checksum(primaryLibrary) + suite.Require().NoError(err) + suite.Equal(primaryChecksum, lockInfo.Checksum) +} + +func (suite *SubcommandTestSuite) TestSyncPrimaryVersionReplacementUpdatesRegistrationAndLockChecksum() { + listPath := filepath.Join(suite.tempdir, "dbc.toml") + suite.Require().NoError(os.WriteFile(listPath, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0o644)) + + m := SyncCmd{Path: listPath, Level: suite.configLevel}.GetModelCustom(testBaseModel()) + suite.runCmd(m) + old := suite.getInstalledDriver("test-driver-1") + oldLibrary := old.Driver.Shared.Get(config.PlatformTuple()) + + suite.Require().NoError(os.WriteFile(listPath, []byte("[drivers]\n[drivers.test-driver-1]\nversion = '=1.0.0'\n"), 0o644)) + m = SyncCmd{Path: listPath, Level: suite.configLevel}.GetModelCustom(testBaseModel()) + out := suite.runCmd(m) + suite.NotContains(out, "removed") + + current := suite.getInstalledDriver("test-driver-1") + currentLibrary := current.Driver.Shared.Get(config.PlatformTuple()) + suite.Equal("1.0.0", current.Version.String()) + suite.NotEqual(oldLibrary, currentLibrary) + suite.True(strings.HasPrefix(filepath.Base(filepath.Dir(currentLibrary)), fmt.Sprintf(".dbc-package-g-%d-test-driver-1-", len([]byte("test-driver-1"))))) + suite.FileExists(currentLibrary) + + lock, err := loadLockFile(filepath.Join(suite.tempdir, "dbc.lock")) + suite.Require().NoError(err) + lockInfo := lock.lockinfo["test-driver-1"] + suite.Equal("1.0.0", lockInfo.Version.String()) + currentChecksum, err := checksum(currentLibrary) + suite.Require().NoError(err) + suite.Equal(currentChecksum, lockInfo.Checksum) } func (suite *SubcommandTestSuite) TestSyncRejectsUnsupportedLockFileWithoutRewriting() { @@ -162,10 +287,10 @@ func (suite *SubcommandTestSuite) TestSyncInstallFailSig() { Path: filepath.Join(suite.tempdir, "dbc.toml"), }.GetModelCustom( testBaseModel()) - suite.validateOutput("\r ", - "\nError: failed to verify signature: signature file 'test-driver-1-not-valid.so.sig' for driver is missing", - suite.runCmdErr(m)) - suite.Equal([]string{"dbc.toml"}, suite.getFilesInTempDir()) + out := suite.runCmdErr(m) + suite.Contains(out, "failed to verify signature") + suite.Equal(expectedFilesWithPersistentDriverLock("test-driver-no-sig", "dbc.toml"), suite.getFilesInTempDir()) + suite.assertPersistentDriverLockFile("test-driver-no-sig") } func (suite *SubcommandTestSuite) TestSyncInstallNoVerify() { diff --git a/cmd/dbc/uninstall_test.go b/cmd/dbc/uninstall_test.go index 3d6c1f00..6781b97c 100644 --- a/cmd/dbc/uninstall_test.go +++ b/cmd/dbc/uninstall_test.go @@ -21,10 +21,12 @@ import ( "path" "path/filepath" "runtime" + "strings" + "testing" + "github.com/Masterminds/semver/v3" "github.com/columnar-tech/dbc/config" "github.com/columnar-tech/dbc/internal/jsonschema" - "github.com/pelletier/go-toml/v2" ) func (suite *SubcommandTestSuite) TestUninstallNotFound() { @@ -36,7 +38,7 @@ func (suite *SubcommandTestSuite) TestUninstallNotFound() { suite.validateOutput("\r ", "\nError: failed to find driver `notfound` in order to uninstall it: searched "+suite.tempdir, suite.runCmdErr(m)) } -func (suite *SubcommandTestSuite) TestUninstallManifestOnly() { +func (suite *SubcommandTestSuite) TestUninstallExternalRuntimeRegistration() { if runtime.GOOS == "windows" { suite.T().Skip() } @@ -55,7 +57,7 @@ version = "1.0.0" suite.validateOutput("\r ", "Driver `found` uninstalled successfully!", suite.runCmd(m)) } -func (suite *SubcommandTestSuite) TestUninstallDriverAndManifest() { +func (suite *SubcommandTestSuite) TestUninstallExternalRegistrationPreservesSharedLibrary() { if runtime.GOOS == "windows" { suite.T().Skip() } @@ -75,6 +77,8 @@ version = "1.0.0" m := UninstallCmd{Driver: "found", Level: config.ConfigEnv}.GetModel() suite.validateOutput("\r ", "Driver `found` uninstalled successfully!", suite.runCmd(m)) + suite.NoFileExists(path.Join(suite.tempdir, "found.toml")) + suite.FileExists(path.Join(pkgdir, "some.dll")) } // Test what happens when a user installs a driver in multiple locations @@ -166,76 +170,36 @@ func (suite *SubcommandTestSuite) TestUninstallMultipleLocationsNonDefault() { suite.NoFileExists(filepath.Join(installModel.cfg.Location, "test-driver-1.toml")) } -func (suite *SubcommandTestSuite) TestUninstallManifestOnlyDriver() { - m := InstallCmd{Driver: "test-driver-manifest-only", Level: suite.configLevel}. - GetModelCustom(testBaseModel()) - - suite.validateOutput("\r[✓] searching\r\n[✓] downloading\r\n[✓] installing\r\n[✓] verifying signature\r\n", - "\nInstalled test-driver-manifest-only 1.0.0 to "+suite.Dir()+ - "\n\nMust have libtest_driver installed to load this driver", suite.runCmd(m)) - suite.driverIsInstalled("test-driver-manifest-only", false) - - // Verify the sidecar folder exists before we uninstall - new_sidecar_path := fmt.Sprintf("test-driver-manifest-only_%s_v1.0.0", config.PlatformTuple()) - err := os.Rename(filepath.Join(suite.Dir(), "test-driver-manifest-only"), filepath.Join(suite.Dir(), new_sidecar_path)) - if err != nil { - suite.Fail(fmt.Sprintf("Failed to rename sidecar folder. Something is wrong with this test: %v", err)) - } - suite.DirExists(filepath.Join(suite.Dir(), new_sidecar_path)) - - // Now uninstall and verify we clean up - m = UninstallCmd{Driver: "test-driver-manifest-only", Level: suite.configLevel}. - GetModelCustom(testBaseModel()) - suite.validateOutput("\r ", "Driver `test-driver-manifest-only` uninstalled successfully!", suite.runCmd(m)) - suite.driverIsNotInstalled("test-driver-manifest-only") - suite.NoDirExists(filepath.Join(suite.Dir(), new_sidecar_path)) -} - -// See https://github.com/columnar-tech/dbc/issues/37 -func (suite *SubcommandTestSuite) TestUninstallInvalidManifest() { +func (suite *SubcommandTestSuite) TestUninstallLegacyPackageRemovesExactPayloadAndPreservesExternalFiles() { if runtime.GOOS == "windows" { suite.T().Skip() } - m := InstallCmd{Driver: "test-driver-invalid-manifest", Level: suite.configLevel}. - GetModelCustom(testBaseModel()) - suite.runCmd(m) - suite.FileExists(filepath.Join(suite.Dir(), "test-driver-invalid-manifest.toml")) - - // The installed manifest should have a Driver.shared set to a folder, not the .so - // We only need a partial struct definition to read in the Driver.shared table - type partialManifest struct { - Driver struct { - Shared map[string]string `toml:"shared"` - } - } - var invalidManifest partialManifest - f, err := os.Open(filepath.Join(suite.Dir(), "test-driver-invalid-manifest.toml")) - if err != nil { - suite.Error(err) - } - err = toml.NewDecoder(f).Decode(&invalidManifest) - if err != nil { - suite.Error(err) + suite.Require().NoError(os.MkdirAll(suite.Dir(), 0o755)) + packageDir := filepath.Join(suite.Dir(), "legacy-test-driver-install") + suite.Require().NoError(os.Mkdir(packageDir, 0o755)) + legacyLibrary := filepath.Join(packageDir, "libadbc_driver_invalid_manifest.so") + suite.Require().NoError(os.WriteFile(legacyLibrary, []byte("legacy package library"), 0o644)) + externalLibrary := filepath.Join(suite.Dir(), "libadbc_driver_invalid_manifest.so") + suite.Require().NoError(os.WriteFile(externalLibrary, []byte("external library"), 0o644)) + info := config.DriverInfo{ + ID: "test-driver-invalid-manifest", + Name: "Legacy Test Driver", + Version: semver.MustParse("1.0.0"), + Source: "dbc", } - value := invalidManifest.Driver.Shared[config.PlatformTuple()] - // Assert that it's a folder - suite.DirExists(value) - // and continue + info.Driver.Shared.Set(config.PlatformTuple(), legacyLibrary) + suite.Require().NoError(config.CreateManifest(config.Config{Level: suite.configLevel, Location: suite.Dir()}, info)) - m = UninstallCmd{Driver: "test-driver-invalid-manifest", Level: suite.configLevel}.GetModel() + m := UninstallCmd{Driver: "test-driver-invalid-manifest", Level: suite.configLevel}.GetModel() output := suite.runCmd(m) suite.validateOutput("\r ", "Driver `test-driver-invalid-manifest` uninstalled successfully!", output) - // Ensure we don't nuke the installation directory which is the original (major) issue suite.DirExists(suite.Dir()) - - // We do remove the manifest suite.NoFileExists(filepath.Join(suite.Dir(), "test-driver-invalid-manifest.toml")) - // But we don't remove the driver shared folder in this edge case, so we assert - // they're still around - suite.FileExists(filepath.Join(suite.Dir(), "test-driver-invalid-manifest", "libadbc_driver_invalid_manifest.so")) + suite.NoDirExists(packageDir) + suite.FileExists(externalLibrary) } func (suite *SubcommandTestSuite) TestUninstallRemovesSymlink() { @@ -287,3 +251,20 @@ func (suite *SubcommandTestSuite) TestUninstall_JSON() { suite.Equal("success", status.Status) suite.Equal("test-driver-1", status.Driver) } + +func TestUninstallCleanupErrorShowsRetainedGenerationPath(t *testing.T) { + generation := filepath.Join(t.TempDir(), ".dbc-package-g-6-driver-abc123") + cleanupErr := fmt.Errorf("driver registration was removed, but package cleanup failed; package files may remain under %s: remove package generation payload %s: permission denied", + filepath.Dir(generation), generation) + model := uninstallModel{} + updated, _ := model.Update(fmt.Errorf("failed to uninstall driver: %v", cleanupErr)) + + status, ok := updated.(HasStatus) + if !ok || status.Status() != 1 { + t.Fatalf("uninstall model status = %v, want failure", updated) + } + visibleError := formatErr(status.Err()) + if !strings.Contains(visibleError, generation) { + t.Fatalf("user-visible uninstall error %q does not include retained generation path %q", visibleError, generation) + } +} diff --git a/config/config.go b/config/config.go index a7b725fb..dfea119f 100644 --- a/config/config.go +++ b/config/config.go @@ -15,19 +15,17 @@ package config import ( - "archive/tar" - "compress/gzip" "errors" "fmt" "io" "io/fs" "maps" "os" - "path/filepath" "runtime" "slices" "strings" + "github.com/columnar-tech/dbc/internal/hostpath" "github.com/pelletier/go-toml/v2" ) @@ -118,18 +116,14 @@ func (c *ConfigLevel) UnmarshalText(b []byte) error { } func EnsureLocation(cfg Config) (string, error) { - loc := cfg.Location - if cfg.Level == ConfigEnv { - list := splitConfigList(loc) - if len(list) == 0 { - return "", errors.New("ADBC_DRIVER_PATH is empty, must be set to valid path to use") - } - loc = list[0] + loc := cfg.PrimaryLocation() + if cfg.Level == ConfigEnv && loc == "" { + return "", errors.New("ADBC_DRIVER_PATH is empty, must be set to valid path to use") } if _, err := os.Stat(loc); err != nil { if errors.Is(err, fs.ErrNotExist) { - if err := os.MkdirAll(loc, 0o755); err != nil { + if err := hostpath.MkdirAll(loc, 0o755); err != nil { return "", fmt.Errorf("failed to create config directory %s: %w", loc, err) } // Create a .gitignore with "*" in it. @@ -137,7 +131,7 @@ func EnsureLocation(cfg Config) (string, error) { // This depends on the if block it's in: We only want to create this file // if we also had to create `loc` in the same call. if cfg.Level == ConfigEnv { - gitignorePath := filepath.Join(loc, ".gitignore") + gitignorePath := hostpath.Join(loc, ".gitignore") _ = os.WriteFile(gitignorePath, []byte("*\n"), 0o644) } } else { @@ -155,7 +149,7 @@ func loadConfig(lvl ConfigLevel) Config { } if lvl == ConfigEnv { - pathList := filepath.SplitList(cfg.Location) + pathList := splitConfigList(cfg.Location) slices.Reverse(pathList) finalDrivers := make(map[string]DriverInfo) for _, p := range pathList { @@ -207,22 +201,30 @@ func FindDriverConfigsIn(location string) []DriverInfo { } func getEnvConfigDir() string { - envConfigLoc := filepath.SplitList(os.Getenv(adbcEnvVar)) + envConfigLoc := splitConfigList(os.Getenv(adbcEnvVar)) if venv := os.Getenv("VIRTUAL_ENV"); venv != "" { - envConfigLoc = append(envConfigLoc, filepath.Join(venv, "etc", "adbc", "drivers")) + envConfigLoc = append(envConfigLoc, hostpath.Join(venv, "etc", "adbc", "drivers")) } if conda := os.Getenv("CONDA_PREFIX"); conda != "" { - envConfigLoc = append(envConfigLoc, filepath.Join(conda, "etc", "adbc", "drivers")) + envConfigLoc = append(envConfigLoc, hostpath.Join(conda, "etc", "adbc", "drivers")) } envConfigLoc = slices.DeleteFunc(envConfigLoc, func(s string) bool { return s == "" }) - return strings.Join(envConfigLoc, string(filepath.ListSeparator)) + return strings.Join(envConfigLoc, string(hostpath.ListSeparator())) } +// InstallDriver extracts a package into the legacy archive-derived directory and returns its Manifest. +// It does not update runtime registration, create an install receipt, or participate in +// InstallPackage's transaction lock. Pairing it with CreateManifest is not one transaction. +// +// Deprecated: use InstallPackage for transactional package installation. +// +// TODO: InstallDriver has no production callers left in this repo; consider +// unexporting or removing it in a future breaking release. func InstallDriver(cfg Config, shortName string, downloaded *os.File) (Manifest, error) { var ( loc string @@ -231,10 +233,10 @@ func InstallDriver(cfg Config, shortName string, downloaded *os.File) (Manifest, if loc, err = EnsureLocation(cfg); err != nil { return Manifest{}, fmt.Errorf("could not ensure config location: %w", err) } - base := strings.TrimSuffix(strings.TrimSuffix(filepath.Base(downloaded.Name()), ".tar.gz"), ".tgz") - finalDir := filepath.Join(loc, base) + base := strings.TrimSuffix(strings.TrimSuffix(hostpath.Base(downloaded.Name()), ".tar.gz"), ".tgz") + finalDir := hostpath.Join(loc, base) - if err := os.MkdirAll(finalDir, 0o755); err != nil { + if err := hostpath.MkdirAll(finalDir, 0o755); err != nil { return Manifest{}, fmt.Errorf("failed to create driver directory %s: %w", finalDir, err) } @@ -243,70 +245,16 @@ func InstallDriver(cfg Config, shortName string, downloaded *os.File) (Manifest, return Manifest{}, fmt.Errorf("failed to extract tarball: %w", err) } - driverPath := filepath.Join(finalDir, manifest.Files.Driver) + driverPath := hostpath.Join(finalDir, manifest.Files.Driver) manifest.DriverInfo.ID = shortName manifest.DriverInfo.Source = "dbc" + manifest.DriverInfo.Driver.Shared = driverMap{} manifest.DriverInfo.Driver.Shared.Set(PlatformTuple(), driverPath) return manifest, nil } -// TODO: Unexport once we refactor sync.go. sync.go has it's own separate -// installation routine which it probably shouldn't. -func InflateTarball(f *os.File, outDir string) (Manifest, error) { - defer f.Close() - var m Manifest - - if _, err := f.Seek(0, io.SeekStart); err != nil { - return m, fmt.Errorf("could not seek to start: %w", err) - } - rdr, err := gzip.NewReader(f) - if err != nil { - return m, fmt.Errorf("could not create gzip reader: %w", err) - } - defer rdr.Close() - - t := tar.NewReader(rdr) - for { - hdr, err := t.Next() - if errors.Is(err, io.EOF) { - break - } - - if err != nil { - return m, fmt.Errorf("error reading tarball: %w", err) - } - - // Return a helpful error if an entry is a directory. dbc doesn't support - // installing driver tarballs that contain directories. - if hdr.Typeflag == tar.TypeDir { - return m, fmt.Errorf("found a directory entry when trying to extract %s which isn't supported. driver archives shouldn't contain subdirectories", f.Name()) - } - - if hdr.Name != "MANIFEST" { - next, err := os.Create(filepath.Join(outDir, hdr.Name)) - if err != nil { - return m, fmt.Errorf("could not create file %s: %w", hdr.Name, err) - } - - if _, err = io.Copy(next, t); err != nil { - next.Close() - return m, fmt.Errorf("could not write file from tarball %s: %w", hdr.Name, err) - } - next.Close() - } else { - m, err = decodeManifest(t, "", false) - if err != nil { - return m, fmt.Errorf("could not decode manifest: %w", err) - } - - } - } - - return m, nil -} - func decodeManifest(r io.Reader, driverName string, requireShared bool) (Manifest, error) { var di tomlDriverInfo if err := toml.NewDecoder(r).Decode(&di); err != nil { @@ -361,86 +309,3 @@ func decodeManifest(r io.Reader, driverName string, requireShared bool) (Manifes return result, nil } - -// Common, non-platform-specific code for uninstalling a driver. Called by -// platform-specific UninstallDriver function. -func UninstallDriverShared(info DriverInfo) error { - // For the User and System config levels, info.FilePath is set to the - // appropriate registry key instead of the filesystem on windows so we - // handle that here first. - filesystemLocation := info.FilePath - if strings.Contains(info.FilePath, "HKCU\\") { - filesystemLocation = ConfigUser.ConfigLocation() - } else if strings.Contains(info.FilePath, "HKLM\\") { - filesystemLocation = ConfigSystem.ConfigLocation() - } - - root, err := os.OpenRoot(filesystemLocation) - if err != nil { - return fmt.Errorf("error opening driver path %s: %w", info.FilePath, err) - } - defer root.Close() - - for sharedPath := range info.Driver.Shared.Paths() { - // Make sharedPath relative to info.FilePath and use it within root - // to ensure that nothing can escape the intended directory. - // (i.e. avoid malicious driver manifests) - sharedPath, err = filepath.Rel(filesystemLocation, sharedPath) - if err != nil { - // If we can't make it relative, something is wrong, skip - continue - } - - // dbc installs drivers in a folder, other tools may not so we handle each - // differently. - if info.Source == "dbc" { - sharedDir := filepath.Dir(sharedPath) - // Edge case when manifest is ill-formed: if sharedPath is set to the - // folder containing the shared library instead of the shared library - // itself, sharedDir is info.FilePath and we definitely don't want to - // remove that - if sharedDir == "." { - continue - } - - if err := root.RemoveAll(sharedDir); err != nil { - // Ignore only when not found. This supports manifest-only drivers. - // TODO: Come up with a better mechanism to handle manifest-only drivers - // and remove this continue when we do - if errors.Is(err, fs.ErrNotExist) { - continue - } - return fmt.Errorf("error removing driver %s: %w", info.ID, err) - } - } else { - if err := root.Remove(sharedPath); err != nil { - // Ignore only when not found. This supports manifest-only drivers. - // TODO: Come up with a better mechanism to handle manifest-only drivers - // and remove this continue when we do - if errors.Is(err, fs.ErrNotExist) { - continue - } - return fmt.Errorf("error removing driver %s: %w", info.ID, err) - } - } - } - - // Special handling to clean up manifest-only drivers - // - // Manifest only drivers can come with extra files such as a LICENSE and we - // create a folder next to the driver manifest to store them, same as we'd - // store the actual driver shared library. Above, we find the path of this - // folder by looking at the Driver.shared path. For manifest-only drivers, - // Driver.shared is not a valid path (it's just a name), so this trick doesn't - // work. We do want to clean this folder up so here we guess what it is and - // try to remove it e.g., "somedriver_macos_arm64_v1.2.3." - extraFolder := fmt.Sprintf("%s_%s_v%s", info.ID, platformTuple, info.Version) - extraFolder = filepath.Clean(extraFolder) - finfo, err := root.Stat(extraFolder) - if err == nil && finfo.IsDir() && extraFolder != "." { - _ = root.RemoveAll(extraFolder) - // ignore errors - } - - return nil -} diff --git a/config/config_api_test.go b/config/config_api_test.go index 8422caaf..19484f23 100644 --- a/config/config_api_test.go +++ b/config/config_api_test.go @@ -267,6 +267,20 @@ func TestCreateManifest(t *testing.T) { assert.FileExists(t, filepath.Join(newDir, "newdriver.toml")) }) + + t.Run("overwrites_malformed_existing_registration", func(t *testing.T) { + tmpDir := t.TempDir() + manifestPath := filepath.Join(tmpDir, "mydriver.toml") + require.NoError(t, os.WriteFile(manifestPath, []byte("[[[ invalid"), 0o644)) + + cfg := config.Config{Level: config.ConfigEnv, Location: tmpDir} + driver := makeTestDriverInfo("mydriver", tmpDir) + require.NoError(t, config.CreateManifest(cfg, driver)) + + loaded, err := config.GetDriver(cfg, "mydriver") + require.NoError(t, err) + assert.Equal(t, driver.Name, loaded.Name) + }) } func TestFindDriverConfigs(t *testing.T) { @@ -311,60 +325,3 @@ func TestFindDriverConfigs(t *testing.T) { assert.Empty(t, drivers) }) } - -func TestUninstallDriverShared(t *testing.T) { - t.Run("dbc_source_removes_driver_dir", func(t *testing.T) { - tmpDir := t.TempDir() - - driverDir := filepath.Join(tmpDir, "test-driver-1_linux_amd64_v1.0.0") - require.NoError(t, os.MkdirAll(driverDir, 0755)) - - driverPath := filepath.Join(driverDir, "driver.so") - require.NoError(t, os.WriteFile(driverPath, []byte("fake so"), 0644)) - - di := config.DriverInfo{ - ID: "test-driver-1", - FilePath: tmpDir, - Source: "dbc", - } - di.Driver.Shared.Set("linux_amd64", driverPath) - - err := config.UninstallDriverShared(di) - require.NoError(t, err) - - assert.NoDirExists(t, driverDir) - }) - - t.Run("non_dbc_source_removes_file_only", func(t *testing.T) { - tmpDir := t.TempDir() - - driverPath := filepath.Join(tmpDir, "driver.so") - require.NoError(t, os.WriteFile(driverPath, []byte("fake so"), 0644)) - - di := config.DriverInfo{ - ID: "external-driver", - FilePath: tmpDir, - Source: "external", - } - di.Driver.Shared.Set("linux_amd64", driverPath) - - err := config.UninstallDriverShared(di) - require.NoError(t, err) - - assert.NoFileExists(t, driverPath) - }) - - t.Run("missing_file_is_tolerated_for_non_dbc", func(t *testing.T) { - tmpDir := t.TempDir() - - di := config.DriverInfo{ - ID: "missing-driver", - FilePath: tmpDir, - Source: "external", - } - di.Driver.Shared.Set("linux_amd64", filepath.Join(tmpDir, "nonexistent.so")) - - err := config.UninstallDriverShared(di) - assert.NoError(t, err) - }) -} diff --git a/config/config_location.go b/config/config_location.go new file mode 100644 index 00000000..5ae8beb7 --- /dev/null +++ b/config/config_location.go @@ -0,0 +1,29 @@ +// 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. + +package config + +// PrimaryLocation returns the first filesystem root used by this configuration. +// ConfigEnv may contain several roots on native platforms. Wasm preserves its +// single-location path contract through splitConfigList. +func (cfg Config) PrimaryLocation() string { + if cfg.Level != ConfigEnv { + return cfg.Location + } + locations := splitConfigList(cfg.Location) + if len(locations) == 0 { + return "" + } + return locations[0] +} diff --git a/config/config_location_test.go b/config/config_location_test.go new file mode 100644 index 00000000..c8365988 --- /dev/null +++ b/config/config_location_test.go @@ -0,0 +1,54 @@ +// 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. + +package config + +import ( + "path/filepath" + "runtime" + "testing" +) + +func TestConfigPrimaryLocation(t *testing.T) { + t.Run("empty environment location", func(t *testing.T) { + if got := (Config{Level: ConfigEnv}).PrimaryLocation(); got != "" { + t.Fatalf("PrimaryLocation() = %q, want empty", got) + } + }) + + t.Run("non-environment location is unchanged", func(t *testing.T) { + location := "first" + string(filepath.ListSeparator) + "second" + for _, level := range []ConfigLevel{ConfigUser, ConfigSystem} { + if got := (Config{Level: level, Location: location}).PrimaryLocation(); got != location { + t.Errorf("level %s PrimaryLocation() = %q, want %q", level, got, location) + } + } + }) + + if runtime.GOOS == "js" { + for _, location := range []string{`C:\dbc:drivers;secondary`, `/tmp/dbc:drivers;secondary`} { + if got := (Config{Level: ConfigEnv, Location: location}).PrimaryLocation(); got != location { + t.Errorf("Wasm PrimaryLocation() = %q, want whole path %q", got, location) + } + } + return + } + + t.Run("native environment list uses first root", func(t *testing.T) { + location := "first" + string(filepath.ListSeparator) + "second" + if got := (Config{Level: ConfigEnv, Location: location}).PrimaryLocation(); got != "first" { + t.Fatalf("native PrimaryLocation() = %q, want first", got) + } + }) +} diff --git a/config/dirs_unixlike.go b/config/dirs_unixlike.go index 8e019f0f..1c791612 100644 --- a/config/dirs_unixlike.go +++ b/config/dirs_unixlike.go @@ -17,12 +17,15 @@ package config import ( + "context" + "errors" "fmt" "maps" "os" "path/filepath" "runtime" "slices" + "time" ) const ( @@ -109,23 +112,15 @@ func CreateManifest(cfg Config, driver DriverInfo) (err error) { if err != nil { return err } - return createDriverManifest(loc, driver) -} - -func UninstallDriver(_ Config, info DriverInfo) error { - manifest := filepath.Join(info.FilePath, info.ID+".toml") - if err := os.Remove(manifest); err != nil { - return fmt.Errorf("error removing manifest %s: %w", manifest, err) + lock, err := acquireDriverInstallLockWith(context.Background(), loc, driver.ID, 10*time.Second) + if err != nil { + return fmt.Errorf("acquire driver registration lock: %w", err) } - - // Remove the symlink created during installation (one level up from the - // manifest) - // TODO: Remove this when the driver managers are fixed (>=1.8.1). - removeManifestSymlink(info.FilePath, info.ID) - - if err := UninstallDriverShared(info); err != nil { - return fmt.Errorf("failed to delete driver shared object: %w", err) + defer func() { err = errors.Join(err, lock.release()) }() + namespaceLock, _, err := acquireRegistrationNamespaceLock(context.Background(), cfg, loc, 10*time.Second) + if err != nil { + return err } - - return nil + defer func() { err = errors.Join(err, namespaceLock.release()) }() + return createDriverManifestUnlocked(loc, driver) } diff --git a/config/dirs_windows.go b/config/dirs_windows.go index a20b5ff7..262cc5a0 100644 --- a/config/dirs_windows.go +++ b/config/dirs_windows.go @@ -15,6 +15,8 @@ package config import ( + "context" + "encoding/binary" "errors" "fmt" "log" @@ -22,10 +24,13 @@ import ( "os" "path/filepath" "slices" + "time" + "unsafe" "github.com/Masterminds/semver/v3" "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" ) @@ -110,18 +115,6 @@ func keyOptional(k registry.Key, name string) string { return val } -func setKeyMust(k registry.Key, name, value string) { - if err := k.SetStringValue(name, value); err != nil { - panic(err) - } -} - -func setKeyIntMust(k registry.Key, name string, value uint32) { - if err := k.SetDWordValue(name, value); err != nil { - panic(err) - } -} - func driverInfoFromKey(k registry.Key, driverName string, lvl ConfigLevel) (di DriverInfo, err error) { dkey, err := registry.OpenKey(k, driverName, registry.READ) if err != nil { @@ -262,95 +255,243 @@ func GetDriver(cfg Config, driverName string) (DriverInfo, error) { } func CreateManifest(cfg Config, driver DriverInfo) (err error) { + location, err := registrationLockLocation(cfg) + if err != nil { + return err + } + if err := prepareRegistrationLockLocation(cfg, location); err != nil { + return fmt.Errorf("prepare driver registration lock location: %w", err) + } + lock, err := acquireDriverInstallLockWith(context.Background(), location, driver.ID, 10*time.Second) + if err != nil { + return fmt.Errorf("acquire driver registration lock: %w", err) + } + defer func() { err = errors.Join(err, lock.release()) }() + namespaceLock, _, err := acquireRegistrationNamespaceLock(context.Background(), cfg, location, 10*time.Second) + if err != nil { + return err + } + defer func() { err = errors.Join(err, namespaceLock.release()) }() + if cfg.Level == ConfigEnv { - if cfg.Location == "" { - return fmt.Errorf("cannot write manifest to env config without %s set", adbcEnvVar) + return createDriverManifestUnlocked(location, driver) + } + return createRegistryManifestUnlocked(cfg, driver) +} + +func createRegistryManifestUnlocked(cfg Config, driver DriverInfo) error { + changes, err := registryRegistrationChanges(driver) + if err != nil { + return err + } + root := cfg.Level.key() + driversKey, err := registry.OpenKey(root, regKeyADBC, registry.ALL_ACCESS) + var adbcHandle *registryKeyHandle + var driversHandle *registryKeyHandle + var adbcCreated, driversCreated bool + if err == nil { + driversHandle = ®istryKeyHandle{key: driversKey} + } else if errors.Is(err, registry.ErrNotExist) { + adbcKey, adbcOpenedExisting, createErr := registry.CreateKey(root, "SOFTWARE\\ADBC", registry.ALL_ACCESS) + if createErr != nil { + return createErr } - loc, err := EnsureLocation(cfg) - if err != nil { - return err + adbcCreated = registryKeyWasCreated(adbcOpenedExisting) + adbcHandle = ®istryKeyHandle{key: adbcKey} + defer adbcHandle.close() + + var driversOpenedExisting bool + driversKey, driversOpenedExisting, createErr = registry.CreateKey(adbcKey, "Drivers", registry.ALL_ACCESS) + if createErr != nil { + return rollbackCreatedRegistryKeys(createErr, func() error { + return deleteCreatedRegistryKey(root, "SOFTWARE\\ADBC", adbcHandle, adbcCreated) + }) } - return createDriverManifest(loc, driver) + driversCreated = registryKeyWasCreated(driversOpenedExisting) + driversHandle = ®istryKeyHandle{key: driversKey} + } else { + return err } + defer driversHandle.close() - var k registry.Key + driverKey, driverOpenedExisting, err := registry.CreateKey(driversHandle.key, driver.ID, registry.ALL_ACCESS) + if err != nil { + var cleanup []func() error + if adbcHandle != nil { + cleanup = append(cleanup, func() error { + return deleteCreatedRegistryKey(adbcHandle.key, "Drivers", driversHandle, driversCreated) + }) + } + if adbcCreated { + cleanup = append(cleanup, func() error { + return deleteCreatedRegistryKey(root, "SOFTWARE\\ADBC", adbcHandle, true) + }) + } + return rollbackCreatedRegistryKeys(err, cleanup...) + } + driverCreated := registryKeyWasCreated(driverOpenedExisting) + driverHandle := ®istryKeyHandle{key: driverKey} + defer driverHandle.close() - if !cfg.Exists { - k, _, err = registry.CreateKey(cfg.Level.key(), "SOFTWARE\\ADBC", registry.ALL_ACCESS) - if err != nil { - return err + rollbackKey := func() error { + if !driverCreated { + return nil + } + cleanup := []func() error{ + func() error { return deleteCreatedRegistryKey(driversHandle.key, driver.ID, driverHandle, true) }, + } + if adbcHandle != nil && driversCreated { + cleanup = append(cleanup, func() error { + return deleteCreatedRegistryKey(adbcHandle.key, "Drivers", driversHandle, true) + }) } - defer k.Close() + if adbcCreated { + cleanup = append(cleanup, func() error { + return deleteCreatedRegistryKey(root, "SOFTWARE\\ADBC", adbcHandle, true) + }) + } + return cleanupRegistryKeys(cleanup...) + } + store := windowsRegistrationValueStore{key: driverHandle.key} + if driverCreated { + return updateRegistrationValuesWithRollback(store, changes, rollbackKey) + } + return updateRegistrationValues(store, changes) +} + +type windowsRegistrationValueStore struct { + key registry.Key +} + +type registryKeyHandle struct { + key registry.Key + closed bool + closeFn func() error +} + +func (h *registryKeyHandle) close() error { + if h == nil || h.closed { + return nil + } + h.closed = true + if h.closeFn != nil { + return h.closeFn() + } + return h.key.Close() +} - k, _, err = registry.CreateKey(k, "Drivers", registry.ALL_ACCESS) +func registryRegistrationChanges(driver DriverInfo) ([]registrationValueChange, error) { + if driver.Version == nil { + return nil, errors.New("driver version is required for registry registration") + } + values := []struct { + name string + value string + }{ + {name: "name", value: driver.Name}, + {name: "publisher", value: driver.Publisher}, + {name: "license", value: driver.License}, + {name: "version", value: driver.Version.String()}, + {name: "source", value: driver.Source}, + {name: "driver", value: driver.Driver.Shared.Get(PlatformTuple())}, + } + changes := make([]registrationValueChange, 0, len(values)+2) + for _, value := range values { + encoded, err := registryStringValue(value.value) if err != nil { - return err + return nil, fmt.Errorf("encode registry value %q: %w", value.name, err) } - defer k.Close() - } else { - k, err = registry.OpenKey(cfg.Level.key(), regKeyADBC, registry.ALL_ACCESS) + changes = append(changes, registrationValueChange{name: value.name, value: encoded}) + } + changes = append(changes, registrationValueChange{ + name: "manifest_version", value: registryDWordValue(currentManifestVersion), + }) + entrypoint := registrationValue{} + if driver.Driver.Entrypoint != "" { + var err error + entrypoint, err = registryStringValue(driver.Driver.Entrypoint) if err != nil { - return err + return nil, fmt.Errorf("encode registry value %q: %w", "entrypoint", err) } - defer k.Close() } + changes = append(changes, registrationValueChange{name: "entrypoint", value: entrypoint}) + return changes, nil +} - dkey, _, err := registry.CreateKey(k, driver.ID, registry.ALL_ACCESS) - if err != nil { - return err +func (s windowsRegistrationValueStore) read(name string) (registrationValue, error) { + size, kind, err := s.key.GetValue(name, nil) + if errors.Is(err, registry.ErrNotExist) { + return registrationValue{}, nil } - defer dkey.Close() - - defer func() { - if r := recover(); r != nil { - switch r := r.(type) { - case string: - err = errors.New(r) - case error: - err = r - default: - err = fmt.Errorf("unknown error type: %v", r) - } + if err != nil && !errors.Is(err, windows.ERROR_MORE_DATA) { + return registrationValue{}, err + } + data := make([]byte, size) + actualKind := kind + if size > 0 { + n, readKind, err := s.key.GetValue(name, data) + if err != nil { + return registrationValue{}, err } - }() + if n < 0 || n > len(data) { + return registrationValue{}, fmt.Errorf("registry value %q changed size while being read", name) + } + data = data[:n] + actualKind = readKind + } + return registrationValue{exists: true, kind: actualKind, data: data}, nil +} - setKeyMust(dkey, "name", driver.Name) - setKeyIntMust(dkey, "manifest_version", currentManifestVersion) - setKeyMust(dkey, "publisher", driver.Publisher) - setKeyMust(dkey, "license", driver.License) - setKeyMust(dkey, "version", driver.Version.String()) - setKeyMust(dkey, "source", driver.Source) - setKeyMust(dkey, "driver", driver.Driver.Shared.Get(PlatformTuple())) - if driver.Driver.Entrypoint != "" { - setKeyMust(dkey, "entrypoint", driver.Driver.Entrypoint) +func (s windowsRegistrationValueStore) write(name string, value registrationValue) error { + namePointer, err := windows.UTF16PtrFromString(name) + if err != nil { + return err + } + var dataPointer *byte + if len(value.data) > 0 { + dataPointer = &value.data[0] + } + result, _, _ := windows.NewLazySystemDLL("advapi32.dll").NewProc("RegSetValueExW").Call( + uintptr(s.key), uintptr(unsafe.Pointer(namePointer)), 0, uintptr(value.kind), + uintptr(unsafe.Pointer(dataPointer)), uintptr(len(value.data)), + ) + if result != 0 { + return windows.Errno(result) } return nil } -func UninstallDriver(cfg Config, info DriverInfo) error { - if err := UninstallDriverShared(info); err != nil { - return fmt.Errorf("failed to delete driver shared object: %w", err) +func (s windowsRegistrationValueStore) delete(name string) error { + err := s.key.DeleteValue(name) + if errors.Is(err, registry.ErrNotExist) { + return nil } + return err +} - if cfg.Level != ConfigEnv { - k, err := registry.OpenKey(cfg.Level.key(), regKeyADBC, registry.ALL_ACCESS) - if err != nil { - return err - } - defer k.Close() +func registryStringValue(value string) (registrationValue, error) { + encoded, err := windows.UTF16FromString(value) + if err != nil { + return registrationValue{}, fmt.Errorf("invalid registry string value: %w", err) + } + data := make([]byte, len(encoded)*2) + for i, unit := range encoded { + binary.LittleEndian.PutUint16(data[i*2:], unit) + } + return registrationValue{exists: true, kind: registry.SZ, data: data}, nil +} - if err := registry.DeleteKey(k, info.ID); err != nil { - return fmt.Errorf("failed to delete driver registry key: %w", err) - } - } else { - manifest := filepath.Join(info.FilePath, info.ID+".toml") - if err := os.Remove(manifest); err != nil { - return fmt.Errorf("error removing manifest %s: %w", manifest, err) - } +func registryDWordValue(value uint32) registrationValue { + data := make([]byte, 4) + binary.LittleEndian.PutUint32(data, value) + return registrationValue{exists: true, kind: registry.DWORD, data: data} +} - // TODO: Remove this when the driver managers are fixed (>=1.8.1). - removeManifestSymlink(info.FilePath, info.ID) +func deleteCreatedRegistryKey(parent registry.Key, name string, key *registryKeyHandle, created bool) error { + if !created { + return nil } - - return nil + closeErr := key.close() + deleteErr := registry.DeleteKey(parent, name) + return errors.Join(closeErr, deleteErr) } diff --git a/config/driver.go b/config/driver.go index c53b7767..c088a022 100644 --- a/config/driver.go +++ b/config/driver.go @@ -20,10 +20,11 @@ import ( "io/fs" "iter" "os" - "path/filepath" "strings" "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc/internal/atomicfile" + "github.com/columnar-tech/dbc/internal/hostpath" "github.com/pelletier/go-toml/v2" ) @@ -148,7 +149,7 @@ type tomlDriverInfo struct { func loadDriverFromManifest(prefix, driverName string) (DriverInfo, error) { driverName = strings.TrimSuffix(driverName, ".toml") - manifest := filepath.Join(prefix, driverName+".toml") + manifest := hostpath.Join(prefix, driverName+".toml") f, err := os.Open(manifest) if err != nil { return DriverInfo{}, fmt.Errorf("error opening manifest %s: %w", manifest, err) @@ -166,50 +167,34 @@ func loadDriverFromManifest(prefix, driverName string) (DriverInfo, error) { // Create a symlink to manifestPath in the parent dir func createManifestSymlink(location, driverID, manifestPath string) { - parentDir := filepath.Dir(filepath.Clean(location)) - safeDriverID := filepath.Base(driverID) - symlink := filepath.Join(parentDir, safeDriverID+".toml") + parentDir := hostpath.Dir(hostpath.Clean(location)) + safeDriverID := hostpath.Base(driverID) + symlink := hostpath.Join(parentDir, safeDriverID+".toml") - if filepath.Dir(symlink) == parentDir { + if hostpath.Dir(symlink) == parentDir { os.Symlink(manifestPath, symlink) } } // Remove the symlink to manifestPath in the parent dir func removeManifestSymlink(filePath, driverID string) { - parentDir := filepath.Dir(filepath.Clean(filePath)) - safeDriverID := filepath.Base(driverID) - symlink := filepath.Join(parentDir, safeDriverID+".toml") + parentDir := hostpath.Dir(hostpath.Clean(filePath)) + safeDriverID := hostpath.Base(driverID) + symlink := hostpath.Join(parentDir, safeDriverID+".toml") - if filepath.Dir(symlink) == parentDir { + if hostpath.Dir(symlink) == parentDir { os.Remove(symlink) } } -func createDriverManifest(location string, driver DriverInfo) error { +func createDriverManifestUnlocked(location string, driver DriverInfo) error { if _, err := os.Stat(location); errors.Is(err, fs.ErrNotExist) { - if err := os.MkdirAll(location, 0755); err != nil { + if err := hostpath.MkdirAll(location, 0755); err != nil { return fmt.Errorf("error creating driver location %s: %w", location, err) } } - manifestPath := filepath.Join(location, driver.ID+".toml") - f, err := os.Create(manifestPath) - if err != nil { - return fmt.Errorf("error creating manifest %s: %w", driver.ID, err) - } - defer f.Close() - - // Workaround for bug in Python driver manager packages. Version 1.8.0 of the - // packages use the old ADBC_CONFIG_PATH path we originally had and not the - // new ADBC_DRIVER_PATH (e.g., /etc/adbc instead of /etc/adbc/drivers). - // - // To work around this, we create a symlink on level up to the manifest we're - // installing. - // - // TODO: Remove this when the driver managers are fixed (>=1.8.1). - createManifestSymlink(location, driver.ID, manifestPath) - + manifestPath := hostpath.Join(location, driver.ID+".toml") toEncode := tomlDriverInfo{ ManifestVersion: currentManifestVersion, Name: driver.Name, @@ -227,11 +212,24 @@ func createDriverManifest(location string, driver DriverInfo) error { toEncode.Driver.Shared = driver.Driver.Shared.platformMap } - enc := toml.NewEncoder(f).SetIndentTables(false) - + var data strings.Builder + enc := toml.NewEncoder(&data).SetIndentTables(false) if err := enc.Encode(toEncode); err != nil { return fmt.Errorf("error encoding manifest %s: %w", driver.ID, err) } + if err := atomicfile.WriteFilePreservingMode(manifestPath, []byte(data.String()), 0666); err != nil { + return fmt.Errorf("error writing manifest %s: %w", driver.ID, err) + } + + // Workaround for bug in Python driver manager packages. Version 1.8.0 of the + // packages use the old ADBC_CONFIG_PATH path we originally had and not the + // new ADBC_DRIVER_PATH (e.g., /etc/adbc instead of /etc/adbc/drivers). + // + // To work around this, we create a symlink on level up to the manifest we're + // installing. + // + // TODO: Remove this when the driver managers are fixed (>=1.8.1). + createManifestSymlink(location, driver.ID, manifestPath) return nil } diff --git a/config/driver_install_lock.go b/config/driver_install_lock.go new file mode 100644 index 00000000..199cf2a5 --- /dev/null +++ b/config/driver_install_lock.go @@ -0,0 +1,224 @@ +// 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. + +package config + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "os" + "reflect" + "runtime" + "strings" + "sync" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc/internal/fslock" + "github.com/columnar-tech/dbc/internal/hostpath" +) + +var errDriverRegistrationChanged = errors.New("driver registration changed") + +// driverInstallLock serializes driver mutations that acquire it for a specific +// installation location. +type driverInstallLock struct { + releaseFn func() error + releaseOnce sync.Once + releaseErr error +} + +// release releases the driver lock. +func (l *driverInstallLock) release() error { + if l == nil || l.releaseFn == nil { + return nil + } + l.releaseOnce.Do(func() { l.releaseErr = l.releaseFn() }) + return l.releaseErr +} + +// acquireDriverInstallLockWith acquires the lock identified by location and +// runtimeID. Native non-Windows builds use the reserved auxiliary filename +// ..dbc directly under location; Windows and JavaScript/Wasm use a +// hashed filename directly under location. +func acquireDriverInstallLockWith(ctx context.Context, location, runtimeID string, timeout time.Duration) (*driverInstallLock, error) { + if location == "" { + return nil, errors.New("driver lock location is empty") + } + if runtimeID == "" { + return nil, errors.New("driver lock runtime ID is empty") + } + lockPath, err := driverInstallLockPath(location, runtimeID) + if err != nil { + return nil, err + } + if nativeRuntimeFileLock() { + if err := inspectNativeDriverInstallLockPath(lockPath); err != nil { + return nil, err + } + } + return acquireDriverInstallLock(ctx, lockPath, timeout) +} + +// acquireDriverInstallLock takes the fslock for path. On JavaScript/Wasm it +// only guarantees exclusion within one Go/Wasm runtime, not across separate +// Wasm instances or Node workers. +func acquireDriverInstallLock(ctx context.Context, path string, timeout time.Duration) (*driverInstallLock, error) { + lock, err := fslock.AcquireContext(ctx, path, timeout) + if err != nil { + return nil, err + } + return &driverInstallLock{releaseFn: lock.Release}, nil +} + +func nativeRuntimeFileLock() bool { + return !hostpath.IsWindows() && runtime.GOOS != "js" +} + +// Native per-driver locks reserve root-local ..dbc auxiliary files. +// They do not collide with the registration scanner's .toml files. This is a +// cooperative-lock protocol and does not defend against a noncooperating actor +// replacing a checked path between inspection and open. +func inspectNativeDriverInstallLockPath(path string) error { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("inspect driver lock path %s: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() != 0 { + return fmt.Errorf("driver lock path %s collides with existing nonempty or nonregular data", path) + } + return nil +} + +func driverInstallLockPath(location, runtimeID string) (string, error) { + canonicalLocation, err := hostpath.Abs(location) + if err != nil { + return "", fmt.Errorf("resolve driver lock location: %w", err) + } + canonicalLocation = hostpath.Clean(canonicalLocation) + if nativeRuntimeFileLock() { + if err := validatePackageFilename(runtimeID); err != nil { + return "", fmt.Errorf("invalid driver lock runtime ID: %w", err) + } + return hostpath.Join(canonicalLocation, "."+runtimeID+".dbc"), nil + } + canonicalID := runtimeID + if hostpath.IsWindows() { + canonicalLocation = strings.ToLower(canonicalLocation) + canonicalID = strings.ToLower(canonicalID) + } + key := sha256.Sum256([]byte(canonicalID)) + return hostpath.Join(canonicalLocation, fmt.Sprintf(".dbc.install.%x.lock", key)), nil +} + +// UninstallDriver removes a driver's runtime registration. It also removes +// package payloads when dbc can establish ownership; external registrations +// are removed without deleting their shared library files. +func UninstallDriver(cfg Config, info DriverInfo) (err error) { + location, err := uninstallLockLocation(cfg, info) + if err != nil { + return err + } + if err := prepareDriverUninstallLockLocation(cfg, location); err != nil { + return fmt.Errorf("prepare driver uninstall lock location: %w", err) + } + lock, err := acquireDriverInstallLockWith(context.Background(), location, info.ID, 10*time.Second) + if err != nil { + return fmt.Errorf("acquire driver uninstall lock: %w", err) + } + defer func() { err = errors.Join(err, lock.release()) }() + namespaceLock, registrationLocation, err := acquireRegistrationNamespaceLock(context.Background(), cfg, location, 10*time.Second) + if err != nil { + return err + } + defer func() { err = errors.Join(err, namespaceLock.release()) }() + + current, err := readDriverRegistrationForUninstall(cfg, info) + if err != nil { + return fmt.Errorf("re-read driver registration before uninstall: %w", err) + } + if !sameDriverRegistration(info, current) { + return fmt.Errorf("driver %q changed since it was selected; refusing to uninstall: %w", info.ID, errDriverRegistrationChanged) + } + registrations, certain, _ := collectRegistrationSharedMapsExcluding(cfg, registrationLocation, current.FilePath, current.ID) + return uninstallDriverUnlockedWithReferences(cfg, current, registrations, certain) +} + +func uninstallDriverUnlockedWithReferences(cfg Config, info DriverInfo, otherRegistrations []driverMap, referencesCertain bool) error { + return uninstallDriverUnlockedWithCleanupAndReferences(cfg, info, otherRegistrations, referencesCertain, packageCleanupOperations{}) +} + +func uninstallDriverUnlockedWithCleanupAndReferences(cfg Config, info DriverInfo, otherRegistrations []driverMap, referencesCertain bool, operations packageCleanupOperations) error { + if info.Source == "dbc" { + root, err := packageCleanupRoot(cfg, info) + if err != nil { + return err + } + if err := removeDriverRegistration(cfg, info, operations); err != nil { + return err + } + if err := cleanupInstalledPackageWithReferences(cfg, root, info, otherRegistrations, referencesCertain, operations); err != nil { + return fmt.Errorf("driver registration was removed, but package cleanup failed; package files may remain under %s: %w", root, err) + } + return nil + } + return removeDriverRegistration(cfg, info, operations) +} + +func sameDriverRegistration(a, b DriverInfo) bool { + return a.ID == b.ID && canonicalRegistrationPath(a.FilePath) == canonicalRegistrationPath(b.FilePath) && + a.Name == b.Name && a.Publisher == b.Publisher && a.License == b.License && + sameVersion(a.Version, b.Version) && a.Source == b.Source && + sameVersion(a.AdbcInfo.Version, b.AdbcInfo.Version) && + reflect.DeepEqual(a.AdbcInfo.Features, b.AdbcInfo.Features) && + a.Driver.Entrypoint == b.Driver.Entrypoint && sameDriverMap(a.Driver.Shared, b.Driver.Shared) +} + +func sameVersion(a, b *semver.Version) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return a.String() == b.String() +} + +func canonicalRegistrationPath(path string) string { + if path == "" { + return "" + } + clean, err := hostpath.Abs(hostpath.Clean(path)) + if err != nil { + clean = hostpath.Clean(path) + } + if hostpath.IsWindows() { + clean = strings.ToLower(clean) + } + return clean +} + +func sameDriverMap(a, b driverMap) bool { + if a.defaultPath != b.defaultPath || len(a.platformMap) != len(b.platformMap) { + return false + } + for platform, path := range a.platformMap { + if b.platformMap[platform] != path { + return false + } + } + return true +} diff --git a/config/driver_install_lock_native_test.go b/config/driver_install_lock_native_test.go new file mode 100644 index 00000000..29fc4de4 --- /dev/null +++ b/config/driver_install_lock_native_test.go @@ -0,0 +1,145 @@ +// 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 && !js + +package config + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestNativeDriverInstallLockUsesRuntimeIDFilename(t *testing.T) { + root := t.TempDir() + path, err := driverInstallLockPath(root, "driver") + if err != nil { + t.Fatal(err) + } + want := filepath.Join(root, ".driver.dbc") + if path != want { + t.Fatalf("driver lock path = %q, want %q", path, want) + } + if _, err := driverInstallLockPath(root, "../driver"); err == nil { + t.Fatal("driver lock path accepted a runtime ID with path traversal") + } + + lock, err := acquireDriverInstallLockWith(context.Background(), root, "driver", time.Second) + if err != nil { + t.Fatal(err) + } + lockInfo, err := os.Stat(path) + if err != nil { + t.Fatalf("native lock file missing while held: %v", err) + } + if err := lock.release(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, ".dbc.install-locks")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("private lock directory unexpectedly exists: %v", err) + } + releasedInfo, err := os.Stat(path) + if err != nil { + t.Fatalf("persistent native lock file missing after release: %v", err) + } + if !os.SameFile(lockInfo, releasedInfo) { + t.Fatal("native lock file was replaced or unlinked during release") + } + if !releasedInfo.Mode().IsRegular() || releasedInfo.Size() != 0 { + t.Fatalf("persistent native lock file changed: mode=%v size=%d", releasedInfo.Mode(), releasedInfo.Size()) + } +} + +func TestNativeDriverInstallLockRejectsExistingCollisions(t *testing.T) { + for _, kind := range []string{"nonempty file", "directory symlink"} { + t.Run(kind, func(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, ".driver.dbc") + target := t.TempDir() + if kind == "nonempty file" { + if err := os.WriteFile(path, []byte("preserve"), 0o600); err != nil { + t.Fatal(err) + } + } else if err := os.Symlink(target, path); err != nil { + t.Skipf("symlink creation unavailable: %v", err) + } + + if _, err := acquireDriverInstallLockWith(context.Background(), root, "driver", 0); err == nil { + t.Fatal("acquired lock through an existing data collision") + } + if kind == "nonempty file" { + if data, err := os.ReadFile(path); err != nil || string(data) != "preserve" { + t.Fatalf("collision data changed: %q, %v", data, err) + } + } else { + info, err := os.Lstat(path) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("collision symlink changed: %v, %v", info, err) + } + if entries, err := os.ReadDir(target); err != nil || len(entries) != 0 { + t.Fatalf("symlink target changed: entries=%v, err=%v", entries, err) + } + } + }) + } +} + +func TestNativeDriverInstallLockDoesNotConfuseRegistrationScanner(t *testing.T) { + root := t.TempDir() + registration := "manifest_version = 1\nname = \"Foo\"\nversion = \"1.0.0\"\nsource = \"external\"\n[Driver]\nshared = \"library.so\"\n" + if err := os.WriteFile(filepath.Join(root, "foo.toml"), []byte(registration), 0o600); err != nil { + t.Fatal(err) + } + lock, err := acquireDriverInstallLockWith(context.Background(), root, "foo", time.Second) + if err != nil { + t.Fatal(err) + } + defer lock.release() + if _, err := os.Stat(filepath.Join(root, ".foo.dbc")); err != nil { + t.Fatalf("native per-driver lock file is missing while held: %v", err) + } + shared, certain, err := collectFileRegistrationSharedMaps(root, "") + if err != nil || !certain || len(shared) != 1 { + t.Fatalf("registration scan with a held lock = %#v, certain=%t, err=%v", shared, certain, err) + } +} + +func TestNativeDriverInstallLockSupportsMaximumRegistrationFilename(t *testing.T) { + root := t.TempDir() + runtimeID := strings.Repeat("a", 250) + registrationPath := filepath.Join(root, runtimeID+".toml") + if err := os.WriteFile(registrationPath, nil, 0o600); err != nil { + t.Fatalf("create maximum-length registration filename: %v", err) + } + lockPath, err := driverInstallLockPath(root, runtimeID) + if err != nil { + t.Fatal(err) + } + if len(filepath.Base(lockPath)) != len(filepath.Base(registrationPath)) { + t.Fatalf("lock filename length = %d, registration filename length = %d", len(filepath.Base(lockPath)), len(filepath.Base(registrationPath))) + } + lock, err := acquireDriverInstallLockWith(context.Background(), root, runtimeID, time.Second) + if err != nil { + t.Fatalf("acquire maximum-length runtime ID lock: %v", err) + } + defer lock.release() + if _, err := os.Stat(lockPath); err != nil { + t.Fatalf("maximum-length lock file was not created: %v", err) + } +} diff --git a/config/driver_install_lock_test.go b/config/driver_install_lock_test.go new file mode 100644 index 00000000..cea3431e --- /dev/null +++ b/config/driver_install_lock_test.go @@ -0,0 +1,188 @@ +// 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 ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc/internal/fslock" +) + +func TestAcquireDriverInstallLockUsesRuntimeIdentity(t *testing.T) { + location := t.TempDir() + first, err := acquireDriverInstallLockWith(context.Background(), location, "MyDriver", time.Second) + if err != nil { + t.Fatal(err) + } + defer first.release() + _, err = acquireDriverInstallLockWith(context.Background(), location, "MyDriver", 20*time.Millisecond) + if !errors.Is(err, fslock.ErrLockContended) { + t.Fatalf("same-driver lock error = %v, want contention", err) + } + other, err := acquireDriverInstallLockWith(context.Background(), location, "other-driver", time.Second) + if err != nil { + t.Fatalf("different driver should use a separate lock: %v", err) + } + if err := other.release(); err != nil { + t.Fatal(err) + } + + original, err := driverInstallLockPath(location, "MyDriver") + if err != nil { + t.Fatal(err) + } + caseVariant, err := driverInstallLockPath(strings.ToUpper(location), "mydriver") + if err != nil { + t.Fatal(err) + } + if original == caseVariant { + t.Fatalf("case-sensitive runtime lock paths unexpectedly match: %q", original) + } +} + +func TestFileRegistrationNamespaceUsesNormalizedDirectoryIdentity(t *testing.T) { + location := t.TempDir() + firstIdentity, firstDirectory, firstRegistration, err := registrationNamespaceLockSpec(Config{Level: ConfigEnv}, location) + if err != nil { + t.Fatal(err) + } + secondLocation := filepath.Join(location, "nested", "..") + secondIdentity, secondDirectory, secondRegistration, err := registrationNamespaceLockSpec(Config{Level: ConfigEnv}, secondLocation) + if err != nil { + t.Fatal(err) + } + if firstIdentity != secondIdentity || firstDirectory != secondDirectory || firstRegistration != secondRegistration { + t.Fatalf("normalized file namespace differs: (%q, %q, %q) != (%q, %q, %q)", firstIdentity, firstDirectory, firstRegistration, secondIdentity, secondDirectory, secondRegistration) + } + otherIdentity, _, _, err := registrationNamespaceLockSpec(Config{Level: ConfigEnv}, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if otherIdentity == firstIdentity { + t.Fatal("different file registration directories share a namespace") + } +} + +func TestFileRegistrationNamespaceSymlinkAliasesShareLock(t *testing.T) { + real := t.TempDir() + alias := filepath.Join(t.TempDir(), "alias") + if err := os.Symlink(real, alias); err != nil { + t.Skipf("symlink creation unavailable: %v", err) + } + cfg := Config{Level: ConfigEnv} + first, firstLocation, err := acquireRegistrationNamespaceLock(context.Background(), cfg, real, time.Second) + if err != nil { + t.Fatal(err) + } + defer first.release() + secondIdentity, _, resolvedAlias, err := registrationNamespaceLockSpec(cfg, alias) + if err != nil { + t.Fatal(err) + } + firstIdentity, _, resolvedReal, err := registrationNamespaceLockSpec(cfg, real) + if err != nil { + t.Fatal(err) + } + if firstIdentity != secondIdentity || firstLocation != resolvedReal || firstLocation != resolvedAlias { + t.Fatalf("symlink namespace mismatch: first=%q/%q alias=%q/%q", firstIdentity, firstLocation, secondIdentity, resolvedAlias) + } + second, _, err := acquireRegistrationNamespaceLock(context.Background(), cfg, alias, 20*time.Millisecond) + if !errors.Is(err, fslock.ErrLockContended) { + if second != nil { + _ = second.release() + } + t.Fatalf("real-path/alias lock error = %v, want contention", err) + } + + realDriverLock, err := driverInstallLockPath(real, "driver") + if err != nil { + t.Fatal(err) + } + aliasDriverLock, err := driverInstallLockPath(alias, "driver") + if err != nil { + t.Fatal(err) + } + if realDriverLock == aliasDriverLock { + t.Fatal("expected existing driver lock path to differ through a symlink alias") + } +} + +func TestUninstallDriverRefusesChangedRegistration(t *testing.T) { + location := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: location} + oldPath := filepath.Join(location, "old.so") + newPath := filepath.Join(location, "new.so") + if err := os.WriteFile(oldPath, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(newPath, []byte("new"), 0o600); err != nil { + t.Fatal(err) + } + + selected := makeDriverInstallTestInfo("driver", location) + selected.Source = "external" + selected.Driver.Shared.Set("linux_amd64", oldPath) + if err := CreateManifest(cfg, selected); err != nil { + t.Fatal(err) + } + selected, err := GetDriver(cfg, selected.ID) + if err != nil { + t.Fatal(err) + } + + replacement := makeDriverInstallTestInfo("driver", location) + replacement.Version = semver.MustParse("2.0.0") + replacement.Source = "external" + replacement.Driver.Shared.Set("linux_amd64", newPath) + if err := CreateManifest(cfg, replacement); err != nil { + t.Fatal(err) + } + + err = UninstallDriver(cfg, selected) + if !errors.Is(err, errDriverRegistrationChanged) { + t.Fatalf("UninstallDriver error = %v, want errDriverRegistrationChanged", err) + } + if _, err := os.Stat(filepath.Join(location, "driver.toml")); err != nil { + t.Fatalf("replacement registration was removed: %v", err) + } + if _, err := os.Stat(newPath); err != nil { + t.Fatalf("replacement library was removed: %v", err) + } +} + +func makeDriverInstallTestInfo(id, location string) DriverInfo { + info := DriverInfo{ + ID: id, + FilePath: location, + Name: "Test Driver", + Publisher: "Test Publisher", + License: "MIT", + Version: semver.MustParse("1.2.3"), + Source: "external", + } + info.AdbcInfo.Version = semver.MustParse("1.1.0") + info.Driver.Entrypoint = "AdbcDriverInit" + info.Driver.Shared.Set(PlatformTuple(), filepath.Join(location, id, "driver.so")) + return info +} diff --git a/config/driver_install_lock_unix_test.go b/config/driver_install_lock_unix_test.go new file mode 100644 index 00000000..27e49650 --- /dev/null +++ b/config/driver_install_lock_unix_test.go @@ -0,0 +1,64 @@ +// 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 && !js + +package config + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/columnar-tech/dbc/internal/fslock" +) + +func TestDriverInstallLockAliasesShareUnderlyingLock(t *testing.T) { + root := t.TempDir() + alias := filepath.Join(t.TempDir(), "root-alias") + if err := os.Symlink(root, alias); err != nil { + t.Fatal(err) + } + first, err := acquireDriverInstallLockWith(context.Background(), root, "driver", time.Second) + if err != nil { + t.Fatal(err) + } + defer first.release() + + _, err = acquireDriverInstallLockWith(context.Background(), alias, "driver", 20*time.Millisecond) + if !errors.Is(err, fslock.ErrLockContended) { + t.Fatalf("symlink alias lock error = %v, want contention", err) + } +} + +func TestDriverInstallLockSeparatesRoots(t *testing.T) { + firstRoot := t.TempDir() + secondRoot := t.TempDir() + first, err := acquireDriverInstallLockWith(context.Background(), firstRoot, "driver", time.Second) + if err != nil { + t.Fatal(err) + } + defer first.release() + + second, err := acquireDriverInstallLockWith(context.Background(), secondRoot, "driver", time.Second) + if err != nil { + t.Fatalf("same driver in separate root should use an independent lock: %v", err) + } + if err := second.release(); err != nil { + t.Fatal(err) + } +} diff --git a/config/driver_install_lock_windows_test.go b/config/driver_install_lock_windows_test.go new file mode 100644 index 00000000..2aa23fb0 --- /dev/null +++ b/config/driver_install_lock_windows_test.go @@ -0,0 +1,249 @@ +// 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 ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/columnar-tech/dbc/internal/fslock" +) + +func TestDriverInstallLockPathIgnoresCaseOnWindows(t *testing.T) { + location := filepath.Join(t.TempDir(), "InstallRoot") + original, err := driverInstallLockPath(location, "MyDriver") + if err != nil { + t.Fatal(err) + } + caseVariant, err := driverInstallLockPath(strings.ToUpper(location), "mydriver") + if err != nil { + t.Fatal(err) + } + if original != caseVariant { + t.Fatalf("Windows lock paths should ignore location and runtime ID case: %q != %q", original, caseVariant) + } +} + +func TestWindowsUninstallLockSupportsMissingRegistryRoot(t *testing.T) { + missingRoot := filepath.Join(t.TempDir(), "registry-only") + cfg := Config{Level: ConfigUser, Location: missingRoot} + if err := prepareDriverUninstallLockLocation(cfg, missingRoot); err != nil { + t.Fatalf("prepare missing registration root: %v", err) + } + lock, err := acquireDriverInstallLockWith(t.Context(), missingRoot, "driver", 0) + if err != nil { + t.Fatalf("acquire uninstall lock in created root: %v", err) + } + if err := lock.release(); err != nil { + t.Fatal(err) + } +} + +func TestWindowsNonEnvUninstallLockIgnoresCustomLocation(t *testing.T) { + for _, level := range []ConfigLevel{ConfigUser, ConfigSystem} { + t.Run(level.String(), func(t *testing.T) { + first, err := uninstallLockLocation(Config{Level: level, Location: filepath.Join(t.TempDir(), "first")}, DriverInfo{ID: "driver"}) + if err != nil { + t.Fatal(err) + } + second, err := uninstallLockLocation(Config{Level: level, Location: filepath.Join(t.TempDir(), "second")}, DriverInfo{ID: "driver"}) + if err != nil { + t.Fatal(err) + } + if first != second || first != level.ConfigLocation() { + t.Fatalf("custom locations split registry lock: %q != %q; want %q", first, second, level.ConfigLocation()) + } + firstLock, err := driverInstallLockPath(first, "driver") + if err != nil { + t.Fatal(err) + } + secondLock, err := driverInstallLockPath(second, "driver") + if err != nil { + t.Fatal(err) + } + if firstLock != secondLock { + t.Fatalf("registry lock paths differ: %q != %q", firstLock, secondLock) + } + }) + } +} + +func TestWindowsPackageInstallUsesRegistrationLockLocation(t *testing.T) { + for _, level := range []ConfigLevel{ConfigUser, ConfigSystem} { + t.Run(level.String(), func(t *testing.T) { + cfg := Config{Level: level, Location: filepath.Join(t.TempDir(), "custom-payload-root")} + lockLocation, err := packageInstallLockLocation(cfg, cfg.Location) + if err != nil { + t.Fatal(err) + } + if want := level.ConfigLocation(); lockLocation != want { + t.Fatalf("package install lock location = %q, want registration location %q", lockLocation, want) + } + }) + } +} + +func TestWindowsUninstallLockUsesDefaultLocation(t *testing.T) { + for _, level := range []ConfigLevel{ConfigUser, ConfigSystem} { + t.Run(level.String(), func(t *testing.T) { + cfg := Config{Level: level} + location, err := uninstallLockLocation(cfg, DriverInfo{ID: "driver"}) + if err != nil { + t.Fatal(err) + } + if want := level.ConfigLocation(); location != want { + t.Fatalf("uninstall lock location = %q, want default %q", location, want) + } + }) + } +} + +func TestWindowsUninstallLockRejectsUnknownConfigLevel(t *testing.T) { + _, err := uninstallLockLocation(Config{Level: ConfigLevel(255)}, DriverInfo{ID: "driver"}) + if err == nil { + t.Fatal("uninstallLockLocation accepted an unknown config level") + } +} + +func TestWindowsConfigAwareUninstallCleansDefaultAndCustomPackageRoots(t *testing.T) { + for _, locationKind := range []string{"default", "custom"} { + t.Run(locationKind, func(t *testing.T) { + cfg := Config{Level: ConfigUser} + if locationKind == "custom" { + cfg.Location = t.TempDir() + } else { + cfg.Location = cfg.Level.ConfigLocation() + } + root, err := packageCleanupRoot(cfg, DriverInfo{FilePath: "HKCU\\SOFTWARE\\ADBC\\Drivers"}) + if err != nil { + t.Fatal(err) + } + id := fmt.Sprintf("dbc-cleanup-%d", time.Now().UnixNano()) + archive := testPackageArchive(t, "library") + if _, err := InstallPackage(context.Background(), cfg, id, archive, InstallPackageOptions{}); err != nil { + t.Fatal(err) + } + assertArchiveClosed(t, archive) + selected, err := GetDriver(cfg, id) + if err != nil { + t.Fatal(err) + } + generations := packageGenerationNames(t, root, id) + if len(generations) != 1 { + t.Fatalf("installed generation count = %d, want 1", len(generations)) + } + generation := filepath.Join(root, generations[0]) + if err := UninstallDriver(cfg, selected); err != nil { + t.Fatalf("config-aware uninstall: %v", err) + } + if _, err := os.Stat(generation); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("owned generation remains: %v", err) + } + if _, err := GetDriver(cfg, id); err == nil { + t.Fatal("runtime registration remains after uninstall") + } + }) + } +} + +func TestWindowsRegistrationNamespaceIdentityUsesRegistryScope(t *testing.T) { + for _, level := range []ConfigLevel{ConfigUser, ConfigSystem} { + first, firstDir, firstRegistration, err := registrationNamespaceLockSpec(Config{Level: level, Location: filepath.Join(t.TempDir(), "payload-a")}, filepath.Join(t.TempDir(), "payload-a")) + if err != nil { + t.Fatal(err) + } + second, secondDir, secondRegistration, err := registrationNamespaceLockSpec(Config{Level: level, Location: filepath.Join(t.TempDir(), "payload-b")}, filepath.Join(t.TempDir(), "payload-b")) + if err != nil { + t.Fatal(err) + } + if first != second || firstDir != secondDir || firstRegistration != secondRegistration { + t.Fatalf("%s locations split a registry namespace: (%q,%q,%q) != (%q,%q,%q)", level, first, firstDir, firstRegistration, second, secondDir, secondRegistration) + } + } + user, _, _, err := registrationNamespaceLockSpec(Config{Level: ConfigUser}, ConfigUser.ConfigLocation()) + if err != nil { + t.Fatal(err) + } + system, _, _, err := registrationNamespaceLockSpec(Config{Level: ConfigSystem}, ConfigSystem.ConfigLocation()) + if err != nil { + t.Fatal(err) + } + if user == system { + t.Fatal("registry-user and registry-system share a namespace identity") + } + firstFileLocation := t.TempDir() + firstFile, _, _, err := registrationNamespaceLockSpec(Config{Level: ConfigEnv}, firstFileLocation) + if err != nil { + t.Fatal(err) + } + secondFile, _, _, err := registrationNamespaceLockSpec(Config{Level: ConfigEnv}, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if firstFile == secondFile || firstFile == user || firstFile == system { + t.Fatal("file registration namespace collides with a separate namespace") + } +} + +func TestWindowsFileRegistrationNamespaceSymlinkAliasesShareLock(t *testing.T) { + real := t.TempDir() + alias := filepath.Join(t.TempDir(), "alias") + if err := os.Symlink(real, alias); err != nil { + t.Skipf("symlink creation unavailable: %v", err) + } + cfg := Config{Level: ConfigEnv} + first, firstLocation, err := acquireRegistrationNamespaceLock(context.Background(), cfg, real, time.Second) + if err != nil { + t.Fatal(err) + } + defer first.release() + identity, _, registrationLocation, err := registrationNamespaceLockSpec(cfg, alias) + if err != nil { + t.Fatal(err) + } + firstIdentity, _, firstRegistrationLocation, err := registrationNamespaceLockSpec(cfg, real) + if err != nil { + t.Fatal(err) + } + if identity != firstIdentity || registrationLocation != firstLocation || firstRegistrationLocation != firstLocation { + t.Fatalf("real path and alias resolved differently: %q/%q, %q/%q", firstIdentity, firstLocation, identity, registrationLocation) + } + second, _, err := acquireRegistrationNamespaceLock(context.Background(), cfg, alias, 20*time.Millisecond) + if !errors.Is(err, fslock.ErrLockContended) { + if second != nil { + _ = second.release() + } + t.Fatalf("real-path/alias namespace lock error = %v, want contention", err) + } + firstDriverLock, err := driverInstallLockPath(real, "driver") + if err != nil { + t.Fatal(err) + } + aliasDriverLock, err := driverInstallLockPath(alias, "driver") + if err != nil { + t.Fatal(err) + } + if firstDriverLock == aliasDriverLock { + t.Fatal("expected existing driver lock path to differ through a symlink alias") + } +} diff --git a/config/driver_manifest_atomic_unix_test.go b/config/driver_manifest_atomic_unix_test.go new file mode 100644 index 00000000..4576f2fd --- /dev/null +++ b/config/driver_manifest_atomic_unix_test.go @@ -0,0 +1,96 @@ +// 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 && !js + +package config + +import ( + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + + "github.com/Masterminds/semver/v3" +) + +func TestCreateManifestAtomicallyReplacesExistingFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "driver.toml") + if err := os.WriteFile(path, []byte("old manifest"), 0o600); err != nil { + t.Fatal(err) + } + before, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + + version, err := semver.NewVersion("1.2.3") + if err != nil { + t.Fatal(err) + } + driver := DriverInfo{ID: "driver", Name: "new manifest", Version: version} + if err := CreateManifest(Config{Level: ConfigEnv, Location: dir}, driver); err != nil { + t.Fatal(err) + } + after, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if os.SameFile(before, after) { + t.Fatal("manifest was modified in place instead of replaced") + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) == "old manifest" { + t.Fatal("manifest content was not replaced") + } + if got := after.Mode().Perm(); got != 0o600 { + t.Fatalf("replacement permission bits = %o, want 600", got) + } +} + +func TestCreateManifestHonorsRestrictiveUmask(t *testing.T) { + const childEnv = "DBC_CONFIG_MANIFEST_UMASK_CHILD" + if os.Getenv(childEnv) == "1" { + syscall.Umask(0o077) + dir := os.Getenv("DBC_CONFIG_MANIFEST_UMASK_PATH") + version, err := semver.NewVersion("1.2.3") + if err != nil { + t.Fatal(err) + } + driver := DriverInfo{ID: "driver", Name: "new manifest", Version: version} + if err := CreateManifest(Config{Level: ConfigEnv, Location: dir}, driver); err != nil { + t.Fatal(err) + } + return + } + + dir := t.TempDir() + cmd := exec.Command(os.Args[0], "-test.run=^TestCreateManifestHonorsRestrictiveUmask$") + cmd.Env = append(os.Environ(), childEnv+"=1", "DBC_CONFIG_MANIFEST_UMASK_PATH="+dir) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("umask subprocess failed: %v\n%s", err, output) + } + info, err := os.Stat(filepath.Join(dir, "driver.toml")) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("new manifest permission bits = %o, want 600 under umask 077", got) + } +} diff --git a/config/driver_test.go b/config/driver_test.go index 6c94ee9a..0793dfec 100644 --- a/config/driver_test.go +++ b/config/driver_test.go @@ -78,7 +78,7 @@ func TestCreateDriverManifest(t *testing.T) { driverInfo.Driver.Entrypoint = "AdbcDriverInit" driverInfo.Driver.Shared.Set("linux_amd64", "/path/to/driver.so") - err := createDriverManifest(prefix, driverInfo) + err := createDriverManifestUnlocked(prefix, driverInfo) require.NoError(t, err) assert.FileExists(t, manifestPath) diff --git a/config/filesystem_case_test.go b/config/filesystem_case_test.go new file mode 100644 index 00000000..a65449c1 --- /dev/null +++ b/config/filesystem_case_test.go @@ -0,0 +1,277 @@ +// 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 !js && !plan9 + +package config + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/columnar-tech/dbc/internal/fslock" +) + +func TestFilesystemCaseIdentity(t *testing.T) { + probe := probeFilesystemCaseBehavior(t) + t.Logf("filesystem case probe: directories case-insensitive=%t, files case-insensitive=%t", probe.directoriesCaseInsensitive, probe.filesCaseInsensitive) + if probe.directoriesCaseInsensitive != probe.filesCaseInsensitive { + t.Skip("directory and file case behavior differ on this filesystem; focused identity cases need a consistent probe") + } + + if probe.directoriesCaseInsensitive { + t.Run("case-insensitive filesystem", func(t *testing.T) { + t.Run("case-variant reference protects generation with missing ancestor", func(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + selected, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + generation := filepath.Dir(selected.Driver.Shared.Get(PlatformTuple())) + caseVariant := strings.ToUpper(filepath.Base(generation)) + if same, err := samePathIdentity(generation, filepath.Join(root, caseVariant)); err != nil || !same { + t.Fatalf("generation case variant identity = %t, err=%v; want same path", same, err) + } + + borrower := driverMap{} + borrower.Set(PlatformTuple(), filepath.Join(root, caseVariant, "missing", "ancestor", "borrowed.so")) + if err := cleanupInstalledPackageWithReferences(cfg, root, selected, []driverMap{borrower}, true, packageCleanupOperations{}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(generation); err != nil { + t.Fatalf("case-variant reference failed to protect generation: %v", err) + } + if _, err := os.Stat(selected.Driver.Shared.Get(PlatformTuple())); err != nil { + t.Fatalf("owned library disappeared with its referenced generation: %v", err) + } + }) + + t.Run("case-variant reference protects existing library", func(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + selected, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + library := selected.Driver.Shared.Get(PlatformTuple()) + generation := filepath.Dir(library) + caseVariantLibrary := filepath.Join(root, strings.ToUpper(filepath.Base(generation)), strings.ToUpper(filepath.Base(library))) + if same, err := samePathIdentity(library, caseVariantLibrary); err != nil || !same { + t.Fatalf("library case variant identity = %t, err=%v; want same file", same, err) + } + + borrower := driverMap{} + borrower.Set(PlatformTuple(), caseVariantLibrary) + if err := cleanupInstalledPackageWithReferences(cfg, root, selected, []driverMap{borrower}, true, packageCleanupOperations{}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(generation); err != nil { + t.Fatalf("case-variant library reference failed to protect generation: %v", err) + } + if _, err := os.Stat(library); err != nil { + t.Fatalf("referenced library disappeared: %v", err) + } + }) + + t.Run("namespace lock through case-variant root contends", func(t *testing.T) { + first, rootAlias := probe.root, probe.rootVariant + lock, _, err := acquireRegistrationNamespaceLock(context.Background(), Config{Level: ConfigEnv}, first, time.Second) + if err != nil { + t.Fatal(err) + } + defer lock.release() + second, _, err := acquireRegistrationNamespaceLock(context.Background(), Config{Level: ConfigEnv}, rootAlias, 20*time.Millisecond) + if second != nil { + _ = second.release() + } + if !errors.Is(err, fslock.ErrLockContended) { + t.Fatalf("case-variant namespace lock error = %v, want ErrLockContended", err) + } + }) + + t.Run("driver ID case variant contends", func(t *testing.T) { + lock, err := acquireDriverInstallLockWith(context.Background(), probe.root, "Driver", time.Second) + if err != nil { + t.Fatal(err) + } + defer lock.release() + second, err := acquireDriverInstallLockWith(context.Background(), probe.root, "driver", 20*time.Millisecond) + if second != nil { + _ = second.release() + } + if !errors.Is(err, fslock.ErrLockContended) { + t.Fatalf("Driver/driver lock error = %v, want ErrLockContended", err) + } + }) + }) + return + } + + t.Run("case-sensitive filesystem", func(t *testing.T) { + rootInfo, err := os.Stat(probe.root) + if err != nil { + t.Fatal(err) + } + variantInfo, err := os.Stat(probe.rootVariant) + if err != nil { + t.Fatal(err) + } + if os.SameFile(rootInfo, variantInfo) { + t.Fatal("case-sensitive root names unexpectedly identify the same directory") + } + fileInfo, err := os.Stat(probe.file) + if err != nil { + t.Fatal(err) + } + fileVariantInfo, err := os.Stat(probe.fileVariant) + if err != nil { + t.Fatal(err) + } + if os.SameFile(fileInfo, fileVariantInfo) { + t.Fatal("case-sensitive file names unexpectedly identify the same file") + } + if sameRuntimeID("Driver", "driver") { + t.Fatal("case-sensitive runtime IDs unexpectedly compare equal") + } + + first, _, err := acquireRegistrationNamespaceLock(context.Background(), Config{Level: ConfigEnv}, probe.root, time.Second) + if err != nil { + t.Fatal(err) + } + second, _, err := acquireRegistrationNamespaceLock(context.Background(), Config{Level: ConfigEnv}, probe.rootVariant, time.Second) + if err != nil { + _ = first.release() + t.Fatalf("distinct case-sensitive namespace acquired same lock: %v", err) + } + if err := second.release(); err != nil { + t.Fatal(err) + } + if err := first.release(); err != nil { + t.Fatal(err) + } + + firstDriver, err := acquireDriverInstallLockWith(context.Background(), probe.root, "Driver", time.Second) + if err != nil { + t.Fatal(err) + } + secondDriver, err := acquireDriverInstallLockWith(context.Background(), probe.root, "driver", time.Second) + if err != nil { + _ = firstDriver.release() + t.Fatalf("case-sensitive Driver/driver IDs unexpectedly contended: %v", err) + } + if err := secondDriver.release(); err != nil { + t.Fatal(err) + } + if err := firstDriver.release(); err != nil { + t.Fatal(err) + } + + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + selected, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + generation := filepath.Dir(selected.Driver.Shared.Get(PlatformTuple())) + borrower := driverMap{} + borrower.Set(PlatformTuple(), filepath.Join(root, strings.ToUpper(filepath.Base(generation)), "missing", "ancestor", "borrowed.so")) + if err := cleanupInstalledPackageWithReferences(cfg, root, selected, []driverMap{borrower}, true, packageCleanupOperations{}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(generation); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("case-distinct reference protected generation: %v", err) + } + }) +} + +type filesystemCaseProbe struct { + root, rootVariant string + file, fileVariant string + directoriesCaseInsensitive bool + filesCaseInsensitive bool +} + +func probeFilesystemCaseBehavior(t *testing.T) filesystemCaseProbe { + t.Helper() + parent := t.TempDir() + root := filepath.Join(parent, "CaseProbe") + rootVariant := filepath.Join(parent, "caseprobe") + if err := os.Mkdir(root, 0o700); err != nil { + t.Fatal(err) + } + rootInfo, err := os.Stat(rootVariant) + if errors.Is(err, os.ErrNotExist) { + if err := os.Mkdir(rootVariant, 0o700); err != nil { + t.Fatal(err) + } + rootInfo, err = os.Stat(rootVariant) + } + if err != nil { + t.Fatalf("stat mixed-case root variant: %v", err) + } + originalRootInfo, err := os.Stat(root) + if err != nil { + t.Fatal(err) + } + + file := filepath.Join(root, "Library.so") + fileVariant := filepath.Join(root, "library.so") + if err := os.WriteFile(file, []byte("first"), 0o600); err != nil { + t.Fatal(err) + } + fileInfo, err := os.Stat(fileVariant) + if errors.Is(err, os.ErrNotExist) { + if err := os.WriteFile(fileVariant, []byte("second"), 0o600); err != nil { + t.Fatal(err) + } + fileInfo, err = os.Stat(fileVariant) + } + if err != nil { + t.Fatalf("stat mixed-case file variant: %v", err) + } + originalFileInfo, err := os.Stat(file) + if err != nil { + t.Fatal(err) + } + + return filesystemCaseProbe{ + root: root, + rootVariant: rootVariant, + file: file, + fileVariant: fileVariant, + directoriesCaseInsensitive: os.SameFile(originalRootInfo, rootInfo), + filesCaseInsensitive: os.SameFile(originalFileInfo, fileInfo), + } +} + +func samePathIdentity(a, b string) (bool, error) { + aInfo, err := os.Stat(a) + if err != nil { + return false, err + } + bInfo, err := os.Stat(b) + if err != nil { + return false, err + } + return os.SameFile(aInfo, bInfo), nil +} diff --git a/config/install_package.go b/config/install_package.go new file mode 100644 index 00000000..41950e11 --- /dev/null +++ b/config/install_package.go @@ -0,0 +1,241 @@ +// 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. + +package config + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "github.com/columnar-tech/dbc/internal/hostpath" +) + +// PackageVerifier validates a fully extracted package before it is published. +// It runs while the runtime ID's driver lock is held and must not call a driver +// mutation API for that runtime ID. The manifest points to the planned final +// generation path while stagingDir contains the candidate package files. +type PackageVerifier func(stagingDir string, manifest Manifest) error + +// InstallPackageOptions controls optional package verification. +type InstallPackageOptions struct { + // Verifier runs under the driver lock before generation publication. + Verifier PackageVerifier +} + +type packageInstallOperations struct { + rename func(string, string) error + remove func(string) error + removeAll func(string) error + cleanupBeforeRemove func(string) error + register func(Config, string, DriverInfo) error + writeReceipt func(string, packageInstallReceipt) error +} + +// InstallPackage installs a package generation, then points the runtime +// registration at it, and closes the archive file on every return. Stale +// generations are removed best-effort afterwards. A lock release error can be +// returned after commit; the new generation then remains installed. +// On Windows, crash atomicity of the registry update is not guaranteed. +// Cancellation is checked only at transaction boundaries; once registration +// starts it completes. +func InstallPackage(ctx context.Context, cfg Config, runtimeID string, downloaded *os.File, options InstallPackageOptions) (manifest Manifest, err error) { + return installPackageWithContextAndOperations(ctx, cfg, runtimeID, downloaded, options, packageInstallOperations{ + rename: os.Rename, + remove: os.Remove, + removeAll: os.RemoveAll, + register: createRuntimeRegistrationUnlocked, + writeReceipt: writePackageInstallReceipt, + }) +} + +func installPackageWithOperations(cfg Config, runtimeID string, downloaded *os.File, options InstallPackageOptions, operations packageInstallOperations) (manifest Manifest, err error) { + return installPackageWithContextAndOperations(context.Background(), cfg, runtimeID, downloaded, options, operations) +} + +func installPackageWithContextAndOperations(ctx context.Context, cfg Config, runtimeID string, downloaded *os.File, options InstallPackageOptions, operations packageInstallOperations) (manifest Manifest, err error) { + if downloaded == nil { + return Manifest{}, errors.New("downloaded package file is nil") + } + closed := false + defer func() { + if !closed { + err = errors.Join(err, downloaded.Close()) + } + }() + if ctx == nil { + return Manifest{}, errors.New("install context is nil") + } + if err := validatePackageFilename(runtimeID); err != nil { + return Manifest{}, fmt.Errorf("invalid runtime ID: %w", err) + } + + location, err := EnsureLocation(cfg) + if err != nil { + return Manifest{}, fmt.Errorf("could not ensure config location: %w", err) + } + location, err = hostpath.Abs(location) + if err != nil { + return Manifest{}, fmt.Errorf("resolve config location: %w", err) + } + location = hostpath.Clean(location) + lockLocation, err := packageInstallLockLocation(cfg, location) + if err != nil { + return Manifest{}, fmt.Errorf("resolve driver install lock location: %w", err) + } + if err := preparePackageInstallLockLocation(cfg, lockLocation); err != nil { + return Manifest{}, fmt.Errorf("prepare driver install lock location: %w", err) + } + lock, err := acquireDriverInstallLockWith(ctx, lockLocation, runtimeID, 10*time.Second) + if err != nil { + return Manifest{}, fmt.Errorf("acquire driver install lock: %w", err) + } + defer func() { err = errors.Join(err, lock.release()) }() + if err := ctx.Err(); err != nil { + return Manifest{}, fmt.Errorf("install canceled before staging: %w", err) + } + + stageDir, manifest, _, err := extractPackageArchive(downloaded, location) + if err != nil { + return Manifest{}, fmt.Errorf("failed to extract package: %w", err) + } + driverFilename := manifest.Files.Driver + defer func() { + if stageDir != "" { + if cleanupErr := operations.removeAll(stageDir); cleanupErr != nil { + err = errors.Join(err, fmt.Errorf("remove staging directory %s: %w", stageDir, cleanupErr)) + } + } + }() + if closeErr := downloaded.Close(); closeErr != nil { + closed = true + return Manifest{}, fmt.Errorf("close package archive: %w", closeErr) + } + closed = true + + manifest.DriverInfo.ID = runtimeID + manifest.DriverInfo.Source = "dbc" + generationPrefix, err := packageGenerationPrefix(runtimeID) + if err != nil { + return Manifest{}, fmt.Errorf("prepare package generation name: %w", err) + } + reservation, err := os.MkdirTemp(location, generationPrefix) + if err != nil { + return Manifest{}, fmt.Errorf("reserve package generation path: %w", err) + } + generationDir := reservation + reservationExists := true + defer func() { + if reservationExists { + if cleanupErr := operations.removeAll(reservation); cleanupErr != nil { + err = errors.Join(err, fmt.Errorf("remove package generation reservation %s: %w", reservation, cleanupErr)) + } + } + }() + generationMode, err := umaskedDirMode(reservation, "mode", 0o755) + if err != nil { + return Manifest{}, fmt.Errorf("prepare package generation: %w", err) + } + if err := operations.remove(reservation); err != nil { + return Manifest{}, fmt.Errorf("release package generation reservation: %w", err) + } + reservationExists = false + manifest.Driver.Shared = driverMap{} + manifest.Driver.Shared.Set(PlatformTuple(), hostpath.Join(generationDir, driverFilename)) + if options.Verifier != nil { + if err := ctx.Err(); err != nil { + return Manifest{}, fmt.Errorf("install canceled before package verification: %w", err) + } + if err := options.Verifier(stageDir, manifest); err != nil { + return Manifest{}, fmt.Errorf("verify package: %w", err) + } + if err := ctx.Err(); err != nil { + return Manifest{}, fmt.Errorf("install canceled after package verification: %w", err) + } + } + namespaceLock, registrationLocation, err := acquireRegistrationNamespaceLock(ctx, cfg, lockLocation, 10*time.Second) + if err != nil { + return Manifest{}, err + } + defer func() { err = errors.Join(err, namespaceLock.release()) }() + legacyCandidate, hasLegacyCandidate := legacyPackageReplacementCandidate(cfg, location, registrationLocation, runtimeID) + receipt, err := makePackageInstallReceipt(cfg, stageDir, hostpath.Base(generationDir), runtimeID, PlatformTuple(), manifest.DriverInfo, driverFilename) + if err != nil { + return Manifest{}, fmt.Errorf("prepare package install receipt: %w", err) + } + if err := operations.writeReceipt(stageDir, receipt); err != nil { + return Manifest{}, fmt.Errorf("write package install receipt: %w", err) + } + + if err := ctx.Err(); err != nil { + return Manifest{}, fmt.Errorf("install canceled before publishing package generation: %w", err) + } + if err := os.Chmod(stageDir, generationMode); err != nil { + return Manifest{}, fmt.Errorf("prepare package generation: %w", err) + } + if err := operations.rename(stageDir, generationDir); err != nil { + return Manifest{}, fmt.Errorf("publish package generation: %w", err) + } + stageDir = "" + + if err := ctx.Err(); err != nil { + return Manifest{}, cleanupFailedPackageRegistration(generationDir, fmt.Errorf("install canceled before registration: %w", err), operations.removeAll) + } + if err := operations.register(cfg, location, manifest.DriverInfo); err != nil { + return Manifest{}, cleanupFailedPackageRegistration(generationDir, err, operations.removeAll) + } + registrations, certain, _ := collectRegistrationSharedMaps(cfg, registrationLocation, "") + if certain { + referenced, uncertain := referencedPathsForRegistrations(location, registrations) + if hasLegacyCandidate { + // Best-effort compatibility cleanup uses the registration captured + // before commit and removes only its exact regular library file. + _ = cleanupLegacyPackageRegistration(location, legacyCandidate, referenced, !uncertain, packageCleanupOperations{ + beforeRemove: operations.cleanupBeforeRemove, + }) + } + cleanupStalePackageGenerationsWithReferences(cfg, location, manifest.DriverInfo, []string{generationDir}, referenced, uncertain, packageCleanupOperations{ + beforeRemove: operations.cleanupBeforeRemove, + }) + } + return manifest, nil +} + +// umaskedDirMode returns the mode os.Mkdir would give a directory created with +// perm, i.e. with the process umask applied. parent must be a unique private +// directory so the probe cannot collide with anything. +func umaskedDirMode(parent, name string, perm os.FileMode) (os.FileMode, error) { + probe := hostpath.Join(parent, name) + if err := os.Mkdir(probe, perm); err != nil { + return 0, err + } + info, statErr := os.Stat(probe) + removeErr := os.Remove(probe) + if statErr != nil { + return 0, statErr + } + return info.Mode().Perm(), removeErr +} + +func cleanupFailedPackageRegistration(generationDir string, registrationErr error, removeAll func(string) error) error { + if errors.Is(registrationErr, errRegistrationRollbackFailed) { + return fmt.Errorf("register package failed; candidate generation preserved at %s: %w", generationDir, registrationErr) + } + if cleanupErr := removeAll(generationDir); cleanupErr != nil { + return errors.Join(fmt.Errorf("register package: %w", registrationErr), fmt.Errorf("remove unpublished generation %s: %w", generationDir, cleanupErr)) + } + return fmt.Errorf("register package: %w", registrationErr) +} diff --git a/config/install_package_test.go b/config/install_package_test.go new file mode 100644 index 00000000..cd60567d --- /dev/null +++ b/config/install_package_test.go @@ -0,0 +1,792 @@ +// 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. + +package config + +import ( + "archive/tar" + "compress/gzip" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "runtime" + "slices" + "strings" + "testing" + "time" +) + +func TestInstallPackagePublishesAndRegistersGeneration(t *testing.T) { + location := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: location} + archive := writeCustomPackageArchive(t, "name = \"Driver\"\nversion = \"1.0.0\"\n[Driver]\nshared = \"/external/default.so\"\n[Files]\ndriver = \"driver.so\"\n", packageFile("driver.so", "library")) + var stagingPath string + manifest, err := InstallPackage(context.Background(), cfg, "driver", archive, InstallPackageOptions{Verifier: func(stage string, m Manifest) error { + stagingPath = stage + got := m.Driver.Shared.Get(PlatformTuple()) + if got == filepath.Join(stage, "driver.so") || !packageGenerationNameMatches(filepath.Base(filepath.Dir(got)), "driver") { + return fmt.Errorf("verifier shared path = %q, want planned generation", got) + } + if _, err := os.Stat(got); !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("planned generation unexpectedly exists: %v", err) + } + return nil + }}) + if err != nil { + t.Fatal(err) + } + if _, err := archive.Stat(); err == nil { + t.Fatal("archive remains open after successful installation") + } + if _, err := os.Stat(stagingPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("staging directory remains: %v", err) + } + registered, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + finalLibrary := registered.Driver.Shared.Get(PlatformTuple()) + if !packageGenerationNameMatches(filepath.Base(filepath.Dir(finalLibrary)), "driver") { + t.Fatalf("registered library path = %q, want package generation", finalLibrary) + } + if filepath.Dir(filepath.Dir(finalLibrary)) != location { + t.Fatalf("generation is outside primary root: %q", finalLibrary) + } + if got, err := os.ReadFile(finalLibrary); err != nil || string(got) != strings.Repeat("x", 7) { + t.Fatalf("installed library = %q, %v", got, err) + } + if runtime.GOOS != "windows" { + info, err := os.Stat(filepath.Dir(finalLibrary)) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o755 { + t.Fatalf("generation mode = %#o, want 0755", got) + } + } + if manifest.Driver.Shared.Get(PlatformTuple()) != finalLibrary { + t.Fatalf("returned manifest path = %q, registered path = %q", manifest.Driver.Shared.Get(PlatformTuple()), finalLibrary) + } +} + +func TestInstallPackageFailurePreservesPreviousGeneration(t *testing.T) { + location := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: location} + install := func(contents string, verifier PackageVerifier) error { + archive := testPackageArchive(t, contents) + _, err := InstallPackage(context.Background(), cfg, "driver", archive, InstallPackageOptions{Verifier: verifier}) + if _, statErr := archive.Stat(); statErr == nil { + t.Error("archive remains open") + } + return err + } + if err := install("old", nil); err != nil { + t.Fatal(err) + } + previous, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + previousPath := previous.Driver.Shared.Get(PlatformTuple()) + + t.Run("verifier failure", func(t *testing.T) { + err := install("new", func(string, Manifest) error { return errors.New("rejected") }) + if err == nil || !strings.Contains(err.Error(), "verify package") { + t.Fatalf("InstallPackage error = %v", err) + } + assertPreviousPackage(t, cfg, previousPath) + }) + t.Run("invalid archive", func(t *testing.T) { + archive := writeCustomPackageArchive(t, "name = \"Driver\"\nversion = \"1.0.0\"\n[Files]\ndriver = \"missing.so\"\n") + _, err := InstallPackage(context.Background(), cfg, "driver", archive, InstallPackageOptions{}) + if err == nil { + t.Fatal("expected invalid archive error") + } + if _, statErr := archive.Stat(); statErr == nil { + t.Fatal("archive remains open") + } + assertPreviousPackage(t, cfg, previousPath) + }) + t.Run("generation preparation failure", func(t *testing.T) { + err := install("new", func(stage string, _ Manifest) error { + return os.RemoveAll(stage) + }) + if err == nil { + t.Fatal("expected generation preparation failure") + } + assertPreviousPackage(t, cfg, previousPath) + }) + t.Run("successful update publishes exact candidate bytes", func(t *testing.T) { + if err := install("new", nil); err != nil { + t.Fatal(err) + } + registered, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + newPath := registered.Driver.Shared.Get(PlatformTuple()) + if newPath == previousPath { + t.Fatalf("registration path remained %q after update", previousPath) + } + data, err := os.ReadFile(newPath) + if err != nil || string(data) != "new" { + t.Fatalf("updated library = %q, %v; want exact candidate bytes %q", data, err, "new") + } + }) +} + +func TestInstallPackagePipelineFailuresPreservePreviousGeneration(t *testing.T) { + location := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: location} + installInitialPackage(t, cfg) + previous, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + previousPath := previous.Driver.Shared.Get(PlatformTuple()) + registrationErr := errors.New("registration failed") + + t.Run("rename publish failure", func(t *testing.T) { + archive := testPackageArchive(t, "new") + operations := testPackageInstallOperations() + operations.rename = func(string, string) error { return errors.New("injected rename failure") } + _, err := installPackageWithOperations(cfg, "driver", archive, InstallPackageOptions{}, operations) + if err == nil || !strings.Contains(err.Error(), "publish package generation") { + t.Fatalf("InstallPackage error = %v", err) + } + assertArchiveClosed(t, archive) + assertPreviousPackage(t, cfg, previousPath) + assertOnlyPackageGeneration(t, location, filepath.Base(filepath.Dir(previousPath))) + }) + t.Run("registration failure removes published candidate", func(t *testing.T) { + archive := testPackageArchive(t, "new") + operations := testPackageInstallOperations() + operations.register = func(Config, string, DriverInfo) error { return registrationErr } + _, err := installPackageWithOperations(cfg, "driver", archive, InstallPackageOptions{}, operations) + if !errors.Is(err, registrationErr) { + t.Fatalf("InstallPackage error = %v", err) + } + assertArchiveClosed(t, archive) + assertPreviousPackage(t, cfg, previousPath) + assertOnlyPackageGeneration(t, location, filepath.Base(filepath.Dir(previousPath))) + }) + t.Run("rollback failure preserves published candidate", func(t *testing.T) { + archive := testPackageArchive(t, "new") + operations := testPackageInstallOperations() + var candidatePath string + operations.register = func(_ Config, _ string, driver DriverInfo) error { + candidatePath = filepath.Dir(driver.Driver.Shared.Get(PlatformTuple())) + return errors.Join(registrationErr, errRegistrationRollbackFailed) + } + _, err := installPackageWithOperations(cfg, "driver", archive, InstallPackageOptions{}, operations) + if !errors.Is(err, errRegistrationRollbackFailed) || !strings.Contains(err.Error(), packageGenerationNamePrefix) { + t.Fatalf("InstallPackage error = %v", err) + } + assertArchiveClosed(t, archive) + assertPreviousPackage(t, cfg, previousPath) + if _, statErr := os.Stat(candidatePath); statErr != nil { + t.Fatalf("rollback candidate %s was not preserved: %v", candidatePath, statErr) + } + }) + t.Run("candidate cleanup failure retains both errors", func(t *testing.T) { + archive := testPackageArchive(t, "new") + cleanupErr := errors.New("candidate cleanup failed") + operations := testPackageInstallOperations() + operations.register = func(Config, string, DriverInfo) error { return registrationErr } + var candidatePath string + operations.removeAll = func(path string) error { + if packageGenerationNameMatches(filepath.Base(path), "driver") { + candidatePath = path + return cleanupErr + } + return os.RemoveAll(path) + } + _, err := installPackageWithOperations(cfg, "driver", archive, InstallPackageOptions{}, operations) + if !errors.Is(err, registrationErr) || !errors.Is(err, cleanupErr) { + t.Fatalf("InstallPackage error = %v", err) + } + assertArchiveClosed(t, archive) + assertPreviousPackage(t, cfg, previousPath) + if _, statErr := os.Stat(candidatePath); statErr != nil { + t.Fatalf("candidate %s was not preserved after cleanup failure: %v", candidatePath, statErr) + } + }) +} + +func TestInstallPackageContextCancellation(t *testing.T) { + t.Run("nil context closes archive", func(t *testing.T) { + cfg := Config{Level: ConfigEnv, Location: t.TempDir()} + archive := testPackageArchive(t, "new") + _, err := InstallPackage(nil, cfg, "driver", archive, InstallPackageOptions{}) + if err == nil || !strings.Contains(err.Error(), "install context is nil") { + t.Fatalf("InstallPackage error = %v, want nil-context error", err) + } + assertArchiveClosed(t, archive) + }) + + t.Run("driver lock wait", func(t *testing.T) { + location := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: location} + lock, err := acquireDriverInstallLockWith(context.Background(), location, "driver", time.Second) + if err != nil { + t.Fatal(err) + } + defer lock.release() + + ctx, cancel := context.WithCancel(context.Background()) + archive := testPackageArchive(t, "new") + result := make(chan error, 1) + go func() { + _, installErr := InstallPackage(ctx, cfg, "driver", archive, InstallPackageOptions{}) + result <- installErr + }() + time.Sleep(20 * time.Millisecond) + cancel() + if err := <-result; !errors.Is(err, context.Canceled) { + t.Fatalf("InstallPackage error = %v, want context.Canceled", err) + } + assertArchiveClosed(t, archive) + }) + + t.Run("registration namespace lock wait", func(t *testing.T) { + location := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: location} + if _, err := EnsureLocation(cfg); err != nil { + t.Fatal(err) + } + lock, _, err := acquireRegistrationNamespaceLock(context.Background(), cfg, location, time.Second) + if err != nil { + t.Fatal(err) + } + defer lock.release() + + ctx, cancel := context.WithCancel(context.Background()) + archive := testPackageArchive(t, "new") + verifierStarted := make(chan struct{}) + result := make(chan error, 1) + go func() { + _, installErr := InstallPackage(ctx, cfg, "driver", archive, InstallPackageOptions{ + Verifier: func(string, Manifest) error { + close(verifierStarted) + return nil + }, + }) + result <- installErr + }() + <-verifierStarted + time.Sleep(20 * time.Millisecond) + cancel() + if err := <-result; !errors.Is(err, context.Canceled) { + t.Fatalf("InstallPackage error = %v, want context.Canceled", err) + } + assertArchiveClosed(t, archive) + if _, err := GetDriver(cfg, "driver"); err == nil { + t.Fatal("registration was created after namespace lock cancellation") + } + }) + + t.Run("cancellation during verifier preserves registration", func(t *testing.T) { + location := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: location} + installInitialPackage(t, cfg) + previous, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + previousPath := previous.Driver.Shared.Get(PlatformTuple()) + ctx, cancel := context.WithCancel(context.Background()) + archive := testPackageArchive(t, "new") + _, err = InstallPackage(ctx, cfg, "driver", archive, InstallPackageOptions{ + Verifier: func(string, Manifest) error { + cancel() + return nil + }, + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("InstallPackage error = %v, want context.Canceled", err) + } + assertArchiveClosed(t, archive) + assertPreviousPackage(t, cfg, previousPath) + }) + + t.Run("cancellation after registration begins keeps committed install successful", func(t *testing.T) { + location := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: location} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + archive := testPackageArchive(t, "new") + operations := testPackageInstallOperations() + operations.register = func(cfg Config, location string, driver DriverInfo) error { + cancel() + return createRuntimeRegistrationUnlocked(cfg, location, driver) + } + manifest, err := installPackageWithContextAndOperations(ctx, cfg, "driver", archive, InstallPackageOptions{}, operations) + if err != nil { + t.Fatalf("InstallPackage error after commit = %v", err) + } + assertArchiveClosed(t, archive) + registered, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + if registered.Driver.Shared.Get(PlatformTuple()) != manifest.Driver.Shared.Get(PlatformTuple()) { + t.Fatalf("registration = %q, returned manifest = %q", registered.Driver.Shared.Get(PlatformTuple()), manifest.Driver.Shared.Get(PlatformTuple())) + } + }) +} + +func TestInstallPackageReportsPrecommitCleanupFailures(t *testing.T) { + location := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: location} + installInitialPackage(t, cfg) + previous, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + previousPath := previous.Driver.Shared.Get(PlatformTuple()) + + t.Run("staging cleanup", func(t *testing.T) { + archive := testPackageArchive(t, "new") + verificationErr := errors.New("verification failed") + cleanupErr := errors.New("staging cleanup failed") + operations := testPackageInstallOperations() + operations.removeAll = func(path string) error { + if strings.HasPrefix(filepath.Base(path), ".dbc-package-stage-") { + return cleanupErr + } + return os.RemoveAll(path) + } + _, err := installPackageWithOperations(cfg, "driver", archive, InstallPackageOptions{Verifier: func(string, Manifest) error { + return verificationErr + }}, operations) + if !errors.Is(err, verificationErr) || !errors.Is(err, cleanupErr) || !strings.Contains(err.Error(), ".dbc-package-stage-") { + t.Fatalf("InstallPackage error = %v", err) + } + assertArchiveClosed(t, archive) + assertPreviousPackage(t, cfg, previousPath) + }) + + t.Run("reservation cleanup", func(t *testing.T) { + archive := testPackageArchive(t, "new") + releaseErr := errors.New("reservation release failed") + cleanupErr := errors.New("reservation cleanup failed") + operations := testPackageInstallOperations() + operations.remove = func(string) error { return releaseErr } + operations.removeAll = func(path string) error { + if packageGenerationNameMatches(filepath.Base(path), "driver") { + return cleanupErr + } + return os.RemoveAll(path) + } + _, err := installPackageWithOperations(cfg, "driver", archive, InstallPackageOptions{}, operations) + if !errors.Is(err, releaseErr) || !errors.Is(err, cleanupErr) || !strings.Contains(err.Error(), packageGenerationNamePrefix) { + t.Fatalf("InstallPackage error = %v", err) + } + assertArchiveClosed(t, archive) + assertPreviousPackage(t, cfg, previousPath) + }) +} + +func TestInstallPackageRecoversMalformedRegistrationWithOwnedPayload(t *testing.T) { + location := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: location} + if err := os.WriteFile(filepath.Join(location, "external.toml"), []byte("invalid = ["), 0o600); err != nil { + t.Fatal(err) + } + archive := writeCustomPackageArchive(t, "name = \"External\"\nversion = \"1.0.0\"\n[Files]\ndriver = \"driver.so\"\n", packageFile("driver.so", "library")) + if _, err := InstallPackage(context.Background(), cfg, "external", archive, InstallPackageOptions{}); err != nil { + t.Fatal(err) + } + registered, err := GetDriver(cfg, "external") + if err != nil { + t.Fatal(err) + } + if got := registered.Driver.Shared.Get(PlatformTuple()); !strings.HasPrefix(got, location) { + t.Fatalf("owned shared path = %q, want path below %q", got, location) + } +} + +func TestInstallPackageRejectsManifestOnlyWithExternalLibrary(t *testing.T) { + externalLibrary := filepath.Join(t.TempDir(), "external.so") + if err := os.WriteFile(externalLibrary, []byte("external library"), 0o600); err != nil { + t.Fatal(err) + } + quotedPath := strings.ReplaceAll(externalLibrary, `\`, `\\`) + tests := []struct { + name string + manifest string + verifier bool + }{ + {name: "external shared string", manifest: fmt.Sprintf("name = \"External\"\nversion = \"1.0.0\"\n[Driver]\nshared = \"%s\"\n", quotedPath)}, + {name: "external shared string with verifier", manifest: fmt.Sprintf("name = \"External\"\nversion = \"1.0.0\"\n[Driver]\nshared = \"%s\"\n", quotedPath), verifier: true}, + {name: "external shared platform map", manifest: fmt.Sprintf("name = \"External\"\nversion = \"1.0.0\"\n[Driver.shared]\n%s = \"%s\"\n", PlatformTuple(), quotedPath)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + location := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: location} + invalidArchive := writeCustomPackageArchive(t, test.manifest) + options := InstallPackageOptions{} + if test.verifier { + options.Verifier = func(string, Manifest) error { return nil } + } + _, installErr := InstallPackage(context.Background(), cfg, "external", invalidArchive, options) + if installErr == nil || !strings.Contains(installErr.Error(), "does not specify Files.driver") { + t.Fatalf("InstallPackage error = %v", installErr) + } + assertArchiveClosed(t, invalidArchive) + if _, getErr := GetDriver(cfg, "external"); getErr == nil { + t.Fatal("manifest-only package created a runtime registration") + } + data, readErr := os.ReadFile(externalLibrary) + if readErr != nil || string(data) != "external library" { + t.Fatalf("external library changed: %q, %v", data, readErr) + } + if got := packageGenerationNames(t, location, "external"); len(got) != 0 { + t.Fatalf("manifest-only package published generations: %v", got) + } + }) + } +} + +func TestInstallPackageRejectsManifestOnlyUpdateAndPreservesOwnedPackage(t *testing.T) { + location := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: location} + initialArchive := testPackageArchive(t, "old") + if _, err := InstallPackage(context.Background(), cfg, "driver", initialArchive, InstallPackageOptions{}); err != nil { + t.Fatal(err) + } + assertArchiveClosed(t, initialArchive) + previous, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + previousPath := previous.Driver.Shared.Get(PlatformTuple()) + previousBytes, err := os.ReadFile(previousPath) + if err != nil { + t.Fatal(err) + } + previousGenerations := packageGenerationNames(t, location, "driver") + if len(previousGenerations) != 1 { + t.Fatalf("initial generation count = %d, want 1", len(previousGenerations)) + } + + externalLibrary := filepath.Join(t.TempDir(), "external.so") + if err := os.WriteFile(externalLibrary, []byte("external library"), 0o600); err != nil { + t.Fatal(err) + } + quotedPath := strings.ReplaceAll(externalLibrary, `\`, `\\`) + manifest := fmt.Sprintf("name = \"External\"\nversion = \"2.0.0\"\n[Driver]\nshared = \"%s\"\n", quotedPath) + updateArchive := writeCustomPackageArchive(t, manifest) + _, installErr := InstallPackage(context.Background(), cfg, "driver", updateArchive, InstallPackageOptions{}) + if installErr == nil || !strings.Contains(installErr.Error(), "does not specify Files.driver") { + t.Fatalf("InstallPackage update error = %v", installErr) + } + assertArchiveClosed(t, updateArchive) + current, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(current, previous) { + t.Fatalf("registration changed after rejected update:\ncurrent: %+v\nprevious: %+v", current, previous) + } + if got, err := os.ReadFile(previousPath); err != nil || !reflect.DeepEqual(got, previousBytes) { + t.Fatalf("previous payload changed after rejected update: %q, %v", got, err) + } + if got := packageGenerationNames(t, location, "driver"); !slices.Equal(got, previousGenerations) { + t.Fatalf("generation names after rejected update = %v, want %v", got, previousGenerations) + } + if got, err := os.ReadFile(externalLibrary); err != nil || string(got) != "external library" { + t.Fatalf("external library changed after rejected update: %q, %v", got, err) + } +} + +func TestInstallDriverUsesOwnedPayloadOverDefaultSharedPath(t *testing.T) { + location := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: location} + archive := writeCustomPackageArchive(t, + "name = \"Driver\"\nversion = \"1.0.0\"\n[Driver]\nshared = \"/external/libdriver.so\"\n[Files]\ndriver = \"driver.so\"\n", + packageFile("driver.so", "owned library"), + ) + manifest, err := InstallDriver(cfg, "driver", archive) + if err != nil { + t.Fatal(err) + } + want := filepath.Join(location, "package", "driver.so") + if got := manifest.DriverInfo.Driver.Shared.Get(PlatformTuple()); got != want { + t.Fatalf("installed shared path = %q, want owned payload %q", got, want) + } + if _, err := os.Stat(want); err != nil { + t.Fatalf("owned payload is missing: %v", err) + } +} + +func TestInstallPackageNormalizesRelativePayloadRoot(t *testing.T) { + absoluteLocation := filepath.Join(t.TempDir(), "relative-root") + workingDirectory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + relativeLocation, err := filepath.Rel(workingDirectory, absoluteLocation) + if err != nil { + t.Skipf("cannot create a relative fixture across volumes: %v", err) + } + cfg := Config{Level: ConfigEnv, Location: relativeLocation} + archive := testPackageArchive(t, "library") + if _, err := InstallPackage(context.Background(), cfg, "driver", archive, InstallPackageOptions{}); err != nil { + t.Fatal(err) + } + assertArchiveClosed(t, archive) + registered, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + sharedPath := registered.Driver.Shared.Get(PlatformTuple()) + if !filepath.IsAbs(sharedPath) { + t.Fatalf("registered shared path = %q, want absolute path", sharedPath) + } +} + +func TestInstallPackageEnvOnlyTouchesPrimaryRoot(t *testing.T) { + primary := filepath.Join(t.TempDir(), "primary") + secondary := filepath.Join(t.TempDir(), "secondary") + if err := os.WriteFile(secondary, []byte("not a directory"), 0o600); err != nil { + t.Fatal(err) + } + cfg := Config{Level: ConfigEnv, Location: primary + string(filepath.ListSeparator) + secondary} + archive := writeCustomPackageArchive(t, "name = \"Driver\"\nversion = \"1.0.0\"\n[Files]\ndriver = \"driver.so\"\n", packageFile("driver.so", "library")) + if _, err := InstallPackage(context.Background(), cfg, "driver", archive, InstallPackageOptions{}); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(secondary) + if err != nil || string(data) != "not a directory" { + t.Fatalf("secondary root changed: %q, %v", data, err) + } +} + +func TestInstallPackageSerializesSameDriver(t *testing.T) { + location := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: location} + firstArchive := writeCustomPackageArchive(t, "name = \"Driver\"\nversion = \"1.0.0\"\n[Files]\ndriver = \"driver.so\"\n", packageFile("driver.so", "first")) + secondArchive := writeCustomPackageArchive(t, "name = \"Driver\"\nversion = \"1.0.0\"\n[Files]\ndriver = \"driver.so\"\n", packageFile("driver.so", "second")) + entered := make(chan struct{}) + release := make(chan struct{}) + firstDone := make(chan error, 1) + go func() { + _, err := InstallPackage(context.Background(), cfg, "driver", firstArchive, InstallPackageOptions{Verifier: func(string, Manifest) error { + close(entered) + <-release + return nil + }}) + firstDone <- err + }() + select { + case <-entered: + case <-time.After(3 * time.Second): + t.Fatal("first install did not enter verifier") + } + secondEntered := make(chan struct{}) + secondDone := make(chan error, 1) + go func() { + _, err := InstallPackage(context.Background(), cfg, "driver", secondArchive, InstallPackageOptions{Verifier: func(string, Manifest) error { + close(secondEntered) + return nil + }}) + secondDone <- err + }() + select { + case <-secondEntered: + t.Fatal("second install entered verifier while first held the driver lock") + case <-time.After(100 * time.Millisecond): + } + close(release) + if err := <-firstDone; err != nil { + t.Fatal(err) + } + select { + case <-secondEntered: + case <-time.After(3 * time.Second): + t.Fatal("second install did not proceed after first released the lock") + } + if err := <-secondDone; err != nil { + t.Fatal(err) + } +} + +func TestInstallPackageSharesLockWithUninstall(t *testing.T) { + location := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: location} + installInitialPackage(t, cfg) + selected, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + archive := testPackageArchive(t, "new") + entered := make(chan struct{}) + release := make(chan struct{}) + installDone := make(chan error, 1) + go func() { + _, err := InstallPackage(context.Background(), cfg, "driver", archive, InstallPackageOptions{Verifier: func(string, Manifest) error { + close(entered) + <-release + return nil + }}) + installDone <- err + }() + select { + case <-entered: + case <-time.After(3 * time.Second): + t.Fatal("install did not enter verifier") + } + uninstallDone := make(chan error, 1) + go func() { uninstallDone <- UninstallDriver(cfg, selected) }() + select { + case err := <-uninstallDone: + t.Fatalf("uninstall completed while install held lock: %v", err) + case <-time.After(100 * time.Millisecond): + } + close(release) + if err := <-installDone; err != nil { + t.Fatal(err) + } + if err := <-uninstallDone; !errors.Is(err, errDriverRegistrationChanged) { + t.Fatalf("uninstall error = %v, want changed registration", err) + } + assertArchiveClosed(t, archive) +} + +func assertPreviousPackage(t *testing.T, cfg Config, path string) { + t.Helper() + registered, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + if got := registered.Driver.Shared.Get(PlatformTuple()); got != path { + t.Fatalf("registration path = %q, want %q", got, path) + } + data, err := os.ReadFile(path) + if err != nil || string(data) != "old" { + t.Fatalf("previous library = %q, %v; want exact original bytes %q", data, err, "old") + } +} + +// testPackageArchive builds a package whose driver.so holds exactly contents, +// so tests can assert real payload bytes. +func testPackageArchive(t *testing.T, contents string) *os.File { + t.Helper() + const manifest = "name = \"Driver\"\nversion = \"1.0.0\"\n[Files]\ndriver = \"driver.so\"\n" + path := filepath.Join(t.TempDir(), "package.tar.gz") + archive, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = archive.Close() }) + + gz := gzip.NewWriter(archive) + tarWriter := tar.NewWriter(gz) + writeFile := func(name, body string, mode int64) { + header := &tar.Header{Name: name, Typeflag: tar.TypeReg, Mode: mode, Size: int64(len(body))} + if err := tarWriter.WriteHeader(header); err != nil { + t.Fatal(err) + } + if _, err := tarWriter.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + writeFile("MANIFEST", manifest, 0o644) + writeFile("driver.so", contents, 0o4755) + if err := tarWriter.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + if _, err := archive.Seek(0, 0); err != nil { + t.Fatal(err) + } + return archive +} + +func testPackageInstallOperations() packageInstallOperations { + return packageInstallOperations{ + rename: os.Rename, + remove: os.Remove, + removeAll: os.RemoveAll, + register: createRuntimeRegistrationUnlocked, + writeReceipt: writePackageInstallReceipt, + } +} + +func installInitialPackage(t *testing.T, cfg Config) { + t.Helper() + archive := testPackageArchive(t, "old") + if _, err := InstallPackage(context.Background(), cfg, "driver", archive, InstallPackageOptions{}); err != nil { + t.Fatal(err) + } + assertArchiveClosed(t, archive) +} + +func assertArchiveClosed(t *testing.T, archive *os.File) { + t.Helper() + if _, err := archive.Stat(); err == nil { + t.Fatal("archive remains open") + } +} + +func assertOnlyPackageGeneration(t *testing.T, location, expected string) { + t.Helper() + entries, err := os.ReadDir(location) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if packageGenerationNameMatches(entry.Name(), "driver") || strings.HasPrefix(entry.Name(), ".dbc-package-stage-") { + if entry.Name() != expected { + t.Fatalf("unexpected package candidate remains: %s", entry.Name()) + } + } + } +} + +func packageGenerationNames(t *testing.T, location, runtimeID string) []string { + t.Helper() + entries, err := os.ReadDir(location) + if err != nil { + t.Fatal(err) + } + var names []string + for _, entry := range entries { + if packageGenerationNameMatches(entry.Name(), runtimeID) { + names = append(names, entry.Name()) + } + } + return names +} + +func testPackageGenerationPath(t *testing.T, root, runtimeID, suffix string) string { + t.Helper() + prefix, err := packageGenerationPrefix(runtimeID) + if err != nil { + t.Fatal(err) + } + return filepath.Join(root, prefix+suffix) +} + +func packageGenerationNameMatches(name, runtimeID string) bool { + generationID, valid := parsePackageGenerationName(name) + return valid && sameRuntimeID(generationID, runtimeID) +} diff --git a/config/install_package_umask_unix_test.go b/config/install_package_umask_unix_test.go new file mode 100644 index 00000000..8f911f4d --- /dev/null +++ b/config/install_package_umask_unix_test.go @@ -0,0 +1,53 @@ +// 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 && !js + +package config + +import ( + "context" + "fmt" + "os" + "path/filepath" + "syscall" + "testing" +) + +// The umask is process-global, so these subtests must not run in parallel. +func TestInstallPackageGenerationModeHonorsUmask(t *testing.T) { + for _, tc := range []struct { + umask int + want os.FileMode + }{{0o077, 0o700}, {0o022, 0o755}} { + t.Run(fmt.Sprintf("umask_%03o", tc.umask), func(t *testing.T) { + old := syscall.Umask(tc.umask) + t.Cleanup(func() { syscall.Umask(old) }) + location := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: location} + archive := writeCustomPackageArchive(t, "name = \"Driver\"\nversion = \"1.0.0\"\n[Files]\ndriver = \"driver.so\"\n", packageFile("driver.so", "library")) + manifest, err := InstallPackage(context.Background(), cfg, "driver", archive, InstallPackageOptions{}) + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Dir(manifest.Driver.Shared.Get(PlatformTuple()))) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != tc.want { + t.Fatalf("umask %#o: generation mode = %#o, want %#o", tc.umask, got, tc.want) + } + }) + } +} diff --git a/config/install_platform_unixlike.go b/config/install_platform_unixlike.go new file mode 100644 index 00000000..de923d5b --- /dev/null +++ b/config/install_platform_unixlike.go @@ -0,0 +1,91 @@ +// 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 ( + "fmt" + "os" + + "github.com/columnar-tech/dbc/internal/hostpath" +) + +func uninstallLockLocation(_ Config, info DriverInfo) (string, error) { + if info.FilePath == "" { + return "", fmt.Errorf("driver %q has no installation location", info.ID) + } + return info.FilePath, nil +} + +func prepareDriverUninstallLockLocation(Config, string) error { + return nil +} + +func packageCleanupRoot(_ Config, info DriverInfo) (string, error) { + if info.FilePath == "" { + return "", nil + } + return info.FilePath, nil +} + +func readDriverRegistrationForUninstall(_ Config, info DriverInfo) (DriverInfo, error) { + return loadDriverFromManifest(info.FilePath, info.ID) +} + +func removeDriverRegistration(cfg Config, info DriverInfo, operations packageCleanupOperations) error { + if operations.removeRegistration != nil { + return operations.removeRegistration(cfg, info) + } + manifest := hostpath.Join(info.FilePath, info.ID+".toml") + if err := os.Remove(manifest); err != nil { + return fmt.Errorf("error removing manifest %s: %w", manifest, err) + } + // Remove the symlink created during installation (one level up from the + // manifest) + // TODO: Remove this when the driver managers are fixed (>=1.8.1). + removeManifestSymlink(info.FilePath, info.ID) + return nil +} + +func packageRegistrationScopeForConfig(Config) (packageRegistrationScope, error) { + return packageRegistrationFile, nil +} + +func createRuntimeRegistrationUnlocked(_ Config, location string, driver DriverInfo) error { + return createDriverManifestUnlocked(location, driver) +} + +func packageInstallLockLocation(_ Config, payloadLocation string) (string, error) { + return payloadLocation, nil +} + +func preparePackageInstallLockLocation(Config, string) error { + return nil +} + +func registrationNamespaceLockSpec(_ Config, location string) (identity, lockDirectory, registrationLocation string, err error) { + return fileRegistrationNamespaceLockSpec(location) +} + +func collectScopedRegistrationSharedMaps(_ Config, location, excludedID string) ([]driverMap, bool, error) { + return collectFileRegistrationSharedMaps(location, excludedID) +} + +func readPrimaryRuntimeRegistration(_ Config, registrationLocation, runtimeID string) (DriverInfo, bool) { + return readFileRuntimeRegistration(registrationLocation, runtimeID) +} + +func registrationFingerprintIncludesADBC(Config) bool { return true } diff --git a/config/install_platform_windows.go b/config/install_platform_windows.go new file mode 100644 index 00000000..7ced918f --- /dev/null +++ b/config/install_platform_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 config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/windows/registry" +) + +func uninstallLockLocation(cfg Config, info DriverInfo) (string, error) { + switch cfg.Level { + case ConfigUser, ConfigSystem: + return registrationLockLocation(cfg) + case ConfigEnv: + if info.FilePath != "" { + return info.FilePath, nil + } + if cfg.Location != "" { + return cfg.Location, nil + } + return "", fmt.Errorf("driver %q has no installation location", info.ID) + default: + return "", fmt.Errorf("driver %q has unsupported config level %d", info.ID, cfg.Level) + } +} + +func prepareDriverUninstallLockLocation(cfg Config, location string) error { + return prepareRegistrationLockLocation(cfg, location) +} + +func packageCleanupRoot(cfg Config, info DriverInfo) (string, error) { + if cfg.Level == ConfigEnv { + if info.FilePath == "" { + return "", nil + } + return info.FilePath, nil + } + if cfg.Level == ConfigUser || cfg.Level == ConfigSystem { + if cfg.Location != "" { + return cfg.Location, nil + } + return cfg.Level.ConfigLocation(), nil + } + if info.FilePath == "" { + return "", nil + } + return info.FilePath, nil +} + +// Registry mutations are serialized with file locks under ConfigLocation. +// This coordinates cooperating processes only when they resolve the same lock +// directory. In particular, HKCU is shared even if processes have different +// APPDATA values, so this assumes a stable per-user ConfigLocation. +// TODO: Derive a stable lock identity from the HKCU user/hive so differing +// APPDATA values cannot split cooperating processes across lock directories. +func registrationLockLocation(cfg Config) (string, error) { + if cfg.Level == ConfigEnv { + if cfg.Location == "" { + return "", fmt.Errorf("cannot write manifest to env config without %s set", adbcEnvVar) + } + return EnsureLocation(cfg) + } + if cfg.Level != ConfigUser && cfg.Level != ConfigSystem { + return "", fmt.Errorf("unsupported config level %d", cfg.Level) + } + location := cfg.Level.ConfigLocation() + if location == "" { + return "", errors.New("driver registration location is empty") + } + return location, nil +} + +func prepareRegistrationLockLocation(cfg Config, location string) error { + if cfg.Level == ConfigEnv { + return nil + } + return os.MkdirAll(location, 0o755) +} + +func readDriverRegistrationForUninstall(cfg Config, info DriverInfo) (DriverInfo, error) { + if cfg.Level != ConfigEnv { + return GetDriver(cfg, info.ID) + } + return loadDriverFromManifest(info.FilePath, info.ID) +} + +func removeDriverRegistration(cfg Config, info DriverInfo, operations packageCleanupOperations) error { + if operations.removeRegistration != nil { + return operations.removeRegistration(cfg, info) + } + if cfg.Level != ConfigEnv { + k, err := registry.OpenKey(cfg.Level.key(), regKeyADBC, registry.ALL_ACCESS) + if err != nil { + return fmt.Errorf("open driver registry key for removal: %w", err) + } + deleteErr := registry.DeleteKey(k, info.ID) + closeErr := k.Close() + if deleteErr != nil { + return fmt.Errorf("failed to delete driver registry key: %w", errors.Join(deleteErr, closeErr)) + } + return nil + } + manifest := filepath.Join(info.FilePath, info.ID+".toml") + if err := os.Remove(manifest); err != nil { + return fmt.Errorf("error removing manifest %s: %w", manifest, err) + } + // TODO: Remove this when the driver managers are fixed (>=1.8.1). + removeManifestSymlink(info.FilePath, info.ID) + return nil +} + +func packageRegistrationScopeForConfig(cfg Config) (packageRegistrationScope, error) { + switch cfg.Level { + case ConfigEnv: + return packageRegistrationFile, nil + case ConfigUser: + return packageRegistrationRegistryUser, nil + case ConfigSystem: + return packageRegistrationRegistrySystem, nil + default: + return "", fmt.Errorf("unsupported config level %d", cfg.Level) + } +} + +func createRuntimeRegistrationUnlocked(cfg Config, location string, driver DriverInfo) error { + if cfg.Level == ConfigEnv { + return createDriverManifestUnlocked(location, driver) + } + return createRegistryManifestUnlocked(cfg, driver) +} + +func packageInstallLockLocation(cfg Config, payloadLocation string) (string, error) { + if cfg.Level == ConfigEnv { + return payloadLocation, nil + } + location, err := registrationLockLocation(cfg) + if err != nil { + return "", fmt.Errorf("resolve registration lock location: %w", err) + } + return location, nil +} + +func preparePackageInstallLockLocation(cfg Config, lockLocation string) error { + if cfg.Level == ConfigEnv { + return nil + } + return prepareRegistrationLockLocation(cfg, lockLocation) +} + +func registrationNamespaceLockSpec(cfg Config, location string) (identity, lockDirectory, registrationLocation string, err error) { + switch cfg.Level { + case ConfigEnv: + return fileRegistrationNamespaceLockSpec(location) + case ConfigUser: + // The lock file lives under ConfigLocation(), so processes with different + // APPDATA values do not share this lock despite mutating the same registry. + // TODO: Derive a stable HKCU lock identity independent of APPDATA. + return "registry-user:HKCU\\SOFTWARE\\ADBC\\Drivers", ConfigUser.ConfigLocation(), "HKCU\\SOFTWARE\\ADBC\\Drivers", nil + case ConfigSystem: + return "registry-system:HKLM\\SOFTWARE\\ADBC\\Drivers", ConfigSystem.ConfigLocation(), "HKLM\\SOFTWARE\\ADBC\\Drivers", nil + default: + return "", "", "", fmt.Errorf("unsupported registration config level %d", cfg.Level) + } +} + +func collectScopedRegistrationSharedMaps(cfg Config, location, excludedID string) ([]driverMap, bool, error) { + if cfg.Level == ConfigEnv { + return collectFileRegistrationSharedMaps(location, excludedID) + } + if cfg.Level != ConfigUser && cfg.Level != ConfigSystem { + return nil, false, fmt.Errorf("unsupported registration config level %d", cfg.Level) + } + root, err := registry.OpenKey(cfg.Level.key(), regKeyADBC, registry.READ) + if errors.Is(err, registry.ErrNotExist) { + return nil, true, nil + } + if err != nil { + return nil, false, fmt.Errorf("open registration registry namespace: %w", err) + } + // Registry registrations do not carry a manifest directory. Resolve + // relative shared paths against the config level's filesystem root, which + // is the root used by external-library cleanup for that registry scope. + filesystemRoot := cfg.Level.ConfigLocation() + if filesystemRoot == "" { + return nil, false, errors.Join(errors.New("registration filesystem root is empty"), root.Close()) + } + info, err := root.Stat() + if err != nil { + return nil, false, errors.Join(fmt.Errorf("inspect registration registry namespace: %w", err), root.Close()) + } + names, err := root.ReadSubKeyNames(int(info.SubKeyCount)) + if err != nil { + return nil, false, errors.Join(fmt.Errorf("enumerate registration registry namespace: %w", err), root.Close()) + } + var shared []driverMap + for _, name := range names { + if strings.EqualFold(name, excludedID) { + continue + } + registration, err := driverInfoFromKey(root, name, cfg.Level) + if err != nil { + return nil, false, errors.Join(fmt.Errorf("read registration %q: %w", name, err), root.Close()) + } + resolvedShared, err := sharedPathsFromRegistrationRoot(filesystemRoot, registration.Driver.Shared) + if err != nil { + return nil, false, errors.Join(fmt.Errorf("resolve shared paths in registration %q: %w", name, err), root.Close()) + } + shared = append(shared, resolvedShared) + } + if err := root.Close(); err != nil { + return nil, false, fmt.Errorf("close registration registry namespace: %w", err) + } + return shared, true, nil +} + +func readPrimaryRuntimeRegistration(cfg Config, registrationLocation, runtimeID string) (DriverInfo, bool) { + if cfg.Level == ConfigEnv { + return readFileRuntimeRegistration(registrationLocation, runtimeID) + } + if cfg.Level != ConfigUser && cfg.Level != ConfigSystem { + return DriverInfo{}, false + } + root, err := registry.OpenKey(cfg.Level.key(), regKeyADBC, registry.READ) + if err != nil { + return DriverInfo{}, false + } + registration, readErr := driverInfoFromKey(root, runtimeID, cfg.Level) + closeErr := root.Close() + if readErr != nil || closeErr != nil || registration.ID != runtimeID { + return DriverInfo{}, false + } + return registration, true +} + +func registrationFingerprintIncludesADBC(cfg Config) bool { return cfg.Level == ConfigEnv } diff --git a/config/loaddir_js.go b/config/loaddir_js.go index 06b69c82..54bb106d 100644 --- a/config/loaddir_js.go +++ b/config/loaddir_js.go @@ -18,8 +18,9 @@ package config import ( "os" - "path/filepath" "strings" + + "github.com/columnar-tech/dbc/internal/hostpath" ) // loadDir lists installed drivers from a directory. Under GOOS=js, os.ReadDir @@ -42,12 +43,12 @@ func loadDir(dir string) (map[string]DriverInfo, error) { if e.IsDir() || !strings.HasSuffix(e.Name(), ".toml") { continue } - p := filepath.Join(dir, e.Name()) - di, err := loadDriverFromManifest(filepath.Dir(p), filepath.Base(p)) + p := hostpath.Join(dir, e.Name()) + di, err := loadDriverFromManifest(hostpath.Dir(p), hostpath.Base(p)) if err != nil { continue } - di.FilePath = filepath.Dir(p) + di.FilePath = hostpath.Dir(p) ret[di.ID] = di } return ret, nil diff --git a/config/package_archive.go b/config/package_archive.go new file mode 100644 index 00000000..8d60cc32 --- /dev/null +++ b/config/package_archive.go @@ -0,0 +1,339 @@ +// 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. + +package config + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "unicode" + "unicode/utf8" + + "github.com/columnar-tech/dbc/internal/hostpath" +) + +var errPackageArchiveByteLimit = errors.New("package archive exceeds its decompressed byte limit") + +const maxPackageManifestSize = 1 << 20 + +type packageArchiveLimits struct { + manifestSize int64 + entrySize int64 + totalSize int64 + entryCount int + metadataSize int64 +} + +// packageArchiveByteBudget allows limit bytes through and probes one extra byte +// to distinguish exact EOF from an over-limit stream. +type packageArchiveByteBudget struct { + reader io.Reader + limit int64 + read int64 + exceeded bool +} + +func (b *packageArchiveByteBudget) extend(n int64) error { + if n < 0 || n > int64(^uint64(0)>>1)-b.limit { + return errors.New("package archive byte budget overflow") + } + b.limit += n + return nil +} + +func (b *packageArchiveByteBudget) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + if b.exceeded { + return 0, errPackageArchiveByteLimit + } + remaining := b.limit - b.read + if remaining <= 0 { + var probe [1]byte + n, err := b.reader.Read(probe[:]) + if n > 0 { + b.exceeded = true + return 0, errPackageArchiveByteLimit + } + return 0, err + } + if int64(len(p)) > remaining { + p = p[:int(remaining)] + } + n, err := b.reader.Read(p) + b.read += int64(n) + return n, err +} + +var defaultPackageArchiveLimits = packageArchiveLimits{ + manifestSize: maxPackageManifestSize, + entrySize: 2 << 30, + totalSize: 4 << 30, + entryCount: 4096, + metadataSize: 16 << 20, +} + +// TODO: InflateTarball has no production callers left in this repo; consider +// unexporting or removing it in a future breaking release. +func InflateTarball(f *os.File, outDir string) (Manifest, error) { + return inflateTarballWithLimits(f, outDir, defaultPackageArchiveLimits) +} + +func inflateTarballWithLimits(f *os.File, outDir string, limits packageArchiveLimits) (Manifest, error) { + defer f.Close() + if _, err := f.Seek(0, io.SeekStart); err != nil { + return Manifest{}, fmt.Errorf("could not seek to start: %w", err) + } + + stageParent := hostpath.Dir(hostpath.Clean(outDir)) + stageDir, manifest, payloadNames, err := extractPackageArchiveWithLimits(f, stageParent, limits) + if err != nil { + return Manifest{}, err + } + defer os.RemoveAll(stageDir) + + if err := hostpath.MkdirAll(outDir, 0o755); err != nil { + return Manifest{}, fmt.Errorf("could not create output directory %s: %w", outDir, err) + } + root, err := os.OpenRoot(outDir) + if err != nil { + return Manifest{}, fmt.Errorf("could not open output directory %s: %w", outDir, err) + } + defer root.Close() + for _, name := range payloadNames { + src := hostpath.Join(stageDir, name) + if err := copyPackageFile(src, root, name); err != nil { + return Manifest{}, fmt.Errorf("could not publish package file %s: %w", name, err) + } + } + return manifest, nil +} + +// extractPackageArchive validates the complete archive before returning a +// private staging directory. The caller owns the returned directory. +func extractPackageArchive(f *os.File, stageParent string) (string, Manifest, []string, error) { + return extractPackageArchiveWithLimits(f, stageParent, defaultPackageArchiveLimits) +} + +func extractPackageArchiveWithLimits(f *os.File, stageParent string, limits packageArchiveLimits) (string, Manifest, []string, error) { + var manifest Manifest + if _, err := f.Seek(0, io.SeekStart); err != nil { + return "", manifest, nil, fmt.Errorf("could not seek to start: %w", err) + } + if err := hostpath.MkdirAll(stageParent, 0o755); err != nil { + return "", manifest, nil, fmt.Errorf("could not create staging parent: %w", err) + } + stageDir, err := os.MkdirTemp(stageParent, ".dbc-package-stage-") + if err != nil { + return "", manifest, nil, fmt.Errorf("could not create private staging directory: %w", err) + } + keepStage := false + defer func() { + if !keepStage { + _ = os.RemoveAll(stageDir) + } + }() + + gz, err := gzip.NewReader(f) + if err != nil { + return "", manifest, nil, fmt.Errorf("could not create gzip reader: %w", err) + } + defer gz.Close() + + budget := &packageArchiveByteBudget{reader: gz, limit: limits.metadataSize} + tr := tar.NewReader(budget) + seen := make([]string, 0) + payloads := make(map[string]struct{}) + payloadNames := make([]string, 0) + manifestSeen := false + declaredRemaining := limits.totalSize + entryCount := 0 + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return "", manifest, nil, fmt.Errorf("error reading tarball: %w", err) + } + entryCount++ + if entryCount > limits.entryCount { + return "", manifest, nil, fmt.Errorf("archive exceeds the limit of %d entries", limits.entryCount) + } + if hdr.Typeflag == tar.TypeXGlobalHeader { + continue + } + if hdr.Size < 0 { + return "", manifest, nil, fmt.Errorf("archive entry %q has a negative size", hdr.Name) + } + if hdr.Typeflag == tar.TypeDir { + return "", manifest, nil, fmt.Errorf("found a directory entry %q which isn't supported; driver archives shouldn't contain subdirectories", hdr.Name) + } + if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA { + return "", manifest, nil, fmt.Errorf("archive entry %q has unsupported type %d", hdr.Name, hdr.Typeflag) + } + if hdr.Size > limits.entrySize { + return "", manifest, nil, fmt.Errorf("archive entry %q exceeds the limit of %d bytes", hdr.Name, limits.entrySize) + } + if hdr.Size > declaredRemaining { + return "", manifest, nil, fmt.Errorf("archive exceeds the limit of %d regular file bytes", limits.totalSize) + } + if err := validatePackageFilename(hdr.Name); err != nil { + return "", manifest, nil, err + } + if strings.EqualFold(hdr.Name, packageInstallReceiptFilename) { + return "", manifest, nil, fmt.Errorf("archive entry %q uses a reserved package receipt filename", hdr.Name) + } + for _, previous := range seen { + if strings.EqualFold(previous, hdr.Name) { + return "", manifest, nil, fmt.Errorf("duplicate or case-fold-colliding archive entry %q", hdr.Name) + } + } + seen = append(seen, hdr.Name) + if hasSparsePackagePAXRecord(hdr.PAXRecords) { + return "", manifest, nil, fmt.Errorf("sparse archive entry %q is not supported", hdr.Name) + } + + if hdr.Name == "MANIFEST" { + if manifestSeen { + return "", manifest, nil, errors.New("archive contains duplicate MANIFEST entries") + } + if hdr.Size > limits.manifestSize { + return "", manifest, nil, fmt.Errorf("MANIFEST exceeds %d bytes", limits.manifestSize) + } + } + declaredRemaining -= hdr.Size + if err := budget.extend(hdr.Size); err != nil { + return "", manifest, nil, err + } + + if hdr.Name == "MANIFEST" { + manifestSeen = true + data, err := io.ReadAll(io.LimitReader(tr, limits.manifestSize+1)) + if err != nil { + return "", manifest, nil, fmt.Errorf("could not read MANIFEST: %w", err) + } + if int64(len(data)) != hdr.Size { + return "", manifest, nil, fmt.Errorf("MANIFEST size mismatch: expected %d bytes, read %d", hdr.Size, len(data)) + } + manifest, err = decodeManifest(bytes.NewReader(data), "", false) + if err != nil { + return "", Manifest{}, nil, fmt.Errorf("could not decode manifest: %w", err) + } + continue + } + + file, err := os.OpenFile(hostpath.Join(stageDir, hdr.Name), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o666) + if err != nil { + return "", manifest, nil, fmt.Errorf("could not create staged file %s: %w", hdr.Name, err) + } + written, copyErr := io.CopyN(file, tr, hdr.Size) + closeErr := file.Close() + if copyErr != nil { + return "", manifest, nil, fmt.Errorf("could not write staged file %s: %w", hdr.Name, copyErr) + } + if closeErr != nil { + return "", manifest, nil, fmt.Errorf("could not close staged file %s: %w", hdr.Name, closeErr) + } + if written != hdr.Size { + return "", manifest, nil, fmt.Errorf("archive entry %s size mismatch: expected %d bytes, wrote %d", hdr.Name, hdr.Size, written) + } + payloads[hdr.Name] = struct{}{} + payloadNames = append(payloadNames, hdr.Name) + } + + if _, err := io.Copy(io.Discard, budget); err != nil { + return "", manifest, nil, fmt.Errorf("error validating gzip stream: %w", err) + } + if !manifestSeen { + return "", manifest, nil, errors.New("archive does not contain a MANIFEST") + } + if manifest.Files.Driver == "" { + return "", Manifest{}, nil, errors.New("package manifest does not specify Files.driver") + } + for _, ref := range []struct { + kind string + name string + }{{"driver", manifest.Files.Driver}, {"signature", manifest.Files.Signature}} { + if ref.name == "" { + continue + } + if err := validatePackageFilename(ref.name); err != nil { + return "", Manifest{}, nil, fmt.Errorf("invalid Files.%s reference: %w", ref.kind, err) + } + if _, ok := payloads[ref.name]; !ok { + return "", Manifest{}, nil, fmt.Errorf("Files.%s references missing archive entry %q", ref.kind, ref.name) + } + } + keepStage = true + return stageDir, manifest, payloadNames, nil +} + +func hasSparsePackagePAXRecord(records map[string]string) bool { + for key := range records { + if strings.HasPrefix(key, "GNU.sparse") { + return true + } + } + return false +} + +func validatePackageFilename(name string) error { + if name == "" || name == "." || name == ".." || filepath.IsAbs(name) || + strings.ContainsAny(name, `/\\`) || !utf8.ValidString(name) || + strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") { + return fmt.Errorf("unsafe archive filename %q", name) + } + for _, r := range name { + if unicode.IsControl(r) || strings.ContainsRune(`<>:"|?*`, r) { + return fmt.Errorf("unsafe archive filename %q", name) + } + } + base := strings.ToUpper(strings.TrimRight(strings.SplitN(name, ".", 2)[0], " .")) + switch base { + case "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$", + "COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³": + return fmt.Errorf("unsafe reserved archive filename %q", name) + } + if len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) && base[3] >= '1' && base[3] <= '9' { + return fmt.Errorf("unsafe reserved archive filename %q", name) + } + return nil +} + +func copyPackageFile(src string, root *os.Root, name string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := root.OpenFile(name, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o666) + if err != nil { + return err + } + _, copyErr := io.Copy(out, in) + closeErr := out.Close() + if copyErr != nil { + return copyErr + } + return closeErr +} diff --git a/config/package_archive_mode_unixlike_test.go b/config/package_archive_mode_unixlike_test.go new file mode 100644 index 00000000..2d2d9c6f --- /dev/null +++ b/config/package_archive_mode_unixlike_test.go @@ -0,0 +1,55 @@ +//go:build !windows && !js + +// 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. + +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInflateTarballDoesNotInheritArchiveMode(t *testing.T) { + f := writePackageArchive(t, packageFile("MANIFEST", ""), packageFile("driver.so", "x")) + out := t.TempDir() + _, err := InflateTarball(f, out) + require.NoError(t, err) + info, err := os.Stat(filepath.Join(out, "driver.so")) + require.NoError(t, err) + assert.Zero(t, info.Mode().Perm()&0o111) + assert.Zero(t, info.Mode()&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky)) + baseline, err := os.Create(filepath.Join(out, "mode-baseline")) + require.NoError(t, err) + baselineInfo, err := baseline.Stat() + require.NoError(t, err) + require.NoError(t, baseline.Close()) + assert.Equal(t, baselineInfo.Mode().Perm(), info.Mode().Perm()) +} + +func TestInflateTarballPreservesExistingFileMode(t *testing.T) { + f := writePackageArchive(t, packageFile("MANIFEST", ""), packageFile("driver.so", "x")) + out := t.TempDir() + driverPath := filepath.Join(out, "driver.so") + require.NoError(t, os.WriteFile(driverPath, []byte("old"), 0o600)) + _, err := InflateTarball(f, out) + require.NoError(t, err) + info, err := os.Stat(driverPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} diff --git a/config/package_archive_test.go b/config/package_archive_test.go new file mode 100644 index 00000000..a2f1c8d0 --- /dev/null +++ b/config/package_archive_test.go @@ -0,0 +1,421 @@ +// 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. + +package config + +import ( + "archive/tar" + "compress/gzip" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writePackageArchive(t *testing.T, entries ...*tar.Header) *os.File { + t.Helper() + driverName := "" + for _, header := range entries { + if header.Name != "MANIFEST" && (header.Typeflag == tar.TypeReg || header.Typeflag == tar.TypeRegA) { + driverName = header.Name + break + } + } + path := filepath.Join(t.TempDir(), "package.tar.gz") + f, err := os.Create(path) + require.NoError(t, err) + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + for _, header := range entries { + var body string + if header.Name == "MANIFEST" { + body = "name = \"Test Driver\"\nversion = \"1.0.0\"\n" + if driverName != "" { + body += "[Files]\ndriver = \"" + driverName + "\"\n" + } + header.Size = int64(len(body)) + } else if header.Size > 0 { + body = strings.Repeat("x", int(header.Size)) + } + require.NoError(t, tw.WriteHeader(header)) + if header.Typeflag == tar.TypeReg || header.Typeflag == tar.TypeRegA { + _, err = tw.Write([]byte(body)) + require.NoError(t, err) + } + } + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) + _, err = f.Seek(0, io.SeekStart) + require.NoError(t, err) + return f +} + +func packageFile(name, body string) *tar.Header { + return &tar.Header{Name: name, Typeflag: tar.TypeReg, Mode: 0o4755, Size: int64(len(body))} +} + +func TestInflateTarballStagesAndPublishes(t *testing.T) { + t.Run("empty destination and nonzero input offset", func(t *testing.T) { + f := writePackageArchive(t, packageFile("MANIFEST", ""), packageFile("driver.so", "driver")) + _, err := f.Seek(7, io.SeekStart) + require.NoError(t, err) + out := t.TempDir() + manifest, err := InflateTarball(f, out) + require.NoError(t, err) + assert.Equal(t, "Test Driver", manifest.Name) + data, err := os.ReadFile(filepath.Join(out, "driver.so")) + require.NoError(t, err) + assert.Equal(t, "xxxxxx", string(data)) + _, err = f.Stat() + assert.Error(t, err) + }) + + t.Run("nonempty destination overwrites and retains other files", func(t *testing.T) { + f := writePackageArchive(t, packageFile("MANIFEST", ""), packageFile("driver.so", "new")) + out := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(out, "driver.so"), []byte("old"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(out, "keep.txt"), []byte("keep"), 0o600)) + _, err := InflateTarball(f, out) + require.NoError(t, err) + data, err := os.ReadFile(filepath.Join(out, "driver.so")) + require.NoError(t, err) + assert.Equal(t, "xxx", string(data)) + assert.FileExists(t, filepath.Join(out, "keep.txt")) + }) + + t.Run("invalid archive does not publish prefix", func(t *testing.T) { + f := writePackageArchive(t, packageFile("MANIFEST", ""), packageFile("first.so", "new"), packageFile("../escape", "x")) + out := filepath.Join(t.TempDir(), "destination") + require.NoError(t, os.Mkdir(out, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(out, "first.so"), []byte("old"), 0o600)) + _, err := InflateTarball(f, out) + require.Error(t, err) + _, closeErr := f.Stat() + assert.Error(t, closeErr) + data, err := os.ReadFile(filepath.Join(out, "first.so")) + require.NoError(t, err) + assert.Equal(t, "old", string(data)) + assert.NoFileExists(t, filepath.Join(out, "escape")) + }) + + t.Run("manifest only archive is rejected", func(t *testing.T) { + f := writePackageArchive(t, packageFile("MANIFEST", "")) + _, err := InflateTarball(f, t.TempDir()) + require.ErrorContains(t, err, "does not specify Files.driver") + }) +} + +func TestPackageArchiveAcceptsPAXMetadata(t *testing.T) { + manifest := "name = \"Test Driver\"\nversion = \"1.0.0\"\n[Files]\ndriver = \"driver.so\"\n" + f := writePackageArchive(t, + &tar.Header{Name: "global", Typeflag: tar.TypeXGlobalHeader, PAXRecords: map[string]string{"comment": "global metadata"}}, + &tar.Header{Name: "MANIFEST", Typeflag: tar.TypeReg, Mode: 0o644, Size: int64(len(manifest)), Format: tar.FormatPAX, ModTime: time.Unix(1, 123456789)}, + &tar.Header{Name: "driver.so", Typeflag: tar.TypeReg, Mode: 0o644, Size: 1, Format: tar.FormatPAX, PAXRecords: map[string]string{"comment": "file metadata"}}, + ) + defer f.Close() + stage, _, _, err := extractPackageArchive(f, t.TempDir()) + require.NoError(t, err) + defer os.RemoveAll(stage) + assert.FileExists(t, filepath.Join(stage, "driver.so")) +} + +func TestPackageArchiveRejectsUnsafeEntries(t *testing.T) { + tests := []struct { + name string + entries []*tar.Header + }{ + {name: "absolute", entries: []*tar.Header{packageFile("MANIFEST", ""), packageFile("/tmp/escape", "x")}}, + {name: "traversal", entries: []*tar.Header{packageFile("MANIFEST", ""), packageFile("../escape", "x")}}, + {name: "separator", entries: []*tar.Header{packageFile("MANIFEST", ""), packageFile("sub\\file", "x")}}, + {name: "control", entries: []*tar.Header{packageFile("MANIFEST", ""), packageFile("bad\nname", "x")}}, + {name: "trailing dot", entries: []*tar.Header{packageFile("MANIFEST", ""), packageFile("bad.", "x")}}, + {name: "trailing space", entries: []*tar.Header{packageFile("MANIFEST", ""), packageFile("bad ", "x")}}, + {name: "reserved", entries: []*tar.Header{packageFile("MANIFEST", ""), packageFile("CON.txt", "x")}}, + {name: "directory", entries: []*tar.Header{packageFile("MANIFEST", ""), {Name: "subdir", Typeflag: tar.TypeDir}}}, + {name: "symlink", entries: []*tar.Header{packageFile("MANIFEST", ""), {Name: "link", Typeflag: tar.TypeSymlink, Linkname: "target"}}}, + {name: "hardlink", entries: []*tar.Header{packageFile("MANIFEST", ""), {Name: "link", Typeflag: tar.TypeLink, Linkname: "target"}}}, + {name: "device", entries: []*tar.Header{packageFile("MANIFEST", ""), {Name: "device", Typeflag: tar.TypeChar}}}, + {name: "duplicate", entries: []*tar.Header{packageFile("MANIFEST", ""), packageFile("same", "a"), packageFile("same", "b")}}, + {name: "case collision", entries: []*tar.Header{packageFile("MANIFEST", ""), packageFile("Foo", "a"), packageFile("foo", "b")}}, + {name: "reserved receipt filename", entries: []*tar.Header{packageFile("MANIFEST", ""), packageFile(strings.ToUpper(packageInstallReceiptFilename), "x")}}, + {name: "sparse", entries: []*tar.Header{packageFile("MANIFEST", ""), {Name: "sparse", Typeflag: tar.TypeGNUSparse}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := writePackageArchive(t, tt.entries...) + defer f.Close() + stage, _, _, err := extractPackageArchive(f, t.TempDir()) + assert.Error(t, err) + assert.Empty(t, stage) + }) + } +} + +func TestPackageArchiveRejectsSparsePAXMetadata(t *testing.T) { + assert.True(t, hasSparsePackagePAXRecord(map[string]string{"GNU.sparse.size": "10"})) + assert.True(t, hasSparsePackagePAXRecord(map[string]string{"GNU.sparse": "value"})) + assert.False(t, hasSparsePackagePAXRecord(map[string]string{"comment": "metadata"})) +} + +func TestPackageArchiveValidatesManifestReferences(t *testing.T) { + tests := []struct { + name string + manifest string + }{ + {name: "missing driver", manifest: "name = \"Driver\"\nversion = \"1.0.0\"\n[Files]\ndriver = \"missing.so\"\n"}, + {name: "missing signature", manifest: "name = \"Driver\"\nversion = \"1.0.0\"\n[Files]\ndriver = \"other\"\nsignature = \"missing.sig\"\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := writeCustomPackageArchive(t, tt.manifest, packageFile("other", "x")) + defer f.Close() + _, _, _, err := extractPackageArchive(f, t.TempDir()) + assert.Error(t, err) + }) + } +} + +func TestPackageArchiveRejectsLargeManifestAndBadGzipFooter(t *testing.T) { + t.Run("manifest at limit", func(t *testing.T) { + manifest := "name = \"Driver\"\nversion = \"1.0.0\"\n[Files]\ndriver = \"driver.so\"\n" + manifest += strings.Repeat(" ", maxPackageManifestSize-len(manifest)) + f := writeCustomPackageArchive(t, manifest, packageFile("driver.so", "x")) + defer f.Close() + stage, _, _, err := extractPackageArchive(f, t.TempDir()) + require.NoError(t, err) + defer os.RemoveAll(stage) + }) + + t.Run("manifest limit", func(t *testing.T) { + f := writeCustomPackageArchive(t, strings.Repeat("x", maxPackageManifestSize+1)) + defer f.Close() + _, _, _, err := extractPackageArchive(f, t.TempDir()) + assert.ErrorContains(t, err, "exceeds") + }) + + t.Run("gzip checksum", func(t *testing.T) { + f := writePackageArchive(t, packageFile("MANIFEST", ""), packageFile("driver.so", "x")) + _, err := f.Seek(-1, io.SeekEnd) + require.NoError(t, err) + last := []byte{0} + _, err = f.Read(last) + require.NoError(t, err) + last[0] ^= 0xff + _, err = f.Seek(-1, io.SeekEnd) + require.NoError(t, err) + _, err = f.Write(last) + require.NoError(t, err) + out := t.TempDir() + _, err = InflateTarball(f, out) + assert.Error(t, err) + _, closeErr := f.Stat() + assert.Error(t, closeErr) + assert.NoFileExists(t, filepath.Join(out, "driver.so")) + }) +} + +func TestPackageArchiveLimits(t *testing.T) { + t.Run("single declared size over production limit is rejected from header", func(t *testing.T) { + f := writeHeaderOnlyPackageArchive(t, &tar.Header{Name: "large", Typeflag: tar.TypeReg, Size: defaultPackageArchiveLimits.entrySize + 1}) + assertArchiveLimitFailure(t, f, defaultPackageArchiveLimits) + }) + + t.Run("regular total at limit succeeds and one over fails", func(t *testing.T) { + manifest := "name = \"Test Driver\"\nversion = \"1.0.0\"\n[Files]\ndriver = \"data\"\n" + payloadSize := int64(8) + limits := testPackageArchiveLimits() + limits.totalSize = int64(len(manifest)) + payloadSize + f := writeCustomPackageArchive(t, manifest, packageFile("data", strings.Repeat("x", int(payloadSize)))) + stage, _, _, err := extractPackageArchiveWithLimits(f, t.TempDir(), limits) + require.NoError(t, err) + assert.NoError(t, os.RemoveAll(stage)) + assert.NoError(t, f.Close()) + + limits.totalSize-- + f = writeCustomPackageArchive(t, manifest, packageFile("data", strings.Repeat("x", int(payloadSize)))) + assertArchiveLimitFailure(t, f, limits) + }) + + t.Run("zero byte entries count", func(t *testing.T) { + limits := testPackageArchiveLimits() + limits.entryCount = 2 + f := writePackageArchive(t, packageFile("MANIFEST", ""), packageFile("zero-one", ""), packageFile("zero-two", "")) + assertArchiveLimitFailure(t, f, limits) + }) + + t.Run("global PAX entries count before they are skipped", func(t *testing.T) { + limits := testPackageArchiveLimits() + limits.entryCount = 1 + f := writePackageArchive(t, + &tar.Header{Name: "global", Typeflag: tar.TypeXGlobalHeader, PAXRecords: map[string]string{"comment": "metadata"}}, + packageFile("MANIFEST", ""), + ) + assertArchiveLimitFailure(t, f, limits) + }) + + t.Run("local PAX metadata uses metadata budget", func(t *testing.T) { + limits := testPackageArchiveLimits() + limits.metadataSize = 2048 + f := writePackageArchive(t, + packageFile("MANIFEST", ""), + &tar.Header{Name: "payload", Typeflag: tar.TypeReg, Size: 1, Format: tar.FormatPAX, PAXRecords: map[string]string{"comment": strings.Repeat("x", 8192)}}, + ) + err := assertArchiveLimitFailure(t, f, limits) + assert.ErrorIs(t, err, errPackageArchiveByteLimit) + }) + + t.Run("GNU long name metadata uses metadata budget", func(t *testing.T) { + limits := testPackageArchiveLimits() + limits.metadataSize = 2048 + f := writePackageArchive(t, + packageFile("MANIFEST", ""), + &tar.Header{Name: strings.Repeat("n", 2048), Typeflag: tar.TypeReg, Format: tar.FormatGNU}, + ) + err := assertArchiveLimitFailure(t, f, limits) + assert.ErrorIs(t, err, errPackageArchiveByteLimit) + }) + + t.Run("trailing zero data is bounded after tar EOF", func(t *testing.T) { + limits := testPackageArchiveLimits() + limits.metadataSize = 2048 + f := writePackageArchiveWithTrailingData(t, 8192, false) + err := assertArchiveLimitFailure(t, f, limits) + assert.ErrorIs(t, err, errPackageArchiveByteLimit) + }) + + t.Run("later gzip member is bounded after tar EOF", func(t *testing.T) { + limits := testPackageArchiveLimits() + limits.metadataSize = 2048 + f := writePackageArchiveWithTrailingData(t, 8192, true) + err := assertArchiveLimitFailure(t, f, limits) + assert.ErrorIs(t, err, errPackageArchiveByteLimit) + }) + + t.Run("high compression ratio is allowed below absolute limits", func(t *testing.T) { + limits := testPackageArchiveLimits() + limits.entrySize = 32768 + limits.totalSize = 32768 + maxPackageManifestSize + limits.metadataSize = 4096 + f := writePackageArchive(t, packageFile("MANIFEST", ""), packageFile("compressed", strings.Repeat("x", 32768))) + stage, _, _, err := extractPackageArchiveWithLimits(f, t.TempDir(), limits) + require.NoError(t, err) + assert.NoError(t, os.RemoveAll(stage)) + assert.NoError(t, f.Close()) + }) +} + +func testPackageArchiveLimits() packageArchiveLimits { + return packageArchiveLimits{ + manifestSize: maxPackageManifestSize, + entrySize: 1 << 20, + totalSize: 1 << 20, + entryCount: 32, + metadataSize: 16 << 10, + } +} + +func assertArchiveLimitFailure(t *testing.T, f *os.File, limits packageArchiveLimits) error { + t.Helper() + parent := t.TempDir() + out := filepath.Join(parent, "destination") + require.NoError(t, os.Mkdir(out, 0o755)) + marker := filepath.Join(out, "marker") + require.NoError(t, os.WriteFile(marker, []byte("unchanged"), 0o600)) + _, err := inflateTarballWithLimits(f, out, limits) + assert.Error(t, err) + assert.ErrorContains(t, err, "limit") + _, closeErr := f.Stat() + assert.Error(t, closeErr) + data, readErr := os.ReadFile(marker) + assert.NoError(t, readErr) + assert.Equal(t, "unchanged", string(data)) + entries, readDirErr := os.ReadDir(parent) + assert.NoError(t, readDirErr) + assert.Len(t, entries, 1) + assert.Equal(t, "destination", entries[0].Name()) + return err +} + +func writeHeaderOnlyPackageArchive(t *testing.T, header *tar.Header) *os.File { + t.Helper() + path := filepath.Join(t.TempDir(), "header-only.tar.gz") + f, err := os.Create(path) + require.NoError(t, err) + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + require.NoError(t, tw.WriteHeader(header)) + require.NoError(t, gz.Close()) + _, err = f.Seek(0, io.SeekStart) + require.NoError(t, err) + return f +} + +func writePackageArchiveWithTrailingData(t *testing.T, trailingBytes int, secondMember bool) *os.File { + t.Helper() + path := filepath.Join(t.TempDir(), "trailing.tar.gz") + f, err := os.Create(path) + require.NoError(t, err) + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + manifest := "name = \"Test Driver\"\nversion = \"1.0.0\"\n" + require.NoError(t, tw.WriteHeader(&tar.Header{Name: "MANIFEST", Typeflag: tar.TypeReg, Mode: 0o644, Size: int64(len(manifest))})) + _, err = tw.Write([]byte(manifest)) + require.NoError(t, err) + require.NoError(t, tw.Close()) + if !secondMember { + _, err = gz.Write(make([]byte, trailingBytes)) + require.NoError(t, err) + } + require.NoError(t, gz.Close()) + if secondMember { + gz = gzip.NewWriter(f) + _, err = gz.Write(make([]byte, trailingBytes)) + require.NoError(t, err) + require.NoError(t, gz.Close()) + } + _, err = f.Seek(0, io.SeekStart) + require.NoError(t, err) + return f +} + +func writeCustomPackageArchive(t *testing.T, manifest string, payloads ...*tar.Header) *os.File { + t.Helper() + path := filepath.Join(t.TempDir(), "package.tar.gz") + f, err := os.Create(path) + require.NoError(t, err) + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + mh := &tar.Header{Name: "MANIFEST", Typeflag: tar.TypeReg, Mode: 0o644, Size: int64(len(manifest))} + require.NoError(t, tw.WriteHeader(mh)) + _, err = tw.Write([]byte(manifest)) + require.NoError(t, err) + for _, header := range payloads { + body := strings.Repeat("x", int(header.Size)) + require.NoError(t, tw.WriteHeader(header)) + if header.Typeflag == tar.TypeReg || header.Typeflag == tar.TypeRegA { + _, err = tw.Write([]byte(body)) + require.NoError(t, err) + } + } + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) + _, err = f.Seek(0, io.SeekStart) + require.NoError(t, err) + return f +} diff --git a/config/package_cleanup.go b/config/package_cleanup.go new file mode 100644 index 00000000..8c50f55b --- /dev/null +++ b/config/package_cleanup.go @@ -0,0 +1,583 @@ +// 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. + +package config + +import ( + "errors" + "fmt" + "io/fs" + "os" + "sort" + "strings" + + "github.com/columnar-tech/dbc/internal/hostpath" +) + +type packageCleanupOperations struct { + beforeRemove func(string) error + beforeRemoveAll func(string) error + removeRegistration func(Config, DriverInfo) error + hashOwnedLibrary ownedLibraryHashFunc + statGeneration func(*os.Root) (os.FileInfo, error) + beforeGenerationOpen func(string) error + afterGenerationLstat func(string) + afterGenerationVerified func(string) + afterReceiptVerified func(string) + afterGenerationEnumerate func(string) + beforeGenerationRemove func(string) +} + +type verifiedPackageGeneration struct { + name string + root *os.Root + fileInfo os.FileInfo +} + +// Cleanup runs after driver registration has been removed. If cleanup fails, +// package files may remain without a registration that can be uninstalled +// again. TODO: Define a safe recovery path for these retained generations. +func cleanupInstalledPackageWithReferences(cfg Config, root string, info DriverInfo, otherRegistrations []driverMap, referencesCertain bool, operations packageCleanupOperations) error { + if info.Source != "dbc" || root == "" { + return nil + } + if !referencesCertain { + return nil + } + if !supportsPinnedCleanup() { + return nil + } + root, err := hostpath.Abs(root) + if err != nil { + return nil + } + scope, err := packageRegistrationScopeForConfig(cfg) + if err != nil { + return nil + } + rootHandle, err := os.OpenRoot(root) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("open package installation root %s: %w", root, err) + } + defer rootHandle.Close() + entries, err := readRootDirectoryEntries(rootHandle) + if err != nil { + return fmt.Errorf("read package installation root %s: %w", root, err) + } + referenced, uncertain := referencedPathsForRegistrations(root, otherRegistrations) + if uncertain { + return nil + } + + matchedPaths := make([]string, 0, 1) + matchedCount := 0 + var strictErr error + for _, entry := range entries { + generation := entry.Name() + generationRoot, ok := openVerifiedGeneration(rootHandle, generation, root, operations) + if !ok { + continue + } + receipt, ok := readPackageInstallReceiptAtRoot(generationRoot, generation) + if !ok || !sameRuntimeID(receipt.RuntimeID, info.ID) || receipt.Platform != PlatformTuple() || receipt.RegistrationScope != scope { + _ = generationRoot.Close() + continue + } + if operations.afterReceiptVerified != nil { + operations.afterReceiptVerified(hostpath.Join(root, generation)) + } + generationPath := hostpath.Join(root, generation) + if receiptMatchesRegistration(cfg, root, generationPath, receipt, info) { + matchedCount++ + matchedPaths = append(matchedPaths, generationPath) + if !generationReferenced(generationPath, referenced) { + integrityErr := verifyPackageOwnedLibraryIntegrityAtRoot(generationRoot, receipt, operations.hashOwnedLibrary) + if integrityErr != nil { + strictErr = errors.Join(strictErr, fmt.Errorf("verify owned package library in %s: %w", generationPath, integrityErr)) + } else { + generationInfo, statErr := statPackageGenerationRoot(generationRoot, operations) + if statErr != nil { + strictErr = errors.Join(strictErr, fmt.Errorf("inspect package generation %s: %w", generationPath, statErr)) + } else { + strictErr = errors.Join(strictErr, removePackageGenerationAtRoot(rootHandle, verifiedPackageGeneration{name: generation, root: generationRoot, fileInfo: generationInfo}, root, operations)) + } + } + } + } + _ = generationRoot.Close() + } + + if matchedCount == 0 { + if hasTransactionEvidence(entries, info.ID) { + return nil + } + return cleanupLegacyPackageRegistrationAtRoot(rootHandle, root, info, referenced, true, operations) + } + if strictErr != nil { + return strictErr + } + cleanupStalePackageGenerationsWithRoot(cfg, rootHandle, root, info, matchedPaths, referenced, false, operations) + return nil +} + +func statPackageGenerationRoot(generationRoot *os.Root, operations packageCleanupOperations) (os.FileInfo, error) { + if operations.statGeneration != nil { + return operations.statGeneration(generationRoot) + } + return generationRoot.Stat(".") +} + +func legacyPackageReplacementCandidate(cfg Config, root, registrationLocation, runtimeID string) (DriverInfo, bool) { + entries, err := readDirectoryEntries(root) + if err != nil || hasTransactionEvidence(entries, runtimeID) { + return DriverInfo{}, false + } + previous, ok := readPrimaryRuntimeRegistration(cfg, registrationLocation, runtimeID) + if !ok || !sameRuntimeID(previous.ID, runtimeID) || previous.Source != "dbc" { + return DriverInfo{}, false + } + return previous, true +} + +func receiptMatchesRegistration(cfg Config, root, generation string, receipt packageInstallReceipt, info DriverInfo) bool { + shared := info.Driver.Shared.Get(receipt.Platform) + if shared == "" || hasParentTraversal(shared) { + return false + } + expected := hostpath.Join(generation, receipt.OwnedLibraryFilename) + if !sameResolvedFilesystemPath(resolvePackagePath(root, shared), expected) { + return false + } + fingerprint, err := runtimeRegistrationFingerprint(cfg, receipt.RuntimeID, receipt.Platform, info, receipt.OwnedLibraryFilename) + return err == nil && fingerprint == receipt.RegistrationFingerprintValue +} + +func cleanupStalePackageGenerationsWithReferences(cfg Config, root string, current DriverInfo, alreadyRemoved []string, referenced []string, uncertainReferences bool, operations packageCleanupOperations) { + root, err := hostpath.Abs(root) + if err != nil { + return + } + if !supportsPinnedCleanup() { + return + } + rootHandle, err := os.OpenRoot(root) + if err != nil { + return + } + defer rootHandle.Close() + cleanupStalePackageGenerationsWithRoot(cfg, rootHandle, root, current, alreadyRemoved, referenced, uncertainReferences, operations) +} + +func cleanupStalePackageGenerationsWithRoot(cfg Config, rootHandle *os.Root, root string, current DriverInfo, alreadyRemoved []string, referenced []string, uncertainReferences bool, operations packageCleanupOperations) { + root, err := hostpath.Abs(root) + if err != nil { + return + } + scope, err := packageRegistrationScopeForConfig(cfg) + if err != nil { + return + } + entries, err := readRootDirectoryEntries(rootHandle) + if err != nil { + return + } + if uncertainReferences { + return + } + for _, entry := range entries { + generation := hostpath.Join(root, entry.Name()) + if containsFilesystemPathOrUncertain(alreadyRemoved, generation) || generationReferenced(generation, referenced) { + continue + } + generationRoot, ok := openVerifiedGeneration(rootHandle, entry.Name(), root, operations) + if !ok { + continue + } + receipt, ok := readPackageInstallReceiptAtRoot(generationRoot, entry.Name()) + if !ok || !sameRuntimeID(receipt.RuntimeID, current.ID) || receipt.RegistrationScope != scope { + _ = generationRoot.Close() + continue + } + if operations.afterReceiptVerified != nil { + operations.afterReceiptVerified(generation) + } + integrityErr := verifyPackageOwnedLibraryIntegrityAtRoot(generationRoot, receipt, operations.hashOwnedLibrary) + if integrityErr != nil { + _ = generationRoot.Close() + continue + } + generationInfo, statErr := generationRoot.Stat(".") + if statErr != nil { + _ = generationRoot.Close() + continue + } + _ = removePackageGenerationAtRoot(rootHandle, verifiedPackageGeneration{name: entry.Name(), root: generationRoot, fileInfo: generationInfo}, root, operations) + _ = generationRoot.Close() + } +} + +func referencedPathsForRegistrations(root string, registrations []driverMap) ([]string, bool) { + var paths []string + for _, registration := range registrations { + references, uncertain := referencedPaths(root, registration) + if uncertain { + return nil, true + } + paths = append(paths, references...) + } + return paths, false +} + +func readDirectoryEntries(path string) (entries []fs.DirEntry, err error) { + directory, err := os.Open(path) + if err != nil { + return nil, err + } + entries, readErr := directory.ReadDir(-1) + closeErr := directory.Close() + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + if readErr != nil { + readErr = fmt.Errorf("read directory entries from %s: %w", path, readErr) + } + if closeErr != nil { + closeErr = fmt.Errorf("close directory %s: %w", path, closeErr) + } + return entries, errors.Join(readErr, closeErr) +} + +func readRootDirectoryEntries(root *os.Root) ([]fs.DirEntry, error) { + directory, err := root.Open(".") + if err != nil { + return nil, err + } + entries, readErr := directory.ReadDir(-1) + closeErr := directory.Close() + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + return entries, errors.Join(readErr, closeErr) +} + +func openVerifiedGeneration(parent *os.Root, name, rootPath string, operations packageCleanupOperations) (*os.Root, bool) { + if name == "" || name == "." || name == ".." || hostpath.Base(name) != name { + return nil, false + } + path := hostpath.Join(rootPath, name) + if operations.beforeGenerationOpen != nil { + if err := operations.beforeGenerationOpen(path); err != nil { + return nil, false + } + } + before, err := parent.Lstat(name) + if err != nil || !before.IsDir() || before.Mode()&os.ModeSymlink != 0 { + return nil, false + } + if operations.afterGenerationLstat != nil { + operations.afterGenerationLstat(path) + } + generation, err := parent.OpenRoot(name) + if err != nil { + return nil, false + } + opened, err := generation.Stat(".") + if err != nil || !os.SameFile(before, opened) { + _ = generation.Close() + return nil, false + } + if operations.afterGenerationVerified != nil { + operations.afterGenerationVerified(path) + } + return generation, true +} + +func removePackageGenerationAtRoot(parent *os.Root, generation verifiedPackageGeneration, rootPath string, operations packageCleanupOperations) error { + path := hostpath.Join(rootPath, generation.name) + if generation.root == nil || generation.fileInfo == nil { + return fmt.Errorf("package generation %s has no verified directory handle", path) + } + entries, err := readRootDirectoryEntries(generation.root) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return fmt.Errorf("read package generation %s: %w", path, err) + } + if operations.afterGenerationEnumerate != nil { + operations.afterGenerationEnumerate(path) + } + var payloadErr error + for _, entry := range entries { + name := entry.Name() + if name == packageInstallReceiptFilename { + continue + } + childPath := hostpath.Join(path, name) + info, statErr := generation.root.Lstat(name) + if statErr != nil { + payloadErr = errors.Join(payloadErr, fmt.Errorf("inspect package generation entry %s: %w", childPath, statErr)) + continue + } + if info.IsDir() && info.Mode()&os.ModeSymlink == 0 { + if operations.beforeRemoveAll != nil { + if err := operations.beforeRemoveAll(childPath); err != nil { + payloadErr = errors.Join(payloadErr, err) + continue + } + } + payloadErr = errors.Join(payloadErr, generation.root.RemoveAll(name)) + } else { + if operations.beforeRemove != nil { + if err := operations.beforeRemove(childPath); err != nil { + payloadErr = errors.Join(payloadErr, err) + continue + } + } + payloadErr = errors.Join(payloadErr, generation.root.Remove(name)) + } + } + if payloadErr != nil { + return fmt.Errorf("remove package generation payload %s: %w", path, payloadErr) + } + remaining, err := readRootDirectoryEntries(generation.root) + if err != nil { + return fmt.Errorf("verify package generation payload removal %s: %w", path, err) + } + for _, entry := range remaining { + if entry.Name() != packageInstallReceiptFilename { + return fmt.Errorf("package generation %s gained an unowned entry during cleanup: %s", path, entry.Name()) + } + } + if operations.beforeRemove != nil { + if err := operations.beforeRemove(hostpath.Join(path, packageInstallReceiptFilename)); err != nil { + return fmt.Errorf("remove package generation receipt %s: %w", path, err) + } + } + if err := generation.root.Remove(packageInstallReceiptFilename); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("remove package generation receipt %s: %w", path, err) + } + if operations.beforeGenerationRemove != nil { + operations.beforeGenerationRemove(path) + } + if err := generation.root.Close(); err != nil { + return fmt.Errorf("close package generation %s before final removal: %w", path, err) + } + generation.root = nil + current, err := parent.Lstat(generation.name) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("verify package generation directory %s before removal: %w", path, err) + } + if !current.IsDir() || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(current, generation.fileInfo) { + return fmt.Errorf("package generation directory %s changed before final removal", path) + } + if err := parent.Remove(generation.name); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("remove package generation directory %s: %w", path, err) + } + return nil +} + +func hasTransactionEvidence(entries []fs.DirEntry, runtimeID string) bool { + for _, entry := range entries { + generationID, valid := parsePackageGenerationName(entry.Name()) + if valid && sameRuntimeID(generationID, runtimeID) { + return true + } + } + return false +} + +func hasReservedTransactionAncestor(path string) bool { + absolute, err := hostpath.Abs(path) + if err != nil { + return true + } + for current := hostpath.Clean(absolute); ; current = hostpath.Dir(current) { + name := hostpath.Base(current) + prefix := ".dbc-package-" + reservedName := len(name) > len(prefix) && strings.EqualFold(name[:len(prefix)], prefix) + if reservedName { + return true + } + parent := hostpath.Dir(current) + if parent == current { + break + } + } + return false +} + +func referencedPaths(root string, shared driverMap) ([]string, bool) { + var result []string + for path := range shared.Paths() { + if path != "" { + if hasParentTraversal(path) { + return nil, true + } + result = append(result, resolvePackagePath(root, path)) + } + } + return result, false +} + +func hasParentTraversal(path string) bool { + path = strings.ReplaceAll(path, `\`, "/") + for _, component := range strings.Split(path, "/") { + if component == ".." { + return true + } + } + return false +} + +func resolvePackagePath(root, path string) string { + if !hostpath.IsAbs(path) { + path = hostpath.Join(root, path) + } + resolved, err := hostpath.Abs(hostpath.Clean(path)) + if err != nil { + return hostpath.Clean(path) + } + return resolved +} + +func generationReferenced(generation string, references []string) bool { + for _, reference := range references { + if pathWithin(generation, reference) { + return true + } + } + return false +} + +func pathWithin(parent, child string) bool { + resolvedParent, parentCertain := canonicalFilesystemPath(parent) + resolvedChild, childCertain := canonicalFilesystemPath(child) + if !parentCertain || !childCertain { + return true + } + parentInfo, err := os.Stat(resolvedParent) + if err != nil || !parentInfo.IsDir() { + return true + } + for current := resolvedChild; ; current = hostpath.Dir(current) { + info, err := os.Stat(current) + if err == nil { + if info.IsDir() && os.SameFile(parentInfo, info) { + return true + } + } else if !errors.Is(err, fs.ErrNotExist) { + return true + } + parentPath := hostpath.Dir(current) + if parentPath == current { + return false + } + } +} + +func containsFilesystemPathOrUncertain(paths []string, target string) bool { + for _, path := range paths { + if sameFilesystemPathOrUncertain(path, target) { + return true + } + } + return false +} + +func sameRuntimeID(a, b string) bool { + if hostpath.IsWindows() { + return strings.EqualFold(a, b) + } + return a == b +} + +func sameFilesystemPathOrUncertain(a, b string) bool { + equal, certain := sameResolvedFilesystemPathWithCertainty(a, b) + return equal || !certain +} + +func sameResolvedFilesystemPath(a, b string) bool { + equal, certain := sameResolvedFilesystemPathWithCertainty(a, b) + return equal && certain +} + +func sameResolvedFilesystemPathWithCertainty(a, b string) (bool, bool) { + resolvedA, certainA := canonicalFilesystemPath(a) + resolvedB, certainB := canonicalFilesystemPath(b) + if !certainA || !certainB { + return false, false + } + infoA, errA := os.Stat(resolvedA) + infoB, errB := os.Stat(resolvedB) + if errA == nil && errB == nil { + return os.SameFile(infoA, infoB), true + } + missingA := errors.Is(errA, fs.ErrNotExist) + missingB := errors.Is(errB, fs.ErrNotExist) + if errA != nil && !missingA || errB != nil && !missingB { + return false, false + } + if missingA && missingB { + if hostpath.Equal(resolvedA, resolvedB) { + return true, true + } + return false, false + } + // An existing path and a missing path cannot identify the same current + // filesystem object, even when their spellings differ only by case. + return false, true +} + +func canonicalFilesystemPath(path string) (string, bool) { + absolute, err := hostpath.Abs(hostpath.Clean(path)) + if err != nil { + return "", false + } + current := absolute + var suffix []string + for { + info, statErr := os.Lstat(current) + if statErr == nil { + if len(suffix) > 0 && !info.IsDir() && info.Mode()&os.ModeSymlink == 0 { + return "", false + } + resolved, resolveErr := hostpath.EvalSymlinks(current) + if resolveErr != nil { + return "", false + } + if len(suffix) > 0 { + targetInfo, targetErr := os.Stat(current) + if targetErr != nil || !targetInfo.IsDir() { + return "", false + } + } + for i := len(suffix) - 1; i >= 0; i-- { + resolved = hostpath.Join(resolved, suffix[i]) + } + return hostpath.Clean(resolved), true + } + if !errors.Is(statErr, fs.ErrNotExist) { + return "", false + } + parent := hostpath.Dir(current) + if parent == current { + return "", false + } + suffix = append(suffix, hostpath.Base(current)) + current = parent + } +} diff --git a/config/package_cleanup_helpers_test.go b/config/package_cleanup_helpers_test.go new file mode 100644 index 00000000..ca5bd24d --- /dev/null +++ b/config/package_cleanup_helpers_test.go @@ -0,0 +1,74 @@ +// 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. + +package config + +import ( + "os" + + "github.com/columnar-tech/dbc/internal/hostpath" +) + +// readPackageInstallReceipt accepts evidence only when its identity is +// structurally consistent with a direct generation under the absolute primary +// root. Invalid or absent evidence is treated as no ownership proof. +func readPackageInstallReceipt(primaryRoot, generationDir string) (packageInstallReceipt, bool) { + var empty packageInstallReceipt + root, err := hostpath.Abs(primaryRoot) + if err != nil { + return empty, false + } + generation, err := hostpath.Abs(generationDir) + if err != nil { + return empty, false + } + root = hostpath.Clean(root) + generation = hostpath.Clean(generation) + if hostpath.Dir(generation) != root { + return empty, false + } + parent, err := os.OpenRoot(root) + if err != nil { + return empty, false + } + defer parent.Close() + generationName := hostpath.Base(generation) + generationInfo, err := parent.Lstat(generationName) + if err != nil || !generationInfo.IsDir() || generationInfo.Mode()&os.ModeSymlink != 0 { + return empty, false + } + generationRoot, err := parent.OpenRoot(generationName) + if err != nil { + return empty, false + } + defer generationRoot.Close() + openedGenerationInfo, err := generationRoot.Stat(".") + if err != nil || !os.SameFile(generationInfo, openedGenerationInfo) { + return empty, false + } + return readPackageInstallReceiptAtRoot(generationRoot, generationName) +} + +func cleanupInstalledPackage(cfg Config, root string, info DriverInfo) error { + return cleanupInstalledPackageWithOperations(cfg, root, info, packageCleanupOperations{}) +} + +func cleanupInstalledPackageWithOperations(cfg Config, root string, info DriverInfo, operations packageCleanupOperations) error { + return cleanupInstalledPackageWithReferences(cfg, root, info, nil, true, operations) +} + +func cleanupStalePackageGenerations(cfg Config, root string, current DriverInfo, alreadyRemoved []string) { + referenced, uncertain := referencedPaths(root, current.Driver.Shared) + cleanupStalePackageGenerationsWithReferences(cfg, root, current, alreadyRemoved, referenced, uncertain, packageCleanupOperations{}) +} diff --git a/config/package_cleanup_identity_test.go b/config/package_cleanup_identity_test.go new file mode 100644 index 00000000..4ffd19db --- /dev/null +++ b/config/package_cleanup_identity_test.go @@ -0,0 +1,160 @@ +// 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. + +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFilesystemPathIdentityUsesExistingFileIdentity(t *testing.T) { + root := t.TempDir() + first := filepath.Join(root, "library.so") + second := filepath.Join(root, "hardlink.so") + other := filepath.Join(root, "other.so") + if err := os.WriteFile(first, []byte("library"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Link(first, second); err != nil { + t.Skipf("hardlinks are unavailable: %v", err) + } + if err := os.WriteFile(other, []byte("other"), 0o600); err != nil { + t.Fatal(err) + } + if equal, certain := sameResolvedFilesystemPathWithCertainty(first, second); !equal || !certain { + t.Fatalf("hardlink equality = (%t, %t), want (true, true)", equal, certain) + } + if equal, certain := sameResolvedFilesystemPathWithCertainty(first, other); equal || !certain { + t.Fatalf("distinct-file equality = (%t, %t), want (false, true)", equal, certain) + } + if equal, certain := sameResolvedFilesystemPathWithCertainty(first, filepath.Join(root, "missing.so")); equal || !certain { + t.Fatalf("existing/missing equality = (%t, %t), want (false, true)", equal, certain) + } + if equal, certain := sameResolvedFilesystemPathWithCertainty(filepath.Join(root, "missing.so"), filepath.Join(root, "missing.so")); !equal || !certain { + t.Fatalf("same missing-path equality = (%t, %t), want (true, true)", equal, certain) + } + if equal, certain := sameResolvedFilesystemPathWithCertainty(filepath.Join(root, "missing-a.so"), filepath.Join(root, "missing-b.so")); equal || certain { + t.Fatalf("different missing-path equality = (%t, %t), want (false, false)", equal, certain) + } + + generation := filepath.Join(root, "generation") + if err := os.Mkdir(generation, 0o700); err != nil { + t.Fatal(err) + } + packageLibrary := filepath.Join(generation, "driver.so") + if err := os.WriteFile(packageLibrary, []byte("owned"), 0o600); err != nil { + t.Fatal(err) + } + externalHardlink := filepath.Join(root, "external.so") + if err := os.Link(packageLibrary, externalHardlink); err != nil { + t.Skipf("hardlinks are unavailable: %v", err) + } + if !sameFilesystemPathOrUncertain(packageLibrary, externalHardlink) { + t.Fatal("external hardlink did not retain exact shared-file reference") + } + if pathWithin(generation, externalHardlink) || generationReferenced(generation, []string{externalHardlink}) { + t.Fatal("external hardlink was treated as a descendant of its source generation") + } +} + +func TestFilesystemPathContainmentUsesDirectoryIdentity(t *testing.T) { + root := t.TempDir() + generation := filepath.Join(root, "generation") + if err := os.Mkdir(generation, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(generation, "driver.so"), []byte("owned"), 0o600); err != nil { + t.Fatal(err) + } + missingDescendant := filepath.Join(generation, "missing", "ancestor", "borrowed.so") + if !pathWithin(generation, missingDescendant) { + t.Fatal("missing descendant did not resolve to the existing generation directory") + } + if !generationReferenced(generation, []string{missingDescendant}) { + t.Fatal("missing descendant reference did not protect generation") + } + otherDirectory := filepath.Join(root, "other") + if err := os.Mkdir(otherDirectory, 0o700); err != nil { + t.Fatal(err) + } + if pathWithin(generation, filepath.Join(otherDirectory, "missing.so")) { + t.Fatal("a missing file under a distinct directory was treated as contained") + } + if pathWithin(generation, filepath.Join(root, "outside.so")) { + t.Fatal("a distinct missing file was treated as contained") + } + + alias := filepath.Join(root, "generation-alias") + if err := os.Symlink(generation, alias); err != nil { + t.Skipf("symlink creation is unavailable: %v", err) + } + if !pathWithin(generation, filepath.Join(alias, "missing", "borrowed.so")) { + t.Fatal("symlinked missing descendant did not resolve to generation") + } + + broken := filepath.Join(root, "broken") + if err := os.Symlink(filepath.Join(root, "absent"), broken); err != nil { + t.Skipf("symlink creation is unavailable: %v", err) + } + if !pathWithin(generation, filepath.Join(broken, "borrowed.so")) { + t.Fatal("broken symlink reference was not treated as uncertain") + } + if equal, certain := sameResolvedFilesystemPathWithCertainty(filepath.Join(broken, "library.so"), filepath.Join(broken, "library.so")); equal || certain { + t.Fatalf("broken symlink equality = (%t, %t), want uncertain", equal, certain) + } + + nonDirectory := filepath.Join(root, "file") + if err := os.WriteFile(nonDirectory, []byte("file"), 0o600); err != nil { + t.Fatal(err) + } + invalidDescendant := filepath.Join(nonDirectory, "child") + if !pathWithin(generation, invalidDescendant) { + t.Fatal("ENOTDIR reference was not treated as uncertain") + } + if equal, certain := sameResolvedFilesystemPathWithCertainty(invalidDescendant, invalidDescendant); equal || certain { + t.Fatalf("ENOTDIR equality = (%t, %t), want uncertain", equal, certain) + } +} + +func TestReservedTransactionPrefixIsCaseFoldedOnEveryPlatform(t *testing.T) { + root := t.TempDir() + for _, name := range []string{".dbc-package-stage-abc", ".DBC-PACKAGE-STAGE-abc", ".DbC-PaCkAgE-g-driver-token"} { + if !hasReservedTransactionAncestor(filepath.Join(root, name, "driver.so")) { + t.Errorf("transaction-like path %q was not reserved", name) + } + } + if hasReservedTransactionAncestor(filepath.Join(root, ".dbc-package-", "driver.so")) { + t.Fatal("bare transaction prefix without a suffix was reserved") + } +} + +func TestFilesystemPathIdentityTreatsNonNotExistErrorsAsUncertain(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "directory", "child") + if err := os.Mkdir(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("file"), 0o600); err != nil { + t.Fatal(err) + } + notDirectory := filepath.Join(path, "nested") + if _, err := os.Stat(notDirectory); err == nil { + t.Fatal("unexpected path unexpectedly exists") + } + if equal, certain := sameResolvedFilesystemPathWithCertainty(notDirectory, notDirectory); equal || certain { + t.Fatalf("non-directory ancestry equality = (%t, %t), want uncertain", equal, certain) + } +} diff --git a/config/package_cleanup_integrity.go b/config/package_cleanup_integrity.go new file mode 100644 index 00000000..7eb73f11 --- /dev/null +++ b/config/package_cleanup_integrity.go @@ -0,0 +1,77 @@ +// 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. + +package config + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "io/fs" + "os" +) + +type ownedLibraryHashFunc func(io.Reader) ([]byte, error) + +// verifyPackageOwnedLibraryIntegrityAtRoot compares the installed bytes with +// the receipt's local integrity evidence. This does not prove correspondence +// to any downloaded or locked archive. The receipt must already have passed +// validPackageInstallReceiptForGeneration, which checks the filename and +// digest format. A library that is already absent is not an error. +func verifyPackageOwnedLibraryIntegrityAtRoot(generation *os.Root, receipt packageInstallReceipt, hashLibrary ownedLibraryHashFunc) error { + filename := receipt.OwnedLibraryFilename + info, err := generation.Lstat(filename) + if errors.Is(err, fs.ErrNotExist) { + // Partial cleanup may already have removed the library while retaining + // the receipt. Allow a later pass to remove the remaining payload. + return nil + } + if err != nil { + return fmt.Errorf("inspect owned library %s: %w", filename, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("owned library %s is not a regular file", filename) + } + file, err := generation.Open(filename) + if err != nil { + return fmt.Errorf("open owned library %s: %w", filename, err) + } + openedInfo, statErr := file.Stat() + if statErr != nil { + closeErr := file.Close() + return fmt.Errorf("stat opened owned library %s: %w", filename, errors.Join(statErr, closeErr)) + } + if !openedInfo.Mode().IsRegular() || !os.SameFile(info, openedInfo) { + closeErr := file.Close() + return errors.Join(fmt.Errorf("opened owned library %s changed identity or is not a regular file", filename), closeErr) + } + if hashLibrary == nil { + hashLibrary = hashSHA256 + } + actualDigest, copyErr := hashLibrary(file) + closeErr := file.Close() + if copyErr != nil || closeErr != nil { + return fmt.Errorf("hash owned library %s: %w", filename, errors.Join(copyErr, closeErr)) + } + if len(actualDigest) != sha256.Size { + return fmt.Errorf("hash owned library %s: got %d digest bytes, want %d", filename, len(actualDigest), sha256.Size) + } + actual := hex.EncodeToString(actualDigest) + if actual != receipt.OwnedLibrarySHA256 { + return fmt.Errorf("owned library %s SHA-256 does not match installation receipt", filename) + } + return nil +} diff --git a/config/package_cleanup_integrity_test.go b/config/package_cleanup_integrity_test.go new file mode 100644 index 00000000..33797636 --- /dev/null +++ b/config/package_cleanup_integrity_test.go @@ -0,0 +1,216 @@ +// 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. + +package config + +import ( + "crypto/sha256" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Masterminds/semver/v3" +) + +func TestCleanupVerifiesOwnedLibraryIntegrityBeforeRemoval(t *testing.T) { + for _, mode := range []string{"valid", "mismatch", "missing", "nonregular", "read error"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + info, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + generation := filepath.Dir(info.Driver.Shared.Get(PlatformTuple())) + library := info.Driver.Shared.Get(PlatformTuple()) + var injectedErr error + switch mode { + case "mismatch": + if err := os.WriteFile(library, []byte("bad"), 0o600); err != nil { + t.Fatal(err) + } + case "missing": + if err := os.Remove(library); err != nil { + t.Fatal(err) + } + case "nonregular": + if err := os.Remove(library); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(library, 0o700); err != nil { + t.Fatal(err) + } + case "read error": + injectedErr = errors.New("injected library read failure") + } + operations := packageCleanupOperations{} + if injectedErr != nil { + operations.hashOwnedLibrary = func(io.Reader) ([]byte, error) { return nil, injectedErr } + } + err = cleanupInstalledPackageWithOperations(cfg, root, info, operations) + if mode == "valid" || mode == "missing" { + if err != nil { + t.Fatalf("cleanup failed for %s owned library: %v", mode, err) + } + if _, err := os.Stat(generation); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("generation remains after %s library cleanup: %v", mode, err) + } + return + } + if err == nil { + t.Fatal("cleanup succeeded despite unverified owned library bytes") + } + if mode == "read error" && !errors.Is(err, injectedErr) { + t.Fatalf("cleanup error = %v, want injected read failure", err) + } + if !strings.Contains(err.Error(), generation) { + t.Fatalf("cleanup error lacks retained generation path: %v", err) + } + if _, err := os.Stat(filepath.Join(generation, packageInstallReceiptFilename)); err != nil { + t.Fatalf("unverified generation receipt was not retained: %v", err) + } + }) + } +} + +func TestCleanupRetriesAfterOwnedLibraryWasRemoved(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + info, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + generation := filepath.Dir(info.Driver.Shared.Get(PlatformTuple())) + library := info.Driver.Shared.Get(PlatformTuple()) + cleanupErr := errors.New("injected locked library") + err = cleanupInstalledPackageWithOperations(cfg, root, info, packageCleanupOperations{ + beforeRemove: func(path string) error { + if path == library { + return cleanupErr + } + return nil + }, + }) + if !errors.Is(err, cleanupErr) { + t.Fatalf("first cleanup error = %v", err) + } + if _, err := os.Stat(filepath.Join(generation, packageInstallReceiptFilename)); err != nil { + t.Fatalf("receipt was not retained after partial cleanup: %v", err) + } + if err := os.Remove(library); err != nil { + t.Fatal(err) + } + if err := cleanupInstalledPackageWithOperations(cfg, root, info, packageCleanupOperations{}); err != nil { + t.Fatalf("retry with missing owned library failed: %v", err) + } + if _, err := os.Stat(generation); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("receipt-backed generation remains after cleanup retry: %v", err) + } +} + +func TestStaleCleanupRetainsOwnedLibraryHashMismatch(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + previous, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + previousGeneration := filepath.Dir(previous.Driver.Shared.Get(PlatformTuple())) + archive := testPackageArchive(t, "updated") + operations := testPackageInstallOperations() + operations.cleanupBeforeRemove = func(path string) error { + if filepath.Dir(path) == previousGeneration && filepath.Base(path) == filepath.Base(previous.Driver.Shared.Get(PlatformTuple())) { + return errors.New("retain previous generation for integrity test") + } + return nil + } + if _, err := installPackageWithOperations(cfg, "driver", archive, InstallPackageOptions{}, operations); err != nil { + t.Fatal(err) + } + assertArchiveClosed(t, archive) + if err := os.WriteFile(previous.Driver.Shared.Get(PlatformTuple()), []byte("bad"), 0o600); err != nil { + t.Fatal(err) + } + current, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + referenced, uncertain := referencedPaths(root, current.Driver.Shared) + if uncertain { + t.Fatal("current registration references were uncertain") + } + cleanupStalePackageGenerationsWithReferences(cfg, root, current, []string{filepath.Dir(current.Driver.Shared.Get(PlatformTuple()))}, referenced, false, packageCleanupOperations{}) + if _, err := os.Stat(filepath.Join(previousGeneration, packageInstallReceiptFilename)); err != nil { + t.Fatalf("stale generation with hash mismatch was removed: %v", err) + } +} + +func TestOwnedLibraryIntegrityIsCheckedOnceBeforeRemoval(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + info, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + checks := 0 + if err := cleanupInstalledPackageWithOperations(cfg, root, info, packageCleanupOperations{ + hashOwnedLibrary: func(reader io.Reader) ([]byte, error) { + checks++ + data, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + digest := sha256.Sum256(data) + return digest[:], nil + }, + }); err != nil { + t.Fatal(err) + } + if checks != 1 { + t.Fatalf("owned library was checked %d times, want once", checks) + } +} + +func TestExternalRegistrationCleanupDoesNotHashSharedLibrary(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + external := filepath.Join(t.TempDir(), "shared.so") + if err := os.WriteFile(external, []byte("external"), 0o600); err != nil { + t.Fatal(err) + } + info := DriverInfo{ID: "external", Name: "External", Version: semver.MustParse("1.0.0"), Source: "external"} + info.Driver.Shared.Set(PlatformTuple(), external) + if err := CreateManifest(cfg, info); err != nil { + t.Fatal(err) + } + checks := 0 + if err := cleanupInstalledPackageWithOperations(cfg, root, info, packageCleanupOperations{ + hashOwnedLibrary: func(io.Reader) ([]byte, error) { checks++; return nil, nil }, + }); err != nil { + t.Fatal(err) + } + if checks != 0 { + t.Fatalf("external shared library was hashed %d times", checks) + } + if _, err := os.Stat(external); err != nil { + t.Fatalf("external shared library was changed: %v", err) + } +} diff --git a/config/package_cleanup_root_supported.go b/config/package_cleanup_root_supported.go new file mode 100644 index 00000000..c55c371d --- /dev/null +++ b/config/package_cleanup_root_supported.go @@ -0,0 +1,19 @@ +// 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 !js && !plan9 + +package config + +func supportsPinnedCleanup() bool { return true } diff --git a/config/package_cleanup_root_unsupported.go b/config/package_cleanup_root_unsupported.go new file mode 100644 index 00000000..d961dfac --- /dev/null +++ b/config/package_cleanup_root_unsupported.go @@ -0,0 +1,22 @@ +// 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 js || plan9 + +package config + +// os.Root does not pin directory identity across renames on these targets and +// its symlink confinement is vulnerable to TOCTOU races on js. Retaining the +// generation is safer than deleting by a pathname that may have changed. +func supportsPinnedCleanup() bool { return false } diff --git a/config/package_cleanup_root_unsupported_test.go b/config/package_cleanup_root_unsupported_test.go new file mode 100644 index 00000000..51f7e487 --- /dev/null +++ b/config/package_cleanup_root_unsupported_test.go @@ -0,0 +1,46 @@ +// 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 js || plan9 + +package config + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestCleanupRetainsGenerationWhenRootCannotPinDirectoryIdentity(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + selected, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + generation := filepath.Dir(selected.Driver.Shared.Get(PlatformTuple())) + if err := UninstallDriver(cfg, selected); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "driver.toml")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("registration was not removed: %v", err) + } + for _, name := range []string{packageInstallReceiptFilename, "driver.so"} { + if _, err := os.Stat(filepath.Join(generation, name)); err != nil { + t.Fatalf("un-pinned generation %s was not retained: %v", name, err) + } + } +} diff --git a/config/package_cleanup_test.go b/config/package_cleanup_test.go new file mode 100644 index 00000000..d123bf7d --- /dev/null +++ b/config/package_cleanup_test.go @@ -0,0 +1,1773 @@ +// 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. + +package config + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "syscall" + "testing" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc/internal/fslock" +) + +func assertParentTraversalReferenceResolvesOnUnix(t *testing.T, path string) { + t.Helper() + if runtime.GOOS == "windows" { + // Windows path resolution does not guarantee Unix's behavior for a + // parent component following a symlink. These tests assert conservative + // retention of the ambiguous reference, not that Windows resolves it. + return + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("fixture parent-traversal reference does not resolve: %v", err) + } +} + +func TestUninstallDriverCleansOwnedGenerationAndPreservesExternalFiles(t *testing.T) { + for _, externalInsideRoot := range []bool{true, false} { + name := "external-outside-root" + if externalInsideRoot { + name = "external-inside-root" + } + t.Run(name, func(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + externalRoot := t.TempDir() + if externalInsideRoot { + externalRoot = filepath.Join(root, "external") + } + if err := os.MkdirAll(externalRoot, 0o700); err != nil { + t.Fatal(err) + } + external := filepath.Join(externalRoot, "libexternal.so") + if err := os.WriteFile(external, []byte("external library"), 0o600); err != nil { + t.Fatal(err) + } + sibling := filepath.Join(externalRoot, "user-data.txt") + if err := os.WriteFile(sibling, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + manifest := "name = \"External\"\nversion = \"1.0.0\"\n[Files]\ndriver = \"driver.so\"\n" + archive := writeCustomPackageArchive(t, manifest, packageFile("driver.so", "owned library"), packageFile("NOTICE", "package metadata")) + if _, err := InstallPackage(context.Background(), cfg, "external", archive, InstallPackageOptions{}); err != nil { + t.Fatal(err) + } + selected, err := GetDriver(cfg, "external") + if err != nil { + t.Fatal(err) + } + generation := filepath.Join(root, packageGenerationNames(t, root, "external")[0]) + if err := UninstallDriver(cfg, selected); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(generation); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("metadata generation remains: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "external.toml")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("registration remains: %v", err) + } + for _, path := range []string{external, sibling} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("external or sibling file %s was removed: %v", path, err) + } + } + }) + } +} + +func TestUninstallReportsGenerationStatFailureAndRetainsPayload(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + selected, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + generation := filepath.Dir(selected.Driver.Shared.Get(PlatformTuple())) + shared := selected.Driver.Shared.Get(PlatformTuple()) + statFailure := errors.New("injected generation stat failure") + + err = uninstallDriverUnlockedWithCleanupAndReferences(cfg, selected, nil, true, packageCleanupOperations{ + statGeneration: func(*os.Root) (os.FileInfo, error) { + return nil, statFailure + }, + }) + if err == nil || !strings.Contains(err.Error(), "package files may remain") { + t.Fatalf("uninstall error does not expose the retained package cleanup failure: %v", err) + } + if !strings.Contains(err.Error(), generation) { + t.Fatalf("uninstall error does not identify exact retained generation %s: %v", generation, err) + } + if !errors.Is(err, statFailure) { + t.Fatalf("uninstall error does not preserve generation stat cause: %v", err) + } + if _, err := os.Stat(shared); err != nil { + t.Fatalf("payload was removed despite generation identity stat failure: %v", err) + } + if _, ok := readPackageInstallReceipt(root, generation); !ok { + t.Fatal("receipt was removed despite generation identity stat failure") + } + if _, err := os.Stat(filepath.Join(root, "driver.toml")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("driver registration remains after post-removal cleanup failure: %v", err) + } +} + +func TestCleanupGenerationHandleSurvivesSymlinkSwaps(t *testing.T) { + for _, stage := range []string{"before open", "after lstat", "after generation open", "after receipt", "after enumerate", "during payload", "before final remove"} { + t.Run(stage, func(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + selected, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + generation := filepath.Dir(selected.Driver.Shared.Get(PlatformTuple())) + outside := t.TempDir() + sentinel := filepath.Join(outside, "unrelated.txt") + if err := os.WriteFile(sentinel, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + moved := filepath.Join(t.TempDir(), "original-generation") + swapped := false + swap := func() { + t.Helper() + if swapped { + return + } + if err := os.Rename(generation, moved); err != nil { + t.Fatalf("move generation before symlink swap: %v", err) + } + if err := os.Symlink(outside, generation); err != nil { + t.Skipf("directory symlinks are unavailable: %v", err) + } + swapped = true + } + operations := packageCleanupOperations{} + switch stage { + case "before open": + operations.beforeGenerationOpen = func(string) error { swap(); return nil } + case "after lstat": + operations.afterGenerationLstat = func(string) { swap() } + case "after generation open": + operations.afterGenerationVerified = func(string) { swap() } + case "after receipt": + operations.afterReceiptVerified = func(string) { swap() } + case "after enumerate": + operations.afterGenerationEnumerate = func(string) { swap() } + case "during payload": + operations.beforeRemove = func(path string) error { + if path == selected.Driver.Shared.Get(PlatformTuple()) { + swap() + } + return nil + } + case "before final remove": + operations.beforeGenerationRemove = func(string) { swap() } + } + err = cleanupInstalledPackageWithOperations(cfg, root, selected, operations) + expectedToReachPinnedObject := stage == "after generation open" || stage == "after receipt" || stage == "after enumerate" || stage == "during payload" || stage == "before final remove" + if expectedToReachPinnedObject && err == nil { + t.Fatal("cleanup unexpectedly succeeded after generation pathname was replaced") + } + if !expectedToReachPinnedObject && err != nil { + t.Fatalf("cleanup failed before opening the replacement generation: %v", err) + } + if data, err := os.ReadFile(sentinel); err != nil || string(data) != "keep" { + t.Fatalf("unrelated target was changed through generation symlink: %q, %v", data, err) + } + if swapped { + if _, err := os.Lstat(generation); err != nil { + t.Fatalf("replacement symlink disappeared: %v", err) + } + entries, err := os.ReadDir(moved) + if err != nil { + t.Fatalf("inspect original generation after swap: %v", err) + } + if expectedToReachPinnedObject && len(entries) != 0 { + t.Fatalf("verified original generation was not cleaned through its handle: %v", entries) + } + if !expectedToReachPinnedObject && len(entries) == 0 { + t.Fatalf("generation was touched before its identity was verified") + } + } + }) + } +} + +func TestCleanupGenerationHandleSurvivesRootPathSwap(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "root") + if err := os.Mkdir(root, 0o700); err != nil { + t.Fatal(err) + } + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + selected, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + generation := filepath.Dir(selected.Driver.Shared.Get(PlatformTuple())) + outside := t.TempDir() + sentinel := filepath.Join(outside, "unrelated.txt") + if err := os.WriteFile(sentinel, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + rootSentinel := filepath.Join(root, "unrelated-root.txt") + if err := os.WriteFile(rootSentinel, []byte("keep root"), 0o600); err != nil { + t.Fatal(err) + } + movedRoot := filepath.Join(parent, "original-root") + rootRenameBlocked := false + operations := packageCleanupOperations{afterReceiptVerified: func(string) { + if err := os.Rename(root, movedRoot); err != nil { + if runtime.GOOS != "windows" || !errors.Is(err, syscall.Errno(32)) { + t.Fatalf("move package root: unexpected error: %v", err) + } + // On Windows, the pinned os.Root intentionally prevents renaming + // the path while the handle is open. That refusal itself blocks the + // root-path swap; cleanup must still leave unrelated data intact. + rootRenameBlocked = true + return + } + if err := os.Symlink(outside, root); err != nil { + t.Skipf("directory symlinks are unavailable: %v", err) + } + }} + if err := cleanupInstalledPackageWithOperations(cfg, root, selected, operations); err != nil { + t.Fatalf("cleanup through pinned root handle: %v", err) + } + if rootRenameBlocked { + if data, err := os.ReadFile(rootSentinel); err != nil || string(data) != "keep root" { + t.Fatalf("unrelated file in the pinned root changed: %q, %v", data, err) + } + if _, err := os.Lstat(generation); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("owned generation remains in pinned root: %v", err) + } + } else { + if _, err := os.Lstat(filepath.Join(movedRoot, filepath.Base(generation))); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("owned generation remains in pinned root: %v", err) + } + if _, err := os.Lstat(root); err != nil { + t.Fatalf("replacement root path was not preserved: %v", err) + } + } + if data, err := os.ReadFile(sentinel); err != nil || string(data) != "keep" { + t.Fatalf("unrelated target was changed through root symlink: %q, %v", data, err) + } +} + +func TestCleanupGenerationChildSymlinkCannotEscape(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + selected, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + generation := filepath.Dir(selected.Driver.Shared.Get(PlatformTuple())) + outside := t.TempDir() + sentinel := filepath.Join(outside, "unrelated.txt") + if err := os.WriteFile(sentinel, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + nested := filepath.Join(generation, "nested") + if err := os.Mkdir(nested, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nested, "keep.txt"), []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(generation, "outside-link")); err != nil { + t.Skipf("directory symlinks are unavailable: %v", err) + } + movedNested := filepath.Join(t.TempDir(), "original-nested") + operations := packageCleanupOperations{afterGenerationEnumerate: func(string) { + if err := os.Rename(nested, movedNested); err != nil { + t.Fatalf("move nested directory before symlink swap: %v", err) + } + if err := os.Symlink(outside, nested); err != nil { + t.Skipf("directory symlinks are unavailable: %v", err) + } + }} + if err := cleanupInstalledPackageWithOperations(cfg, root, selected, operations); err != nil { + t.Fatal(err) + } + if data, err := os.ReadFile(sentinel); err != nil || string(data) != "keep" { + t.Fatalf("child symlink escaped package generation: %q, %v", data, err) + } + if data, err := os.ReadFile(filepath.Join(movedNested, "keep.txt")); err != nil || string(data) != "keep" { + t.Fatalf("replacement symlink caused deletion of moved child directory: %q, %v", data, err) + } +} + +func TestUninstallDriverRetainsUnprovenGeneration(t *testing.T) { + for _, mode := range []string{"missing", "corrupt", "fingerprint mismatch"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + selected, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + generation := filepath.Dir(selected.Driver.Shared.Get(PlatformTuple())) + receiptPath := filepath.Join(generation, packageInstallReceiptFilename) + switch mode { + case "missing": + if err := os.Remove(receiptPath); err != nil { + t.Fatal(err) + } + case "corrupt": + if err := os.WriteFile(receiptPath, []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + case "fingerprint mismatch": + selected.Name = "Changed registration" + if err := createRuntimeRegistrationUnlocked(cfg, selected.FilePath, selected); err != nil { + t.Fatal(err) + } + selected, err = GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + } + if err := UninstallDriver(cfg, selected); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(generation); err != nil { + t.Fatalf("unproven generation was removed: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "driver.toml")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("registration remains: %v", err) + } + }) + } +} + +func TestUninstallPreservesUnsupportedReceiptShapesAndDoesNotFallBackToLegacyCleanup(t *testing.T) { + for _, kind := range []string{"external", "package_file"} { + t.Run(kind+" receipt shape", func(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + legacyDir := filepath.Join(root, "legacy-package") + if err := os.Mkdir(legacyDir, 0o700); err != nil { + t.Fatal(err) + } + legacyLibrary := filepath.Join(legacyDir, "driver.so") + if err := os.WriteFile(legacyLibrary, []byte("legacy library"), 0o600); err != nil { + t.Fatal(err) + } + info := DriverInfo{ID: "driver", Name: "Driver", Version: semver.MustParse("1.0.0"), Source: "dbc"} + info.Driver.Shared.Set(PlatformTuple(), legacyLibrary) + if err := CreateManifest(cfg, info); err != nil { + t.Fatal(err) + } + registered, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + + generation := testPackageGenerationPath(t, root, "driver", "unsupported-shape") + if err := os.Mkdir(generation, 0o700); err != nil { + t.Fatal(err) + } + filename, payload := "external.so", []byte("external receipt bytes") + ownedFilename, ownedHash := "", "" + if kind == "package_file" { + filename, payload = "driver.so", []byte("old owned receipt bytes") + ownedFilename = filename + digest := sha256.Sum256(payload) + ownedHash = hex.EncodeToString(digest[:]) + } + if err := os.WriteFile(filepath.Join(generation, filename), payload, 0o600); err != nil { + t.Fatal(err) + } + unsupportedReceipt := map[string]any{ + "schema_version": packageInstallReceiptVersion, + "registration_scope": packageRegistrationFile, + "runtime_id": "driver", + "driver_version": "1.0.0", + "platform": PlatformTuple(), + "generation": filepath.Base(generation), + "library_kind": kind, + "owned_library_filename": ownedFilename, + "owned_library_sha256": ownedHash, + "registration_fingerprint_algorithm": registrationFingerprintName, + "registration_fingerprint_version": registrationFingerprintVer, + "registration_fingerprint": strings.Repeat("a", sha256.Size*2), + } + data, err := json.Marshal(unsupportedReceipt) + if err != nil { + t.Fatal(err) + } + receiptPath := filepath.Join(generation, packageInstallReceiptFilename) + if err := os.WriteFile(receiptPath, data, 0o600); err != nil { + t.Fatal(err) + } + if _, ok := readPackageInstallReceipt(root, generation); ok { + t.Fatalf("unsupported %s receipt shape was accepted at schema version %d", kind, packageInstallReceiptVersion) + } + + if err := UninstallDriver(cfg, registered); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(legacyLibrary); err != nil || string(got) != "legacy library" { + t.Fatalf("legacy payload was removed by fallback: %q, %v", got, err) + } + if got, err := os.ReadFile(filepath.Join(generation, filename)); err != nil || string(got) != string(payload) { + t.Fatalf("unsupported receipt generation bytes changed: %q, %v", got, err) + } + if _, err := os.Stat(receiptPath); err != nil { + t.Fatalf("unsupported receipt evidence was removed: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "driver.toml")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("runtime registration remains: %v", err) + } + }) + } +} + +func TestCleanupIsolatesRegistrationScopesInOnePayloadRoot(t *testing.T) { + levels := []ConfigLevel{ConfigEnv, ConfigUser, ConfigSystem} + seen := make(map[packageRegistrationScope]struct{}) + for _, level := range levels { + cfg := Config{Level: level} + scope, err := packageRegistrationScopeForConfig(cfg) + if err != nil { + continue + } + if _, exists := seen[scope]; exists { + continue + } + seen[scope] = struct{}{} + root := t.TempDir() + info := fingerprintTestDriverInfo() + info.ID = "driver" + info.Source = "dbc" + ownedBytes := []byte("owned driver bytes") + ownedHash := sha256.Sum256(ownedBytes) + locations := make(map[packageRegistrationScope]string) + for _, candidateScope := range []packageRegistrationScope{packageRegistrationFile, packageRegistrationRegistryUser, packageRegistrationRegistrySystem} { + generation := testPackageGenerationPath(t, root, "driver", string(candidateScope)) + if err := os.Mkdir(generation, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(generation, "driver.so"), ownedBytes, 0o600); err != nil { + t.Fatal(err) + } + locations[candidateScope] = generation + } + info.Driver.Shared.Set(PlatformTuple(), filepath.Join(locations[scope], "driver.so")) + fingerprint, err := runtimeRegistrationFingerprint(cfg, info.ID, PlatformTuple(), info, "driver.so") + if err != nil { + t.Fatal(err) + } + for _, candidateScope := range []packageRegistrationScope{packageRegistrationFile, packageRegistrationRegistryUser, packageRegistrationRegistrySystem} { + generation := locations[candidateScope] + receipt := packageInstallReceipt{ + SchemaVersion: packageInstallReceiptVersion, + RegistrationScope: candidateScope, + RuntimeID: info.ID, + DriverVersion: info.Version.String(), + Platform: PlatformTuple(), + Generation: filepath.Base(generation), + OwnedLibraryFilename: "driver.so", + OwnedLibrarySHA256: hex.EncodeToString(ownedHash[:]), + RegistrationFingerprintAlgo: registrationFingerprintName, + RegistrationFingerprintVer: registrationFingerprintVer, + RegistrationFingerprintValue: fingerprint, + } + data, err := json.Marshal(receipt) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(generation, packageInstallReceiptFilename), data, 0o600); err != nil { + t.Fatal(err) + } + } + if err := cleanupInstalledPackage(cfg, root, info); err != nil { + t.Fatal(err) + } + for candidateScope, generation := range locations { + _, err := os.Stat(generation) + if candidateScope == scope { + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("scope %q candidate remains: %v", candidateScope, err) + } + } else if err != nil { + t.Errorf("scope %q candidate was removed across scope %q: %v", candidateScope, scope, err) + } + } + gcGeneration := testPackageGenerationPath(t, root, "driver", "gc") + if err := os.Mkdir(gcGeneration, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gcGeneration, "driver.so"), ownedBytes, 0o600); err != nil { + t.Fatal(err) + } + gcReceipt := packageInstallReceipt{ + SchemaVersion: packageInstallReceiptVersion, + RegistrationScope: scope, + RuntimeID: info.ID, + DriverVersion: info.Version.String(), + Platform: PlatformTuple(), + Generation: filepath.Base(gcGeneration), + OwnedLibraryFilename: "driver.so", + OwnedLibrarySHA256: hex.EncodeToString(ownedHash[:]), + RegistrationFingerprintAlgo: registrationFingerprintName, + RegistrationFingerprintVer: registrationFingerprintVer, + RegistrationFingerprintValue: fingerprint, + } + data, err := json.Marshal(gcReceipt) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gcGeneration, packageInstallReceiptFilename), data, 0o600); err != nil { + t.Fatal(err) + } + cleanupStalePackageGenerations(cfg, root, info, nil) + if _, err := os.Stat(gcGeneration); !errors.Is(err, os.ErrNotExist) { + t.Errorf("stale generation in current scope was not collected: %v", err) + } + } +} + +func TestInstallPackageProtectsGenerationReferencedByExternalRegistration(t *testing.T) { + // Package replacement must preserve old payloads borrowed by external registrations. + // The reference symlink and payload-root alias put symlinks on opposite sides + // of the lookup; raw parent traversal is uncertain before path resolution. + for _, referenceKind := range []string{"direct", "reference symlink", "payload root alias", "parent traversal"} { + t.Run(referenceKind, func(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + first, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + firstLibrary := first.Driver.Shared.Get(PlatformTuple()) + reference := firstLibrary + switch referenceKind { + case "reference symlink": + link := filepath.Join(root, "generation-link") + if err := os.Symlink(filepath.Dir(firstLibrary), link); err != nil { + t.Skipf("symlink creation is unavailable: %v", err) + } + reference = filepath.Join(link, filepath.Base(firstLibrary)) + case "payload root alias": + rootAlias := filepath.Join(t.TempDir(), "root-alias") + if err := os.Symlink(root, rootAlias); err != nil { + t.Skipf("symlink creation is unavailable: %v", err) + } + cfg.Location = rootAlias + case "parent traversal": + subdirectory := filepath.Join(filepath.Dir(firstLibrary), "subdir") + if err := os.Mkdir(subdirectory, 0o700); err != nil { + t.Fatal(err) + } + link := filepath.Join(root, "generation-subdir") + if err := os.Symlink(subdirectory, link); err != nil { + t.Skipf("symlink creation is unavailable: %v", err) + } + reference = link + string(filepath.Separator) + ".." + string(filepath.Separator) + filepath.Base(firstLibrary) + assertParentTraversalReferenceResolvesOnUnix(t, reference) + } + + borrower := DriverInfo{ID: "borrower", Name: "Borrower", Version: semver.MustParse("1.0.0"), Source: "external"} + borrower.Driver.Shared.Set(PlatformTuple(), reference) + if err := CreateManifest(cfg, borrower); err != nil { + t.Fatal(err) + } + archive := testPackageArchive(t, "replacement") + if _, err := InstallPackage(context.Background(), cfg, "driver", archive, InstallPackageOptions{}); err != nil { + t.Fatal(err) + } + assertArchiveClosed(t, archive) + current, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + if current.Driver.Shared.Get(PlatformTuple()) == reference { + t.Fatalf("replacement registration still points to old generation %q", reference) + } + registeredBorrower, err := GetDriver(cfg, "borrower") + if err != nil { + t.Fatal(err) + } + if registeredBorrower.Driver.Shared.Get(PlatformTuple()) != reference { + t.Fatalf("external borrower points to %q, want %q", registeredBorrower.Driver.Shared.Get(PlatformTuple()), reference) + } + if data, err := os.ReadFile(firstLibrary); err != nil || string(data) != "old" { + t.Fatalf("referenced previous generation was not preserved: %q, %v", data, err) + } + }) + } +} + +func TestUncertainSymlinkReferenceProtectsGeneration(t *testing.T) { + root := t.TempDir() + generation := testPackageGenerationPath(t, root, "driver", "generation") + if err := os.Mkdir(generation, 0o700); err != nil { + t.Fatal(err) + } + brokenLink := filepath.Join(root, "broken-link") + if err := os.Symlink(filepath.Join(root, "missing-target"), brokenLink); err != nil { + t.Skipf("symlink creation is unavailable: %v", err) + } + if !generationReferenced(generation, []string{filepath.Join(brokenLink, "lib.so")}) { + t.Fatal("uncertain symlink reference did not conservatively protect generation") + } +} + +func TestRemovePackageGenerationReceiptOrdering(t *testing.T) { + payloadErr := errors.New("loaded DLL") + tests := []struct { + name string + failPayload bool + wantRemoved []string + }{ + {name: "receipt retained after payload failure", failPayload: true, wantRemoved: []string{"driver.so"}}, + {name: "receipt removed last"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + generation := testPackageGenerationPath(t, root, "driver", "test") + if err := os.Mkdir(generation, 0o700); err != nil { + t.Fatal(err) + } + files := []string{"driver.so", packageInstallReceiptFilename} + if !test.failPayload { + files = append(files, "NOTICE") + } + for _, name := range files { + if err := os.WriteFile(filepath.Join(generation, name), []byte(name), 0o600); err != nil { + t.Fatal(err) + } + } + parent, err := os.OpenRoot(root) + if err != nil { + t.Fatal(err) + } + defer parent.Close() + gen, ok := openVerifiedGeneration(parent, filepath.Base(generation), root, packageCleanupOperations{}) + if !ok { + t.Fatal("could not open verified generation") + } + genInfo, err := gen.Stat(".") + if err != nil { + t.Fatal(err) + } + var removed []string + err = removePackageGenerationAtRoot(parent, verifiedPackageGeneration{name: filepath.Base(generation), root: gen, fileInfo: genInfo}, root, packageCleanupOperations{beforeRemove: func(path string) error { + removed = append(removed, filepath.Base(path)) + if test.failPayload && filepath.Base(path) == "driver.so" { + return payloadErr + } + return nil + }}) + if test.failPayload != errors.Is(err, payloadErr) || (!test.failPayload && err != nil) { + t.Fatalf("remove generation error = %v", err) + } + if test.failPayload { + if !slices.Equal(removed, test.wantRemoved) { + t.Fatalf("remove order = %v, receipt must not be removed after payload failure", removed) + } + if _, err := os.Stat(filepath.Join(generation, packageInstallReceiptFilename)); err != nil { + t.Fatalf("receipt was not retained: %v", err) + } + return + } + if len(removed) != 3 || removed[2] != packageInstallReceiptFilename { + t.Fatalf("removal order = %v, want receipt last", removed) + } + if _, err := os.Stat(generation); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("generation remains after cleanup: %v", err) + } + }) + } +} + +func TestUninstallRegistrationFailurePreservesPackage(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + selected, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + generation := filepath.Dir(selected.Driver.Shared.Get(PlatformTuple())) + registrationErr := errors.New("injected registration removal failure") + operations := packageCleanupOperations{ + beforeRemove: func(string) error { t.Fatal("package cleanup ran before registration commit"); return nil }, + beforeRemoveAll: func(string) error { t.Fatal("package cleanup ran before registration commit"); return nil }, + removeRegistration: func(Config, DriverInfo) error { + return registrationErr + }, + } + if err := uninstallDriverUnlockedWithCleanup(cfg, selected, operations); !errors.Is(err, registrationErr) { + t.Fatalf("uninstall error = %v, want injected registration error", err) + } + if _, err := os.Stat(filepath.Join(root, "driver.toml")); err != nil { + t.Fatalf("registration changed after failed removal: %v", err) + } + if _, err := os.Stat(filepath.Join(generation, packageInstallReceiptFilename)); err != nil { + t.Fatalf("receipt changed after failed registration removal: %v", err) + } + if _, err := os.Stat(selected.Driver.Shared.Get(PlatformTuple())); err != nil { + t.Fatalf("payload changed after failed registration removal: %v", err) + } +} + +func TestUninstallPayloadFailureAfterCommitLeavesReceipt(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + selected, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + generation := filepath.Dir(selected.Driver.Shared.Get(PlatformTuple())) + cleanupErr := errors.New("loaded package library") + operations := packageCleanupOperations{ + beforeRemove: func(path string) error { + if path == selected.Driver.Shared.Get(PlatformTuple()) { + return cleanupErr + } + return nil + }, + } + err = uninstallDriverUnlockedWithCleanup(cfg, selected, operations) + if !errors.Is(err, cleanupErr) { + t.Fatalf("uninstall error = %v", err) + } + if !strings.Contains(err.Error(), "registration was removed") || !strings.Contains(err.Error(), generation) { + t.Fatalf("post-commit cleanup error lacks commit state or residual path: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "driver.toml")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("registration remains after cleanup failure: %v", err) + } + if _, err := os.Stat(filepath.Join(generation, packageInstallReceiptFilename)); err != nil { + t.Fatalf("ownership receipt was removed after post-commit payload failure: %v", err) + } +} + +func TestUninstallRetriesStaleReceiptGenerationsBestEffort(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + old, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + oldGeneration := filepath.Dir(old.Driver.Shared.Get(PlatformTuple())) + archive := testPackageArchive(t, "updated") + installOps := testPackageInstallOperations() + installOps.cleanupBeforeRemove = func(path string) error { + if filepath.Dir(path) == oldGeneration { + return errors.New("old library is still loaded") + } + return nil + } + if _, err := installPackageWithOperations(cfg, "driver", archive, InstallPackageOptions{}, installOps); err != nil { + t.Fatal(err) + } + assertArchiveClosed(t, archive) + current, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + currentGeneration := filepath.Dir(current.Driver.Shared.Get(PlatformTuple())) + if _, ok := readPackageInstallReceipt(root, oldGeneration); !ok { + t.Fatal("old generation receipt was not retained after install GC failure") + } + if err := UninstallDriver(cfg, current); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(oldGeneration); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("old generation was not retried during uninstall: %v", err) + } + if _, err := os.Stat(currentGeneration); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("current generation remains after uninstall: %v", err) + } +} + +func TestUninstallStaleCleanupFailureDoesNotBlockRegistrationRemoval(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + old, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + oldGeneration := filepath.Dir(old.Driver.Shared.Get(PlatformTuple())) + archive := testPackageArchive(t, "updated") + installOps := testPackageInstallOperations() + installOps.cleanupBeforeRemove = func(path string) error { + if filepath.Dir(path) == oldGeneration { + return errors.New("old library is still loaded") + } + return nil + } + if _, err := installPackageWithOperations(cfg, "driver", archive, InstallPackageOptions{}, installOps); err != nil { + t.Fatal(err) + } + assertArchiveClosed(t, archive) + current, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + staleErr := errors.New("stale library remains loaded") + uninstallOps := packageCleanupOperations{ + beforeRemove: func(path string) error { + if filepath.Dir(path) == oldGeneration { + return staleErr + } + return nil + }, + } + if err := uninstallDriverUnlockedWithCleanup(cfg, current, uninstallOps); err != nil { + t.Fatalf("stale cleanup failure blocked uninstall: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "driver.toml")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("registration remains after stale cleanup failure: %v", err) + } + if _, ok := readPackageInstallReceipt(root, oldGeneration); !ok { + t.Fatal("stale failure did not retain the receipt") + } +} + +func TestInstallPackageCleansStaleReceiptGenerationsBestEffort(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + old, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + oldGeneration := filepath.Dir(old.Driver.Shared.Get(PlatformTuple())) + archive := testPackageArchive(t, "updated") + cleanupErr := errors.New("injected loaded library") + operations := testPackageInstallOperations() + operations.cleanupBeforeRemove = func(path string) error { + if filepath.Dir(path) == oldGeneration { + return cleanupErr + } + return nil + } + if _, err := installPackageWithOperations(cfg, "driver", archive, InstallPackageOptions{}, operations); err != nil { + t.Fatalf("install should succeed when stale cleanup fails: %v", err) + } + assertArchiveClosed(t, archive) + current, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + currentGeneration := filepath.Dir(current.Driver.Shared.Get(PlatformTuple())) + if sameResolvedFilesystemPath(currentGeneration, oldGeneration) { + t.Fatal("registration still references old generation") + } + if _, ok := readPackageInstallReceipt(root, oldGeneration); !ok { + t.Fatal("failed stale cleanup did not preserve old ownership receipt") + } + if _, ok := readPackageInstallReceipt(root, currentGeneration); !ok { + t.Fatal("current generation receipt is missing") + } +} + +func makeNamespaceSiblingInfo(id, root string) DriverInfo { + info := DriverInfo{ + ID: id, + FilePath: root, + Name: "Sibling Driver", + Publisher: "Test Publisher", + License: "MIT", + Version: semver.MustParse("1.0.0"), + Source: "external", + } + info.Driver.Shared.Set(PlatformTuple(), filepath.Join(root, id, "driver.so")) + return info +} + +func TestInstallPackageStaleCleanupProtectsGenerationReferencedBySiblingDriver(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + old, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + oldGeneration := filepath.Dir(old.Driver.Shared.Get(PlatformTuple())) + + sibling := makeNamespaceSiblingInfo("sibling", root) + sibling.Source = "external" + sibling.Driver.Shared.Set(PlatformTuple(), old.Driver.Shared.Get(PlatformTuple())) + if err := CreateManifest(cfg, sibling); err != nil { + t.Fatal(err) + } + + archive := testPackageArchive(t, "updated") + if _, err := InstallPackage(context.Background(), cfg, "driver", archive, InstallPackageOptions{}); err != nil { + t.Fatal(err) + } + assertArchiveClosed(t, archive) + if _, ok := readPackageInstallReceipt(root, oldGeneration); !ok { + t.Fatal("stale generation referenced by sibling registration was removed") + } +} + +func TestUninstallKeepsGenerationReferencedBySiblingDriver(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + owned, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + generation := filepath.Dir(owned.Driver.Shared.Get(PlatformTuple())) + + sibling := makeNamespaceSiblingInfo("sibling", root) + sibling.Source = "external" + sibling.Driver.Shared.Set(PlatformTuple(), owned.Driver.Shared.Get(PlatformTuple())) + if err := CreateManifest(cfg, sibling); err != nil { + t.Fatal(err) + } + if err := UninstallDriver(cfg, owned); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "driver.toml")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("uninstalled registration remains: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "sibling.toml")); err != nil { + t.Fatalf("sibling registration was removed: %v", err) + } + if _, ok := readPackageInstallReceipt(root, generation); !ok { + t.Fatal("generation referenced by sibling registration was removed") + } +} + +func TestUninstallExternalRegistrationPreservesSharedLibrary(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + shared := filepath.Join(root, "externally-managed", "driver.so") + if err := os.MkdirAll(filepath.Dir(shared), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(shared, []byte("external library"), 0o600); err != nil { + t.Fatal(err) + } + info := DriverInfo{ID: "external", FilePath: root, Name: "External", Version: semver.MustParse("1.0.0"), Source: "external"} + info.Driver.Shared.Set(PlatformTuple(), shared) + if err := CreateManifest(cfg, info); err != nil { + t.Fatal(err) + } + selected, err := GetDriver(cfg, info.ID) + if err != nil { + t.Fatal(err) + } + if err := UninstallDriver(cfg, selected); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "external.toml")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("external registration remains: %v", err) + } + if data, err := os.ReadFile(shared); err != nil || string(data) != "external library" { + t.Fatalf("external shared library changed: %q, %v", data, err) + } +} + +func TestNonDBCCleanupRetainsReservedTransactionGeneration(t *testing.T) { + for _, referenceKind := range []string{"direct", "missing-receipt", "corrupt-receipt", "symlink-alias", "parent-traversal"} { + t.Run(referenceKind, func(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + owner, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + library := owner.Driver.Shared.Get(PlatformTuple()) + generation := filepath.Dir(library) + if referenceKind == "missing-receipt" { + if err := os.Remove(filepath.Join(generation, packageInstallReceiptFilename)); err != nil { + t.Fatal(err) + } + } else if referenceKind == "corrupt-receipt" { + if err := os.WriteFile(filepath.Join(generation, packageInstallReceiptFilename), []byte("invalid"), 0o600); err != nil { + t.Fatal(err) + } + } + shared := library + switch referenceKind { + case "symlink-alias", "parent-traversal": + subdirectory := filepath.Join(generation, "subdirectory") + if err := os.Mkdir(subdirectory, 0o700); err != nil { + t.Fatal(err) + } + alias := filepath.Join(root, "generation-alias") + if err := os.Symlink(subdirectory, alias); err != nil { + t.Skipf("symlink creation is unavailable: %v", err) + } + if referenceKind == "symlink-alias" { + if err := os.Remove(alias); err != nil { + t.Fatal(err) + } + if err := os.Symlink(generation, alias); err != nil { + t.Fatal(err) + } + shared = filepath.Join(alias, filepath.Base(library)) + } else { + shared = alias + string(filepath.Separator) + ".." + string(filepath.Separator) + filepath.Base(library) + } + if referenceKind == "parent-traversal" { + assertParentTraversalReferenceResolvesOnUnix(t, shared) + } else if _, err := os.Stat(shared); err != nil { + t.Fatalf("fixture alias does not resolve to library: %v", err) + } + } + + borrower := makeNamespaceSiblingInfo("borrower", root) + borrower.Source = "external" + borrower.Driver.Shared.Set(PlatformTuple(), shared) + if err := CreateManifest(cfg, borrower); err != nil { + t.Fatal(err) + } + selected, err := GetDriver(cfg, borrower.ID) + if err != nil { + t.Fatal(err) + } + if err := UninstallDriver(cfg, selected); err != nil { + t.Fatalf("borrower uninstall: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "borrower.toml")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("borrower registration remains: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "driver.toml")); err != nil { + t.Fatalf("owner registration was removed: %v", err) + } + if _, err := os.Stat(generation); err != nil { + t.Fatalf("borrower cleanup removed owner generation: %v", err) + } + if referenceKind == "direct" { + if _, ok := readPackageInstallReceipt(root, generation); !ok { + t.Fatal("borrower cleanup removed the owner's receipt") + } + } + }) + } +} + +func TestRegistrationNamespaceLockSerializesInstallCleanupAndCreateManifest(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + old, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + oldGeneration := filepath.Dir(old.Driver.Shared.Get(PlatformTuple())) + cleanupStarted := make(chan struct{}) + allowCleanup := make(chan struct{}) + archive := testPackageArchive(t, "updated") + operations := testPackageInstallOperations() + operations.cleanupBeforeRemove = func(path string) error { + if filepath.Dir(path) == oldGeneration { + select { + case <-cleanupStarted: + default: + close(cleanupStarted) + } + <-allowCleanup + } + return nil + } + installDone := make(chan error, 1) + go func() { + _, err := installPackageWithOperations(cfg, "driver", archive, InstallPackageOptions{}, operations) + installDone <- err + }() + <-cleanupStarted + + sibling := makeNamespaceSiblingInfo("sibling", root) + sibling.Source = "external" + sibling.Driver.Shared.Set(PlatformTuple(), old.Driver.Shared.Get(PlatformTuple())) + siblingDone := make(chan error, 1) + started := make(chan struct{}) + go func() { + close(started) + siblingDone <- CreateManifest(cfg, sibling) + }() + <-started + deadline := time.Now().Add(time.Second) + for { + probe, probeErr := acquireDriverInstallLockWith(context.Background(), root, "sibling", 5*time.Millisecond) + if errors.Is(probeErr, fslock.ErrLockContended) { + break + } + if probeErr != nil { + t.Fatalf("probe sibling driver lock: %v", probeErr) + } + if err := probe.release(); err != nil { + t.Fatal(err) + } + if time.Now().After(deadline) { + t.Fatal("sibling registration did not acquire its driver lock") + } + time.Sleep(5 * time.Millisecond) + } + select { + case err := <-siblingDone: + t.Fatalf("sibling registration published during stale cleanup: %v", err) + default: + } + close(allowCleanup) + if err := <-installDone; err != nil { + t.Fatalf("update install: %v", err) + } + if err := <-siblingDone; err != nil { + t.Fatalf("sibling manifest creation: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "sibling.toml")); err != nil { + t.Fatalf("sibling registration was not published after cleanup: %v", err) + } +} + +func TestUncertainSiblingRegistrationSkipsPayloadCleanupButAllowsUninstall(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + old, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + oldGeneration := filepath.Dir(old.Driver.Shared.Get(PlatformTuple())) + if err := os.WriteFile(filepath.Join(root, "broken.toml"), []byte("[Driver\n"), 0o600); err != nil { + t.Fatal(err) + } + + archive := testPackageArchive(t, "updated") + if _, err := InstallPackage(context.Background(), cfg, "driver", archive, InstallPackageOptions{}); err != nil { + t.Fatalf("install should continue when cleanup enumeration is uncertain: %v", err) + } + assertArchiveClosed(t, archive) + if _, ok := readPackageInstallReceipt(root, oldGeneration); !ok { + t.Fatal("uncertain registration enumeration did not conservatively retain stale generation") + } + current, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + currentGeneration := filepath.Dir(current.Driver.Shared.Get(PlatformTuple())) + if err := UninstallDriver(cfg, current); err != nil { + t.Fatalf("uninstall should remove registration despite uncertain cleanup enumeration: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "driver.toml")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("registration remains after uninstall: %v", err) + } + for _, generation := range []string{oldGeneration, currentGeneration} { + if _, ok := readPackageInstallReceipt(root, generation); !ok { + t.Errorf("unproven cleanup removed generation %s", generation) + } + } +} + +func TestInstallPackageCleansRollbackCandidateOnLaterSuccess(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + archive := testPackageArchive(t, "candidate") + operations := testPackageInstallOperations() + var candidate string + operations.register = func(_ Config, _ string, info DriverInfo) error { + candidate = filepath.Dir(info.Driver.Shared.Get(PlatformTuple())) + return errors.Join(errors.New("registry failed"), errRegistrationRollbackFailed) + } + if _, err := installPackageWithOperations(cfg, "driver", archive, InstallPackageOptions{}, operations); !errors.Is(err, errRegistrationRollbackFailed) { + t.Fatalf("initial install error = %v", err) + } + if _, ok := readPackageInstallReceipt(root, candidate); !ok { + t.Fatal("rollback candidate receipt is invalid") + } + archive = testPackageArchive(t, "success") + if _, err := InstallPackage(context.Background(), cfg, "driver", archive, InstallPackageOptions{}); err != nil { + t.Fatal(err) + } + assertArchiveClosed(t, archive) + if _, err := os.Stat(candidate); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("stale rollback candidate remains: %v", err) + } +} + +func TestLegacyPackageCleanupRemovesOnlyExactRegisteredFile(t *testing.T) { + for _, basename := range []string{"driver_" + PlatformTuple() + "_v1.2.3", "unknown-layout"} { + for _, hasUnrelatedFile := range []bool{false, true} { + name := basename + "/only registered file" + if hasUnrelatedFile { + name = basename + "/retains unrelated file" + } + t.Run(name, func(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + generation := filepath.Join(root, basename) + if err := os.Mkdir(generation, 0o700); err != nil { + t.Fatal(err) + } + shared := filepath.Join(generation, "driver.so") + if err := os.WriteFile(shared, []byte("legacy"), 0o600); err != nil { + t.Fatal(err) + } + unrelated := filepath.Join(generation, "unrelated.txt") + if hasUnrelatedFile { + if err := os.WriteFile(unrelated, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + } + info := DriverInfo{ID: "driver", FilePath: root, Name: "Driver", Source: "dbc", Version: semver.MustParse("1.2.3")} + info.Driver.Shared.Set(PlatformTuple(), shared) + if err := CreateManifest(cfg, info); err != nil { + t.Fatal(err) + } + selected, err := GetDriver(cfg, info.ID) + if err != nil { + t.Fatal(err) + } + cleanupErr := UninstallDriver(cfg, selected) + _, err = os.Stat(generation) + if cleanupErr != nil { + t.Fatal(cleanupErr) + } + if _, payloadErr := os.Stat(shared); !errors.Is(payloadErr, os.ErrNotExist) { + t.Fatalf("registered legacy payload remains: %v", payloadErr) + } + if hasUnrelatedFile { + if err != nil { + t.Fatalf("directory with unrelated files was removed: %v", err) + } + if _, err := os.Stat(unrelated); err != nil { + t.Fatalf("unrelated file was removed: %v", err) + } + } else if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("empty legacy directory remains: %v", err) + } + }) + } + } +} + +func TestLegacyPackageCleanupRetainsCorruptReceipt(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + generation := filepath.Join(root, "driver_"+PlatformTuple()+"_v1.2.3") + if err := os.Mkdir(generation, 0o700); err != nil { + t.Fatal(err) + } + shared := filepath.Join(generation, "driver.so") + if err := os.WriteFile(shared, []byte("legacy"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(generation, packageInstallReceiptFilename), []byte("bad receipt"), 0o600); err != nil { + t.Fatal(err) + } + info := DriverInfo{ID: "driver", FilePath: root, Name: "Driver", Source: "dbc", Version: semver.MustParse("1.2.3")} + info.Driver.Shared.Set(PlatformTuple(), shared) + if err := CreateManifest(cfg, info); err != nil { + t.Fatal(err) + } + selected, err := GetDriver(cfg, info.ID) + if err != nil { + t.Fatal(err) + } + if err := UninstallDriver(cfg, selected); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(generation); err != nil { + t.Fatalf("generation with corrupt receipt was removed: %v", err) + } +} + +func TestLegacyPackageCleanupPreservesGenerationReferencedBySibling(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + version := semver.MustParse("1.2.3") + generation := filepath.Join(root, "driver_"+PlatformTuple()+"_v"+version.String()) + if err := os.Mkdir(generation, 0o700); err != nil { + t.Fatal(err) + } + library := filepath.Join(generation, "driver.so") + if err := os.WriteFile(library, []byte("legacy"), 0o600); err != nil { + t.Fatal(err) + } + owner := DriverInfo{ID: "driver", Name: "Driver", Version: version, Source: "dbc"} + owner.Driver.Shared.Set(PlatformTuple(), library) + if err := CreateManifest(cfg, owner); err != nil { + t.Fatal(err) + } + borrower := makeNamespaceSiblingInfo("borrower", root) + borrower.Source = "external" + borrower.Driver.Shared.Set(PlatformTuple(), library) + if err := CreateManifest(cfg, borrower); err != nil { + t.Fatal(err) + } + selected, err := GetDriver(cfg, owner.ID) + if err != nil { + t.Fatal(err) + } + if err := UninstallDriver(cfg, selected); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(generation); err != nil { + t.Fatalf("legacy generation referenced by sibling was removed: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "driver.toml")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("owner registration remains after uninstall: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "borrower.toml")); err != nil { + t.Fatalf("sibling registration was removed: %v", err) + } +} + +func TestTransactionEvidenceBlocksLegacyFallback(t *testing.T) { + for _, receiptState := range []string{"missing", "corrupt"} { + t.Run(receiptState, func(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + version := semver.MustParse("1.2.3") + legacySidecar := filepath.Join(root, "driver_"+PlatformTuple()+"_v"+version.String()) + if err := os.Mkdir(legacySidecar, 0o700); err != nil { + t.Fatal(err) + } + external := filepath.Join(legacySidecar, "external.so") + if err := os.WriteFile(external, []byte("external library"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(legacySidecar, "sibling.txt"), []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + transaction := testPackageGenerationPath(t, root, "driver", "incomplete") + if err := os.Mkdir(transaction, 0o700); err != nil { + t.Fatal(err) + } + if receiptState == "corrupt" { + if err := os.WriteFile(filepath.Join(transaction, packageInstallReceiptFilename), []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + } + info := DriverInfo{ID: "driver", Name: "Driver", Version: version, Source: "dbc"} + info.Driver.Shared.Set(PlatformTuple(), external) + if err := CreateManifest(cfg, info); err != nil { + t.Fatal(err) + } + selected, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + if err := UninstallDriver(cfg, selected); err != nil { + t.Fatal(err) + } + for _, path := range []string{legacySidecar, transaction, external, filepath.Join(legacySidecar, "sibling.txt")} { + if _, err := os.Stat(path); err != nil { + t.Errorf("unproven package data %s was removed: %v", path, err) + } + } + if _, err := os.Stat(filepath.Join(root, "driver.toml")); !errors.Is(err, os.ErrNotExist) { + t.Errorf("registration remains: %v", err) + } + }) + } +} + +func TestUninstallUsesSelectedConfigEnvRootOnly(t *testing.T) { + primary := filepath.Join(t.TempDir(), "primary") + secondary := filepath.Join(t.TempDir(), "secondary") + for _, root := range []string{primary, secondary} { + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + } + cfg := Config{Level: ConfigEnv, Location: strings.Join([]string{primary, secondary}, string(filepath.ListSeparator))} + archive := testPackageArchive(t, "library") + if _, err := InstallPackage(context.Background(), cfg, "driver", archive, InstallPackageOptions{}); err != nil { + t.Fatal(err) + } + selected, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + primaryGeneration := filepath.Dir(selected.Driver.Shared.Get(PlatformTuple())) + secondaryGeneration := filepath.Join(secondary, filepath.Base(primaryGeneration)) + if err := os.Mkdir(secondaryGeneration, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(secondaryGeneration, "user-file"), []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + if err := UninstallDriver(cfg, selected); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(primaryGeneration); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("selected primary generation remains: %v", err) + } + if _, err := os.Stat(filepath.Join(secondaryGeneration, "user-file")); err != nil { + t.Fatalf("secondary root was modified: %v", err) + } +} + +func TestInstallPackageCleansStrictLegacyGenerationOnlyAfterCommit(t *testing.T) { + for _, failure := range []string{"", "verify", "register"} { + name := "success" + if failure != "" { + name = failure + " failure" + } + t.Run(name, func(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + legacyGeneration, legacyLibrary := createLegacyPackageForInstall(t, cfg, "driver", "0.9.0", "legacy library") + archive := testPackageArchive(t, "new library") + var installErr error + if failure == "register" { + operations := testPackageInstallOperations() + operations.register = func(Config, string, DriverInfo) error { return errors.New("registration failed") } + _, installErr = installPackageWithOperations(cfg, "driver", archive, InstallPackageOptions{}, operations) + } else { + options := InstallPackageOptions{} + if failure == "verify" { + options.Verifier = func(string, Manifest) error { return errors.New("verification failed") } + } + _, installErr = InstallPackage(context.Background(), cfg, "driver", archive, options) + } + if failure == "" { + if installErr != nil { + t.Fatal(installErr) + } + if _, err := os.Stat(legacyGeneration); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("committed install left legacy generation: %v", err) + } + current, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + if current.Version.String() != "1.0.0" || current.Driver.Shared.Get(PlatformTuple()) == legacyLibrary { + t.Fatalf("current registration = %#v", current) + } + if _, err := os.Stat(current.Driver.Shared.Get(PlatformTuple())); err != nil { + t.Fatalf("new library is unavailable: %v", err) + } + return + } + if installErr == nil { + t.Fatal("expected installation failure") + } + current, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + if current.Driver.Shared.Get(PlatformTuple()) != legacyLibrary || current.Version.String() != "0.9.0" { + t.Fatalf("failed replacement changed registration: %#v", current) + } + if data, err := os.ReadFile(legacyLibrary); err != nil || string(data) != "legacy library" { + t.Fatalf("failed replacement changed legacy payload: %q, %v", data, err) + } + }) + } +} + +func TestInstallPackageLegacyCleanupFailureDoesNotFailCommit(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + legacyGeneration, legacyLibrary := createLegacyPackageForInstall(t, cfg, "driver", "0.9.0", "legacy") + cleanupErr := errors.New("legacy cleanup failed") + operations := testPackageInstallOperations() + operations.cleanupBeforeRemove = func(path string) error { + if path == legacyLibrary { + return cleanupErr + } + return nil + } + archive := testPackageArchive(t, "new") + if _, err := installPackageWithOperations(cfg, "driver", archive, InstallPackageOptions{}, operations); err != nil { + t.Fatalf("legacy cleanup failure changed install result: %v", err) + } + current, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + if current.Version.String() != "1.0.0" { + t.Fatalf("current version = %s", current.Version) + } + if _, err := os.Stat(current.Driver.Shared.Get(PlatformTuple())); err != nil { + t.Fatalf("committed library is unavailable: %v", err) + } + if _, err := os.Stat(legacyGeneration); err != nil { + t.Fatalf("failed best-effort cleanup did not retain legacy generation: %v", err) + } +} + +func TestInstallPackageRetainsUnprovenLegacyCandidates(t *testing.T) { + for _, mode := range []string{"malformed registration", "symlink registration", "arbitrary basename", "corrupt legacy receipt", "missing transaction receipt", "corrupt transaction receipt"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + legacyGeneration, _ := createLegacyPackageForInstall(t, cfg, "driver", "0.9.0", "legacy") + unprovenPath := legacyGeneration + var arbitraryLibrary, unrelatedFile string + switch mode { + case "malformed registration": + if err := os.WriteFile(filepath.Join(root, "driver.toml"), []byte("not valid ["), 0o600); err != nil { + t.Fatal(err) + } + case "symlink registration": + target := filepath.Join(t.TempDir(), "registration.toml") + registration, err := os.ReadFile(filepath.Join(root, "driver.toml")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, registration, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(root, "driver.toml")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(root, "driver.toml")); err != nil { + t.Skipf("symlink creation is unavailable: %v", err) + } + case "arbitrary basename": + if err := os.Remove(filepath.Join(legacyGeneration, "driver.so")); err != nil { + t.Fatal(err) + } + if err := os.Remove(legacyGeneration); err != nil { + t.Fatal(err) + } + arbitrary := filepath.Join(root, "release-bundle") + if err := os.MkdirAll(arbitrary, 0o700); err != nil { + t.Fatal(err) + } + library := filepath.Join(arbitrary, "driver.so") + if err := os.WriteFile(library, []byte("legacy"), 0o600); err != nil { + t.Fatal(err) + } + unrelatedFile = filepath.Join(arbitrary, "unrelated.txt") + if err := os.WriteFile(unrelatedFile, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + arbitraryLibrary = library + info, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + info.Driver.Shared.Set(PlatformTuple(), library) + if err := createRuntimeRegistrationUnlocked(cfg, root, info); err != nil { + t.Fatal(err) + } + unprovenPath = arbitrary + case "corrupt legacy receipt": + if err := os.WriteFile(filepath.Join(legacyGeneration, packageInstallReceiptFilename), []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + case "missing transaction receipt", "corrupt transaction receipt": + transaction := testPackageGenerationPath(t, root, "driver", "unproven") + if err := os.Mkdir(transaction, 0o700); err != nil { + t.Fatal(err) + } + if mode == "corrupt transaction receipt" { + if err := os.WriteFile(filepath.Join(transaction, packageInstallReceiptFilename), []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + } + } + archive := testPackageArchive(t, "new") + if _, err := InstallPackage(context.Background(), cfg, "driver", archive, InstallPackageOptions{}); err != nil { + t.Fatal(err) + } + if mode == "arbitrary basename" { + if _, err := os.Stat(arbitraryLibrary); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("registered arbitrary-basename payload remains: %v", err) + } + if _, err := os.Stat(unprovenPath); err != nil { + t.Fatalf("directory with unknown files was removed: %v", err) + } + if data, err := os.ReadFile(unrelatedFile); err != nil || string(data) != "keep" { + t.Fatalf("unrelated arbitrary-basename file changed: %q, %v", data, err) + } + } else if _, err := os.Stat(unprovenPath); err != nil { + t.Fatalf("unproven candidate %s was removed: %v", unprovenPath, err) + } + if mode == "missing transaction receipt" || mode == "corrupt transaction receipt" { + transaction := testPackageGenerationPath(t, root, "driver", "unproven") + if _, err := os.Stat(transaction); err != nil { + t.Fatalf("unproven transaction generation was removed: %v", err) + } + } + }) + } +} + +func TestInstallPackageLegacyCleanupRespectsReferencesAndUncertainty(t *testing.T) { + for _, mode := range []string{"sibling reference", "external borrower", "uncertain reference snapshot"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + legacyGeneration, legacyLibrary := createLegacyPackageForInstall(t, cfg, "driver", "0.9.0", "legacy") + if mode == "sibling reference" { + sibling := DriverInfo{ID: "sibling", Name: "Sibling", Version: semver.MustParse("1.0.0"), Source: "external"} + sibling.Driver.Shared.Set(PlatformTuple(), legacyLibrary) + if err := createRuntimeRegistrationUnlocked(cfg, root, sibling); err != nil { + t.Fatal(err) + } + } else if mode == "external borrower" { + borrower := DriverInfo{ID: "borrower", Name: "Borrower", Version: semver.MustParse("1.0.0"), Source: "external"} + borrower.Driver.Shared.Set(PlatformTuple(), legacyLibrary) + if err := CreateManifest(cfg, borrower); err != nil { + t.Fatal(err) + } + } else if mode == "uncertain reference snapshot" { + if err := os.WriteFile(filepath.Join(root, "sibling.toml"), []byte("invalid = ["), 0o600); err != nil { + t.Fatal(err) + } + } + var archive *os.File + archive = testPackageArchive(t, "new") + if _, err := InstallPackage(context.Background(), cfg, "driver", archive, InstallPackageOptions{}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(legacyGeneration); err != nil { + t.Fatalf("referenced or uncertain legacy generation was removed: %v", err) + } + }) + } +} + +func TestInstallPackageLegacyCleanupPreservesExternalLibraryAndUnknownFiles(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + external := filepath.Join(t.TempDir(), "external.so") + if err := os.WriteFile(external, []byte("external"), 0o600); err != nil { + t.Fatal(err) + } + legacyGeneration := filepath.Join(root, "driver_"+PlatformTuple()+"_v0.9.0") + if err := os.Mkdir(legacyGeneration, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(legacyGeneration, "NOTICE"), []byte("metadata"), 0o600); err != nil { + t.Fatal(err) + } + legacy := DriverInfo{ID: "driver", Name: "Driver", Version: semver.MustParse("0.9.0"), Source: "dbc"} + legacy.Driver.Shared.Set(PlatformTuple(), external) + if err := createRuntimeRegistrationUnlocked(cfg, root, legacy); err != nil { + t.Fatal(err) + } + archive := testPackageArchive(t, "new") + if _, err := InstallPackage(context.Background(), cfg, "driver", archive, InstallPackageOptions{}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(legacyGeneration); err != nil { + t.Fatalf("legacy directory with unknown files was removed: %v", err) + } + if data, err := os.ReadFile(filepath.Join(legacyGeneration, "NOTICE")); err != nil || string(data) != "metadata" { + t.Fatalf("unknown legacy metadata changed: %q, %v", data, err) + } + if data, err := os.ReadFile(external); err != nil || string(data) != "external" { + t.Fatalf("external library was removed or changed: %q, %v", data, err) + } +} + +func TestInstallPackageLegacyCleanupUsesPrimaryRootOnly(t *testing.T) { + primary, secondary := t.TempDir(), t.TempDir() + primaryCfg := Config{Level: ConfigEnv, Location: primary} + secondaryCfg := Config{Level: ConfigEnv, Location: secondary} + secondaryGeneration, _ := createLegacyPackageForInstall(t, secondaryCfg, "driver", "0.9.0", "secondary legacy") + cfg := Config{Level: ConfigEnv, Location: primary + string(filepath.ListSeparator) + secondary} + archive := testPackageArchive(t, "new") + if _, err := InstallPackage(context.Background(), cfg, "driver", archive, InstallPackageOptions{}); err != nil { + t.Fatal(err) + } + if _, err := GetDriver(primaryCfg, "driver"); err != nil { + t.Fatalf("primary registration was not installed: %v", err) + } + if _, err := os.Stat(secondaryGeneration); err != nil { + t.Fatalf("secondary legacy generation was modified: %v", err) + } +} + +func TestInstallPackageStageRuntimeIDUsesSeparateNamespace(t *testing.T) { + for _, preexistingEvidence := range []bool{false, true} { + name := "staging entry uses separate namespace" + if preexistingEvidence { + name = "preexisting transaction evidence" + } + t.Run(name, func(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + legacyGeneration, _ := createLegacyPackageForInstall(t, cfg, "stage", "0.9.0", "legacy") + if preexistingEvidence { + generationPrefix, err := packageGenerationPrefix("stage") + if err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(root, generationPrefix+"preexisting"), 0o700); err != nil { + t.Fatal(err) + } + } + archive := testPackageArchive(t, "new") + if _, err := InstallPackage(context.Background(), cfg, "stage", archive, InstallPackageOptions{}); err != nil { + t.Fatal(err) + } + _, err := os.Stat(legacyGeneration) + if preexistingEvidence && err != nil { + t.Fatalf("preexisting transaction evidence failed to protect legacy predecessor: %v", err) + } + if !preexistingEvidence && !errors.Is(err, os.ErrNotExist) { + t.Fatalf("current staging entry prevented legacy cleanup: %v", err) + } + }) + } +} + +func createLegacyPackageForInstall(t *testing.T, cfg Config, runtimeID, version, contents string) (string, string) { + t.Helper() + root, err := EnsureLocation(cfg) + if err != nil { + t.Fatal(err) + } + generation := filepath.Join(root, runtimeID+"_"+PlatformTuple()+"_v"+version) + if err := os.Mkdir(generation, 0o700); err != nil { + t.Fatal(err) + } + library := filepath.Join(generation, runtimeID+".so") + if err := os.WriteFile(library, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + info := DriverInfo{ID: runtimeID, Name: "Driver", Version: semver.MustParse(version), Source: "dbc"} + info.Driver.Shared.Set(PlatformTuple(), library) + if err := createRuntimeRegistrationUnlocked(cfg, root, info); err != nil { + t.Fatal(err) + } + return generation, library +} + +func uninstallDriverUnlockedWithCleanup(cfg Config, info DriverInfo, operations packageCleanupOperations) error { + registrations, certain, _ := collectRegistrationSharedMapsExcluding(cfg, info.FilePath, info.FilePath, info.ID) + return uninstallDriverUnlockedWithCleanupAndReferences(cfg, info, registrations, certain, operations) +} diff --git a/config/package_legacy_cleanup.go b/config/package_legacy_cleanup.go new file mode 100644 index 00000000..d46f2925 --- /dev/null +++ b/config/package_legacy_cleanup.go @@ -0,0 +1,138 @@ +// 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. + +package config + +import ( + "errors" + "fmt" + "io/fs" + "os" + + "github.com/columnar-tech/dbc/internal/hostpath" +) + +// cleanupLegacyPackageRegistration removes only the exact registered library +// from a receipt-less direct child directory. The directory itself is removed +// only when that exact file was its last entry. +func cleanupLegacyPackageRegistration(root string, info DriverInfo, references []string, referencesCertain bool, operations packageCleanupOperations) error { + if info.Source != "dbc" || root == "" || !referencesCertain || !supportsPinnedCleanup() { + return nil + } + root, err := hostpath.Abs(root) + if err != nil { + return nil + } + parent, err := os.OpenRoot(root) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("open legacy package root %s: %w", root, err) + } + defer parent.Close() + return cleanupLegacyPackageRegistrationAtRoot(parent, root, info, references, referencesCertain, operations) +} + +func cleanupLegacyPackageRegistrationAtRoot(parent *os.Root, root string, info DriverInfo, references []string, referencesCertain bool, operations packageCleanupOperations) error { + if info.Source != "dbc" || validatePackageFilename(info.ID) != nil || !referencesCertain { + return nil + } + shared := info.Driver.Shared.Get(PlatformTuple()) + if shared == "" || hasParentTraversal(shared) { + return nil + } + rootAbs, err := hostpath.Abs(root) + if err != nil { + return nil + } + sharedAbs := resolvePackagePath(rootAbs, shared) + generationPath := hostpath.Dir(sharedAbs) + if !sameResolvedFilesystemPath(hostpath.Dir(generationPath), hostpath.Clean(rootAbs)) { + return nil + } + generationName := hostpath.Base(generationPath) + sharedName := hostpath.Base(sharedAbs) + if generationName == "." || generationName == ".." || sharedName == "." || sharedName == ".." || sharedName == packageInstallReceiptFilename { + return nil + } + if hasReservedTransactionAncestor(generationPath) { + return nil + } + for _, reference := range references { + if sameFilesystemPathOrUncertain(sharedAbs, reference) { + return nil + } + } + + generation, ok := openVerifiedGeneration(parent, generationName, rootAbs, operations) + if !ok { + return nil + } + defer generation.Close() + + if _, err := generation.Lstat(packageInstallReceiptFilename); !errors.Is(err, fs.ErrNotExist) { + // Any receipt, including a corrupt one, reserves this directory for the + // transaction cleanup path. A missing receipt is required for fallback. + return nil + } + payloadInfo, err := generation.Lstat(sharedName) + if err != nil || !payloadInfo.Mode().IsRegular() { + return nil + } + if operations.beforeRemove != nil { + if err := operations.beforeRemove(sharedAbs); err != nil { + return fmt.Errorf("remove legacy package payload %s: %w", sharedAbs, err) + } + } + currentPayload, err := generation.Lstat(sharedName) + if err != nil || !currentPayload.Mode().IsRegular() || !os.SameFile(payloadInfo, currentPayload) { + return nil + } + if err := generation.Remove(sharedName); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("remove legacy package payload %s: %w", sharedAbs, err) + } + + remaining, err := readRootDirectoryEntries(generation) + if err != nil { + return fmt.Errorf("inspect legacy package directory %s after payload removal: %w", generationPath, err) + } + if len(remaining) != 0 { + return nil + } + generationInfo, err := generation.Stat(".") + if err != nil { + return fmt.Errorf("inspect legacy package directory %s before removal: %w", generationPath, err) + } + if operations.beforeGenerationRemove != nil { + operations.beforeGenerationRemove(generationPath) + } + if err := generation.Close(); err != nil { + return fmt.Errorf("close legacy package directory %s before removal: %w", generationPath, err) + } + currentGeneration, err := parent.Lstat(generationName) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("verify legacy package directory %s before removal: %w", generationPath, err) + } + if !currentGeneration.IsDir() || currentGeneration.Mode()&os.ModeSymlink != 0 || !os.SameFile(generationInfo, currentGeneration) { + return nil + } + if err := parent.Remove(generationName); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("remove empty legacy package directory %s: %w", generationPath, err) + } + return nil +} diff --git a/config/package_legacy_cleanup_test.go b/config/package_legacy_cleanup_test.go new file mode 100644 index 00000000..cf8f109a --- /dev/null +++ b/config/package_legacy_cleanup_test.go @@ -0,0 +1,135 @@ +// 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. + +package config + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "syscall" + "testing" +) + +func TestUninstallLegacyInstallRemovesOnlyArchiveNamedPayload(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + archive := writeCustomPackageArchive(t, "name = \"Legacy\"\nversion = \"1.0.0\"\n[Files]\ndriver = \"driver.so\"\n", packageFile("driver.so", "legacy")) + archivePath := archive.Name() + legacyArchivePath := filepath.Join(filepath.Dir(archivePath), "test-driver-1.tar.gz") + if err := archive.Close(); err != nil { + t.Fatal(err) + } + if err := os.Rename(archivePath, legacyArchivePath); err != nil { + t.Fatal(err) + } + downloaded, err := os.Open(legacyArchivePath) + if err != nil { + t.Fatal(err) + } + manifest, err := InstallDriver(cfg, "driver", downloaded) + if err != nil { + t.Fatal(err) + } + generation := filepath.Dir(manifest.Driver.Shared.Get(PlatformTuple())) + if filepath.Base(generation) != "test-driver-1" { + t.Fatalf("legacy install directory = %q, want archive-derived basename", generation) + } + unrelated := filepath.Join(generation, "unrelated.txt") + if err := os.WriteFile(unrelated, []byte("user data"), 0o600); err != nil { + t.Fatal(err) + } + if err := CreateManifest(cfg, manifest.DriverInfo); err != nil { + t.Fatal(err) + } + selected, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + if err := UninstallDriver(cfg, selected); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(manifest.Driver.Shared.Get(PlatformTuple())); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("registered legacy payload remains: %v", err) + } + if data, err := os.ReadFile(unrelated); err != nil || string(data) != "user data" { + t.Fatalf("unrelated file changed: %q, %v", data, err) + } + if _, err := os.Stat(generation); err != nil { + t.Fatalf("directory with unknown files was removed: %v", err) + } +} + +func TestLegacyCleanupPinsVerifiedDirectoryAcrossSymlinkSwap(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + probeDirectorySymlinkCapability(t, root, outside) + + generation := filepath.Join(root, "arbitrary-legacy-name") + if err := os.Mkdir(generation, 0o700); err != nil { + t.Fatal(err) + } + library := filepath.Join(generation, "driver.so") + if err := os.WriteFile(library, []byte("legacy"), 0o600); err != nil { + t.Fatal(err) + } + sentinel := filepath.Join(outside, "keep.txt") + if err := os.WriteFile(sentinel, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + moved := filepath.Join(root, "renamed-legacy-directory") + info := DriverInfo{ID: "driver", FilePath: root, Name: "Legacy", Source: "dbc"} + info.Driver.Shared.Set(PlatformTuple(), library) + operations := packageCleanupOperations{ + afterGenerationVerified: func(path string) { + if path != generation { + t.Errorf("verified path = %q, want %q", path, generation) + } + if err := os.Rename(generation, moved); err != nil { + t.Errorf("rename verified generation: %v", err) + return + } + if err := os.Symlink(outside, generation); err != nil { + t.Errorf("replace generation path with symlink: %v", err) + } + }, + } + if err := cleanupLegacyPackageRegistration(root, info, nil, true, operations); err != nil { + t.Fatal(err) + } + if data, err := os.ReadFile(sentinel); err != nil || string(data) != "keep" { + t.Fatalf("outside sentinel changed: %q, %v", data, err) + } + if _, err := os.Stat(filepath.Join(moved, "driver.so")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("verified original payload was not removed through its pinned handle: %v", err) + } + if info, err := os.Lstat(generation); err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("replacement generation symlink = %v, %v", info, err) + } +} + +func probeDirectorySymlinkCapability(t *testing.T, root, target string) { + t.Helper() + probe := filepath.Join(root, "symlink-capability-probe") + if err := os.Symlink(target, probe); err != nil { + if runtime.GOOS == "windows" && errors.Is(err, syscall.Errno(1314)) { + t.Skipf("directory symlinks require an unavailable Windows privilege: %v", err) + } + t.Fatalf("probe directory symlink support: %v", err) + } + if err := os.Remove(probe); err != nil { + t.Fatalf("remove directory symlink capability probe: %v", err) + } +} diff --git a/config/package_receipt.go b/config/package_receipt.go new file mode 100644 index 00000000..6188688f --- /dev/null +++ b/config/package_receipt.go @@ -0,0 +1,350 @@ +// 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. + +package config + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "regexp" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "github.com/columnar-tech/dbc/internal/hostpath" + + "github.com/Masterminds/semver/v3" +) + +const ( + packageInstallReceiptFilename = "dbc-install-receipt.json" + packageInstallReceiptVersion = 1 + registrationFingerprintName = "sha256" + registrationFingerprintVer = 1 + packageInstallReceiptMaxSize = 16 * 1024 +) + +type packageRegistrationScope string + +const ( + packageRegistrationFile packageRegistrationScope = "file" + packageRegistrationRegistryUser packageRegistrationScope = "registry-user" + packageRegistrationRegistrySystem packageRegistrationScope = "registry-system" +) + +// packageInstallReceipt records local ownership and integrity evidence. It +// deliberately contains no source identity or archive provenance. +// TODO: Define and record source/archive evidence before strict locked-artifact +// reuse; OwnedLibrarySHA256 verifies local installed bytes only and does not +// establish upstream artifact identity. +type packageInstallReceipt struct { + SchemaVersion int `json:"schema_version"` + RegistrationScope packageRegistrationScope `json:"registration_scope"` + RuntimeID string `json:"runtime_id"` + DriverVersion string `json:"driver_version"` + Platform string `json:"platform"` + Generation string `json:"generation"` + OwnedLibraryFilename string `json:"owned_library_filename"` + OwnedLibrarySHA256 string `json:"owned_library_sha256"` + RegistrationFingerprintAlgo string `json:"registration_fingerprint_algorithm"` + RegistrationFingerprintVer int `json:"registration_fingerprint_version"` + RegistrationFingerprintValue string `json:"registration_fingerprint"` +} + +type registrationFingerprintDTO struct { + Version int `json:"version"` + RuntimeID string `json:"runtime_id"` + Platform string `json:"platform"` + Source string `json:"source"` + Name string `json:"name"` + Publisher string `json:"publisher"` + License string `json:"license"` + Entrypoint string `json:"entrypoint"` + DriverVer string `json:"driver_version"` + ADBCVer *string `json:"adbc_version,omitempty"` + Features *registrationFeatureSetsDTO `json:"features,omitempty"` + Shared string `json:"shared"` +} + +type registrationFeatureSetsDTO struct { + Supported []string `json:"supported"` + Unsupported []string `json:"unsupported"` +} + +var packagePlatformPattern = regexp.MustCompile(`^[a-z0-9]+_[a-z0-9]+$`) + +const packageGenerationNamePrefix = ".dbc-package-g-" + +func packageGenerationPrefix(runtimeID string) (string, error) { + if err := validatePackageFilename(runtimeID); err != nil { + return "", err + } + return packageGenerationNamePrefix + strconv.Itoa(len([]byte(runtimeID))) + "-" + runtimeID + "-", nil +} + +func parsePackageGenerationName(name string) (string, bool) { + if !strings.HasPrefix(name, packageGenerationNamePrefix) { + return "", false + } + rest := strings.TrimPrefix(name, packageGenerationNamePrefix) + separator := strings.IndexByte(rest, '-') + if separator <= 0 { + return "", false + } + lengthText := rest[:separator] + length, err := strconv.Atoi(lengthText) + if err != nil || length <= 0 || strconv.Itoa(length) != lengthText { + return "", false + } + identityAndSuffix := rest[separator+1:] + if len(identityAndSuffix) <= length || identityAndSuffix[length] != '-' { + return "", false + } + runtimeID := identityAndSuffix[:length] + if !utf8.ValidString(runtimeID) || validatePackageFilename(runtimeID) != nil || identityAndSuffix[length+1:] == "" { + return "", false + } + return runtimeID, true +} + +func validPackageRegistrationScope(scope packageRegistrationScope) bool { + switch scope { + case packageRegistrationFile, packageRegistrationRegistryUser, packageRegistrationRegistrySystem: + return true + default: + return false + } +} + +func makePackageInstallReceipt(cfg Config, stagingDir, generation, runtimeID, platform string, info DriverInfo, ownedLibraryFilename string) (packageInstallReceipt, error) { + scope, err := packageRegistrationScopeForConfig(cfg) + if err != nil { + return packageInstallReceipt{}, fmt.Errorf("resolve package registration scope: %w", err) + } + if err := validatePackageFilename(runtimeID); err != nil { + return packageInstallReceipt{}, fmt.Errorf("invalid runtime ID: %w", err) + } + if !packagePlatformPattern.MatchString(platform) { + return packageInstallReceipt{}, fmt.Errorf("invalid package platform %q", platform) + } + if info.Version == nil { + return packageInstallReceipt{}, errors.New("package has no driver version") + } + if info.ID != runtimeID { + return packageInstallReceipt{}, errors.New("package runtime ID does not match receipt ID") + } + if err := validateOwnedPackageFilename(ownedLibraryFilename); err != nil { + return packageInstallReceipt{}, fmt.Errorf("invalid owned library filename: %w", err) + } + file, err := os.Open(hostpath.Join(stagingDir, ownedLibraryFilename)) + if err != nil { + return packageInstallReceipt{}, fmt.Errorf("open package library for receipt: %w", err) + } + digest, copyErr := hashSHA256(file) + closeErr := file.Close() + if copyErr != nil { + return packageInstallReceipt{}, fmt.Errorf("hash package library for receipt: %w", copyErr) + } + if closeErr != nil { + return packageInstallReceipt{}, fmt.Errorf("close package library after hashing: %w", closeErr) + } + libraryHash := hex.EncodeToString(digest) + fingerprint, err := runtimeRegistrationFingerprint(cfg, runtimeID, platform, info, ownedLibraryFilename) + if err != nil { + return packageInstallReceipt{}, err + } + return packageInstallReceipt{ + SchemaVersion: packageInstallReceiptVersion, + RegistrationScope: scope, + RuntimeID: runtimeID, + DriverVersion: info.Version.String(), + Platform: platform, + Generation: generation, + OwnedLibraryFilename: ownedLibraryFilename, + OwnedLibrarySHA256: libraryHash, + RegistrationFingerprintAlgo: registrationFingerprintName, + RegistrationFingerprintVer: registrationFingerprintVer, + RegistrationFingerprintValue: fingerprint, + }, nil +} + +// hashSHA256 returns the SHA-256 digest of everything read from r. +func hashSHA256(r io.Reader) ([]byte, error) { + hash := sha256.New() + _, err := io.Copy(hash, r) + return hash.Sum(nil), err +} + +func runtimeRegistrationFingerprint(cfg Config, runtimeID, platform string, info DriverInfo, ownedLibraryFilename string) (string, error) { + if info.Version == nil { + return "", errors.New("runtime registration has no driver version") + } + if err := validateOwnedPackageFilename(ownedLibraryFilename); err != nil { + return "", fmt.Errorf("invalid package library identity: %w", err) + } + dto := registrationFingerprintDTO{ + Version: registrationFingerprintVer, + RuntimeID: runtimeID, + Platform: platform, + Source: info.Source, + Name: info.Name, + Publisher: info.Publisher, + License: info.License, + Entrypoint: info.Driver.Entrypoint, + DriverVer: info.Version.String(), + Shared: ownedLibraryFilename, + } + if registrationFingerprintIncludesADBC(cfg) { + features := registrationFeatureSetsDTO{ + Supported: normalizeRegistrationFeatures(info.AdbcInfo.Features.Supported), + Unsupported: normalizeRegistrationFeatures(info.AdbcInfo.Features.Unsupported), + } + dto.Features = &features + if info.AdbcInfo.Version != nil { + version := info.AdbcInfo.Version.String() + dto.ADBCVer = &version + } + } + data, err := json.Marshal(dto) + if err != nil { + return "", fmt.Errorf("encode runtime registration fingerprint: %w", err) + } + digest := sha256.Sum256(data) + return hex.EncodeToString(digest[:]), nil +} + +func normalizeRegistrationFeatures(features []string) []string { + if len(features) == 0 { + return []string{} + } + unique := make(map[string]struct{}, len(features)) + for _, feature := range features { + unique[feature] = struct{}{} + } + result := make([]string, 0, len(unique)) + for feature := range unique { + result = append(result, feature) + } + sort.Strings(result) + return result +} + +func writePackageInstallReceipt(stagingDir string, receipt packageInstallReceipt) error { + data, err := json.Marshal(receipt) + if err != nil { + return fmt.Errorf("encode package install receipt: %w", err) + } + data = append(data, '\n') + if len(data) > packageInstallReceiptMaxSize { + return fmt.Errorf("package install receipt exceeds maximum size of %d bytes: got %d", packageInstallReceiptMaxSize, len(data)) + } + path := hostpath.Join(stagingDir, packageInstallReceiptFilename) + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return fmt.Errorf("create package install receipt: %w", err) + } + _, writeErr := file.Write(data) + closeErr := file.Close() + if writeErr != nil { + return fmt.Errorf("write package install receipt: %w", writeErr) + } + if closeErr != nil { + return fmt.Errorf("close package install receipt: %w", closeErr) + } + return nil +} + +// readPackageInstallReceiptAtRoot reads ownership evidence relative to an +// already verified generation handle. Cleanup keeps this handle open through +// the subsequent payload deletion so it never reopens a checked pathname. +func readPackageInstallReceiptAtRoot(generationRoot *os.Root, generationName string) (packageInstallReceipt, bool) { + var empty packageInstallReceipt + info, err := generationRoot.Lstat(packageInstallReceiptFilename) + if err != nil || !info.Mode().IsRegular() { + return empty, false + } + file, err := generationRoot.Open(packageInstallReceiptFilename) + if err != nil { + return empty, false + } + defer file.Close() + openedInfo, err := file.Stat() + if err != nil || !openedInfo.Mode().IsRegular() || !os.SameFile(info, openedInfo) { + return empty, false + } + data, err := io.ReadAll(io.LimitReader(file, packageInstallReceiptMaxSize+1)) + if err != nil || len(data) > packageInstallReceiptMaxSize { + return empty, false + } + var receipt packageInstallReceipt + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if decoder.Decode(&receipt) != nil { + return empty, false + } + if decoder.Decode(new(any)) != io.EOF { + return empty, false + } + if !validPackageInstallReceiptForGeneration(receipt, generationName) { + return empty, false + } + return receipt, true +} + +func validPackageInstallReceiptForGeneration(receipt packageInstallReceipt, generationName string) bool { + if receipt.SchemaVersion != packageInstallReceiptVersion || receipt.RegistrationFingerprintAlgo != registrationFingerprintName || receipt.RegistrationFingerprintVer != registrationFingerprintVer { + return false + } + if validatePackageFilename(receipt.RuntimeID) != nil || !packagePlatformPattern.MatchString(receipt.Platform) || !validPackageRegistrationScope(receipt.RegistrationScope) { + return false + } + version, err := semver.NewVersion(receipt.DriverVersion) + if err != nil || version.String() != receipt.DriverVersion { + return false + } + generationRuntimeID, validGeneration := parsePackageGenerationName(generationName) + if receipt.Generation != generationName || !validGeneration || !sameRuntimeID(generationRuntimeID, receipt.RuntimeID) { + return false + } + if len(receipt.RegistrationFingerprintValue) != sha256.Size*2 { + return false + } + if _, err := hex.DecodeString(receipt.RegistrationFingerprintValue); err != nil || strings.ToLower(receipt.RegistrationFingerprintValue) != receipt.RegistrationFingerprintValue { + return false + } + if validateOwnedPackageFilename(receipt.OwnedLibraryFilename) != nil || len(receipt.OwnedLibrarySHA256) != sha256.Size*2 { + return false + } + if _, err := hex.DecodeString(receipt.OwnedLibrarySHA256); err != nil || strings.ToLower(receipt.OwnedLibrarySHA256) != receipt.OwnedLibrarySHA256 { + return false + } + return true +} + +func validateOwnedPackageFilename(name string) error { + if err := validatePackageFilename(name); err != nil { + return err + } + if strings.EqualFold(name, packageInstallReceiptFilename) { + return fmt.Errorf("package library filename %q is reserved", name) + } + return nil +} diff --git a/config/package_receipt_test.go b/config/package_receipt_test.go new file mode 100644 index 00000000..be704838 --- /dev/null +++ b/config/package_receipt_test.go @@ -0,0 +1,600 @@ +// 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. + +package config + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Masterminds/semver/v3" +) + +func TestInstallPackageWritesOwnedLibraryReceipt(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + archive := testPackageArchive(t, "verified-bytes") + _, err := InstallPackage(context.Background(), cfg, "driver", archive, InstallPackageOptions{Verifier: func(stage string, _ Manifest) error { + return os.WriteFile(filepath.Join(stage, "driver.so"), []byte("post verifier bytes"), 0o600) + }}) + if err != nil { + t.Fatal(err) + } + assertArchiveClosed(t, archive) + registered, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + generation := filepath.Dir(registered.Driver.Shared.Get(PlatformTuple())) + receipt, ok := readPackageInstallReceipt(root, generation) + if !ok { + t.Fatal("installed generation has no valid receipt") + } + if receipt.SchemaVersion != packageInstallReceiptVersion || receipt.RuntimeID != "driver" || receipt.DriverVersion != "1.0.0" || receipt.Platform != PlatformTuple() || receipt.Generation != filepath.Base(generation) { + t.Fatalf("receipt identity = %+v", receipt) + } + wantScope, err := packageRegistrationScopeForConfig(cfg) + if err != nil || receipt.RegistrationScope != wantScope { + t.Fatalf("receipt registration scope = %q, want %q: %v", receipt.RegistrationScope, wantScope, err) + } + if receipt.OwnedLibraryFilename != "driver.so" { + t.Fatalf("receipt ownership = %+v", receipt) + } + expected := sha256.Sum256([]byte("post verifier bytes")) + if receipt.OwnedLibrarySHA256 != hex.EncodeToString(expected[:]) { + t.Fatalf("owned hash = %q, want %x", receipt.OwnedLibrarySHA256, expected) + } + data, err := os.ReadFile(filepath.Join(generation, packageInstallReceiptFilename)) + if err != nil { + t.Fatal(err) + } + var keys map[string]json.RawMessage + if err := json.Unmarshal(data, &keys); err != nil { + t.Fatal(err) + } + var encodedVersions struct { + SchemaVersion int `json:"schema_version"` + RegistrationFingerprintVer int `json:"registration_fingerprint_version"` + } + if err := json.Unmarshal(data, &encodedVersions); err != nil { + t.Fatal(err) + } + if encodedVersions.SchemaVersion != 1 || encodedVersions.RegistrationFingerprintVer != 1 { + t.Fatalf("serialized receipt and fingerprint versions = %d/%d, want 1/1", encodedVersions.SchemaVersion, encodedVersions.RegistrationFingerprintVer) + } + for _, key := range []string{"url", "source_identity", "archive_hash", "archive_size", "source_type", "registry", "path"} { + if _, exists := keys[key]; exists { + t.Fatalf("source-specific key %q in receipt", key) + } + } + for _, key := range []string{"owned_library_filename", "owned_library_sha256"} { + if _, exists := keys[key]; !exists { + t.Fatalf("required ownership key %q is missing from receipt", key) + } + } + if _, exists := keys["library_kind"]; exists { + t.Fatal("owned receipt retains the removed library-kind distinction") + } +} + +func TestPackageRegistrationScopesAreValidatedAndDistinct(t *testing.T) { + for _, scope := range []packageRegistrationScope{packageRegistrationFile, packageRegistrationRegistryUser, packageRegistrationRegistrySystem} { + if !validPackageRegistrationScope(scope) { + t.Errorf("known registration scope %q was rejected", scope) + } + } + if validPackageRegistrationScope("other") { + t.Fatal("unknown registration scope was accepted") + } +} + +func TestWritePackageInstallReceiptEnforcesFinalSizeLimit(t *testing.T) { + root := t.TempDir() + generation := testPackageGenerationPath(t, root, "driver", "generation") + if err := os.Mkdir(generation, 0o700); err != nil { + t.Fatal(err) + } + receipt := packageInstallReceipt{ + SchemaVersion: packageInstallReceiptVersion, + RegistrationScope: packageRegistrationFile, + RuntimeID: "driver", + DriverVersion: "1.0.0", + Platform: "linux_amd64", + Generation: filepath.Base(generation), + OwnedLibraryFilename: "driver.so", + OwnedLibrarySHA256: strings.Repeat("a", sha256.Size*2), + RegistrationFingerprintAlgo: registrationFingerprintName, + RegistrationFingerprintVer: registrationFingerprintVer, + RegistrationFingerprintValue: strings.Repeat("a", sha256.Size*2), + } + if err := writePackageInstallReceipt(generation, receipt); err != nil { + t.Fatalf("write receipt below size limit: %v", err) + } + if _, ok := readPackageInstallReceipt(root, generation); !ok { + t.Fatal("receipt written below size limit was not readable") + } + if err := os.Remove(filepath.Join(generation, packageInstallReceiptFilename)); err != nil { + t.Fatal(err) + } + + receipt.DriverVersion = strings.Repeat("x", packageInstallReceiptMaxSize) + err := writePackageInstallReceipt(generation, receipt) + if err == nil || !strings.Contains(err.Error(), "exceeds maximum size") { + t.Fatalf("oversized receipt write error = %v, want clear size-limit error", err) + } + if _, err := os.Lstat(filepath.Join(generation, packageInstallReceiptFilename)); !os.IsNotExist(err) { + t.Fatalf("oversized receipt created a file before rejection: %v", err) + } +} + +func TestInstallPackageReceiptFailurePreservesPreviousRegistration(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + previous, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + previousPath := previous.Driver.Shared.Get(PlatformTuple()) + archive := testPackageArchive(t, "new") + writeErr := errors.New("injected receipt failure") + operations := testPackageInstallOperations() + operations.writeReceipt = func(string, packageInstallReceipt) error { return writeErr } + _, err = installPackageWithOperations(cfg, "driver", archive, InstallPackageOptions{}, operations) + if !errors.Is(err, writeErr) || !strings.Contains(err.Error(), "write package install receipt") { + t.Fatalf("InstallPackage error = %v", err) + } + assertArchiveClosed(t, archive) + assertPreviousPackage(t, cfg, previousPath) + assertOnlyPackageGeneration(t, root, filepath.Base(filepath.Dir(previousPath))) +} + +func TestInstallPackageRollbackFailurePreservesReceipt(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installInitialPackage(t, cfg) + previous, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + previousPath := previous.Driver.Shared.Get(PlatformTuple()) + archive := testPackageArchive(t, "candidate") + operations := testPackageInstallOperations() + var candidate string + operations.register = func(_ Config, _ string, info DriverInfo) error { + candidate = filepath.Dir(info.Driver.Shared.Get(PlatformTuple())) + return errRegistrationRollbackFailed + } + _, err = installPackageWithOperations(cfg, "driver", archive, InstallPackageOptions{}, operations) + if !errors.Is(err, errRegistrationRollbackFailed) { + t.Fatalf("InstallPackage error = %v", err) + } + assertPreviousPackage(t, cfg, previousPath) + if _, err := os.Stat(filepath.Join(candidate, packageInstallReceiptFilename)); err != nil { + t.Fatalf("rollback candidate receipt was not preserved: %v", err) + } + if _, ok := readPackageInstallReceipt(root, candidate); !ok { + t.Fatal("rollback candidate receipt is invalid") + } +} + +func TestRuntimeRegistrationFingerprintCanonicalization(t *testing.T) { + cfg := Config{Level: ConfigEnv} + info := fingerprintTestDriverInfo() + info.AdbcInfo.Features.Supported = []string{"zeta", "alpha", "zeta"} + first, err := runtimeRegistrationFingerprint(cfg, "driver", "linux_amd64", info, "driver.so") + if err != nil { + t.Fatal(err) + } + info.FilePath = "/unrelated/registration/location" + info.Driver.Shared.Set("linux_amd64", "/random/generation-a/driver.so") + info.AdbcInfo.Features.Supported = []string{"alpha", "zeta"} + second, err := runtimeRegistrationFingerprint(cfg, "driver", "linux_amd64", info, "driver.so") + if err != nil || second != first { + t.Fatalf("owned fingerprint changed with generation path/feature order: %q, %v", second, err) + } + info.Name = "Changed name" + metadataChanged, err := runtimeRegistrationFingerprint(cfg, "driver", "linux_amd64", info, "driver.so") + if err != nil || metadataChanged == first { + t.Fatalf("metadata did not change fingerprint: %q, %v", metadataChanged, err) + } + info = fingerprintTestDriverInfo() + info.AdbcInfo.Features.Supported = nil + nilFeatures, err := runtimeRegistrationFingerprint(cfg, "driver", "linux_amd64", info, "driver.so") + if err != nil { + t.Fatal(err) + } + info.AdbcInfo.Features.Supported = []string{} + emptyFeatures, err := runtimeRegistrationFingerprint(cfg, "driver", "linux_amd64", info, "driver.so") + if err != nil || emptyFeatures != nilFeatures { + t.Fatalf("nil and empty feature sets differ: %q, %q, %v", nilFeatures, emptyFeatures, err) + } + info.AdbcInfo.Features.Supported = []string{"alpha", "alpha"} + duplicateFeatures, err := runtimeRegistrationFingerprint(cfg, "driver", "linux_amd64", info, "driver.so") + if err != nil { + t.Fatal(err) + } + info.AdbcInfo.Features.Supported = []string{"alpha"} + singleFeature, err := runtimeRegistrationFingerprint(cfg, "driver", "linux_amd64", info, "driver.so") + if err != nil || duplicateFeatures != singleFeature { + t.Fatalf("feature deduplication behavior = %q, %v", duplicateFeatures, err) + } + info.AdbcInfo.Features.Supported = nil + otherOwnedFile, err := runtimeRegistrationFingerprint(cfg, "driver", "linux_amd64", info, "other.so") + if err != nil || otherOwnedFile == nilFeatures { + t.Fatalf("owned filename did not change fingerprint: %q, %v", otherOwnedFile, err) + } + info = fingerprintTestDriverInfo() + canonicalVersion, err := runtimeRegistrationFingerprint(cfg, "driver", "linux_amd64", info, "driver.so") + if err != nil { + t.Fatal(err) + } + info.Version = semver.MustParse("1.2.3+build.4") + spelledVersion, err := runtimeRegistrationFingerprint(cfg, "driver", "linux_amd64", info, "driver.so") + if err != nil || spelledVersion != canonicalVersion { + t.Fatalf("canonical semver spellings differ: %q, %q, %v", canonicalVersion, spelledVersion, err) + } +} + +func TestReceiptReaderRejectsMalformedEvidence(t *testing.T) { + root := t.TempDir() + generation := testPackageGenerationPath(t, root, "driver", "generation") + if err := os.Mkdir(generation, 0o700); err != nil { + t.Fatal(err) + } + valid := packageInstallReceipt{ + SchemaVersion: packageInstallReceiptVersion, + RegistrationScope: packageRegistrationFile, + RuntimeID: "driver", + DriverVersion: "1.0.0", + Platform: "linux_amd64", + Generation: filepath.Base(generation), + OwnedLibraryFilename: "driver.so", + OwnedLibrarySHA256: strings.Repeat("b", sha256.Size*2), + RegistrationFingerprintAlgo: registrationFingerprintName, + RegistrationFingerprintVer: registrationFingerprintVer, + RegistrationFingerprintValue: strings.Repeat("a", sha256.Size*2), + } + write := func(receipt packageInstallReceipt) { + t.Helper() + data, err := json.Marshal(receipt) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(generation, packageInstallReceiptFilename), data, 0o600); err != nil { + t.Fatal(err) + } + } + write(valid) + if _, ok := readPackageInstallReceipt(root, generation); !ok { + t.Fatal("valid receipt was rejected") + } + invalid := valid + invalid.OwnedLibraryFilename = "../driver.so" + write(invalid) + if _, ok := readPackageInstallReceipt(root, generation); ok { + t.Fatal("receipt with an unsafe owned filename was accepted") + } + invalid = valid + invalid.RegistrationScope = "unknown" + write(invalid) + if _, ok := readPackageInstallReceipt(root, generation); ok { + t.Fatal("receipt with unknown registration scope was accepted") + } + invalid = valid + invalid.Generation = filepath.Base(testPackageGenerationPath(t, root, "driver-other", "generation")) + write(invalid) + if _, ok := readPackageInstallReceipt(root, generation); ok { + t.Fatal("receipt generation with mismatched runtime ID was accepted") + } + mismatchedGeneration := testPackageGenerationPath(t, root, "driver-other", "generation") + if err := os.Mkdir(mismatchedGeneration, 0o700); err != nil { + t.Fatal(err) + } + invalid = valid + invalid.Generation = filepath.Base(mismatchedGeneration) + data, err := json.Marshal(invalid) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(mismatchedGeneration, packageInstallReceiptFilename), data, 0o600); err != nil { + t.Fatal(err) + } + if _, ok := readPackageInstallReceipt(root, mismatchedGeneration); ok { + t.Fatal("receipt runtime ID did not match parsed generation ID") + } + noncanonicalGeneration := filepath.Join(root, ".dbc-package-g-06-driver-generation") + if err := os.Mkdir(noncanonicalGeneration, 0o700); err != nil { + t.Fatal(err) + } + invalid = valid + invalid.Generation = filepath.Base(noncanonicalGeneration) + data, err = json.Marshal(invalid) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(noncanonicalGeneration, packageInstallReceiptFilename), data, 0o600); err != nil { + t.Fatal(err) + } + if _, ok := readPackageInstallReceipt(root, noncanonicalGeneration); ok { + t.Fatal("receipt with noncanonical generation length was accepted") + } + if _, ok := readPackageInstallReceipt(root, filepath.Join(root, "nested", filepath.Base(generation))); ok { + t.Fatal("receipt outside direct primary-root child was accepted") + } + if err := os.Remove(filepath.Join(generation, packageInstallReceiptFilename)); err != nil { + t.Fatal(err) + } + if _, ok := readPackageInstallReceipt(root, generation); ok { + t.Fatal("missing receipt was accepted") + } +} + +func TestPackageGenerationNameParsingAndTransactionEvidence(t *testing.T) { + for _, runtimeID := range []string{"driver", "driver-other", "stage", "réader"} { + prefix, err := packageGenerationPrefix(runtimeID) + if err != nil { + t.Fatal(err) + } + name := prefix + "random" + parsed, ok := parsePackageGenerationName(name) + if !ok || parsed != runtimeID { + t.Fatalf("parse generation %q = %q, %t", name, parsed, ok) + } + } + for _, name := range []string{ + ".dbc-package-g-06-driver-random", + ".dbc-package-g-6-driver", + ".dbc-package-g-6-drive-random", + ".dbc-package-g-6-réader-random", + ".dbc-package-stage-random", + ".dbc-package-driver-random", + } { + if runtimeID, ok := parsePackageGenerationName(name); ok { + t.Errorf("malformed generation %q parsed as %q", name, runtimeID) + } + } + + root := t.TempDir() + driverGeneration := testPackageGenerationPath(t, root, "driver", "evidence") + driverOtherGeneration := testPackageGenerationPath(t, root, "driver-other", "evidence") + for _, generation := range []string{driverGeneration, driverOtherGeneration} { + if err := os.Mkdir(generation, 0o700); err != nil { + t.Fatal(err) + } + } + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + if !hasTransactionEvidence(entries, "driver") || !hasTransactionEvidence(entries, "driver-other") { + t.Fatal("transaction evidence was not matched to its runtime ID") + } + otherRoot := t.TempDir() + if err := os.Mkdir(testPackageGenerationPath(t, otherRoot, "driver-other", "evidence"), 0o700); err != nil { + t.Fatal(err) + } + otherEntries, err := os.ReadDir(otherRoot) + if err != nil { + t.Fatal(err) + } + if hasTransactionEvidence(otherEntries, "driver") { + t.Fatal("driver-other transaction evidence matched driver") + } + driverRoot := t.TempDir() + if err := os.Mkdir(testPackageGenerationPath(t, driverRoot, "driver", "evidence"), 0o700); err != nil { + t.Fatal(err) + } + driverEntries, err := os.ReadDir(driverRoot) + if err != nil { + t.Fatal(err) + } + if hasTransactionEvidence(driverEntries, "driver-other") { + t.Fatal("driver transaction evidence matched driver-other") + } +} + +func TestReceiptReaderRejectsUnknownSchemaAndFields(t *testing.T) { + root := t.TempDir() + generation := testPackageGenerationPath(t, root, "driver", "generation") + if err := os.Mkdir(generation, 0o700); err != nil { + t.Fatal(err) + } + valid := packageInstallReceipt{ + SchemaVersion: packageInstallReceiptVersion, + RegistrationScope: packageRegistrationFile, + RuntimeID: "driver", + DriverVersion: "1.0.0", + Platform: "linux_amd64", + Generation: filepath.Base(generation), + OwnedLibraryFilename: "driver.so", + OwnedLibrarySHA256: strings.Repeat("b", sha256.Size*2), + RegistrationFingerprintAlgo: registrationFingerprintName, + RegistrationFingerprintVer: registrationFingerprintVer, + RegistrationFingerprintValue: strings.Repeat("b", sha256.Size*2), + } + encoded, err := json.Marshal(valid) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(generation, packageInstallReceiptFilename) + write := func(data []byte) { + t.Helper() + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(generation, packageInstallReceiptFilename), encoded, 0o600); err != nil { + t.Fatal(err) + } + unknownSchema := valid + unknownSchema.SchemaVersion++ + data, err := json.Marshal(unknownSchema) + if err != nil { + t.Fatal(err) + } + write(data) + if _, ok := readPackageInstallReceipt(root, generation); ok { + t.Fatal("unknown receipt schema was accepted") + } + unknownFingerprintVersion := valid + unknownFingerprintVersion.RegistrationFingerprintVer++ + data, err = json.Marshal(unknownFingerprintVersion) + if err != nil { + t.Fatal(err) + } + write(data) + if _, ok := readPackageInstallReceipt(root, generation); ok { + t.Fatal("unknown registration fingerprint version was accepted") + } + write([]byte(strings.TrimSuffix(string(encoded), "}") + `,"unrecognized":true}`)) + if _, ok := readPackageInstallReceipt(root, generation); ok { + t.Fatal("receipt with unknown field was accepted") + } + write(append(append([]byte(nil), encoded...), []byte(" trailing data")...)) + if _, ok := readPackageInstallReceipt(root, generation); ok { + t.Fatal("receipt with trailing data was accepted") + } + badGeneration := valid + badGeneration.Generation = filepath.Base(testPackageGenerationPath(t, root, "driver", "other")) + data, err = json.Marshal(badGeneration) + if err != nil { + t.Fatal(err) + } + write(data) + if _, ok := readPackageInstallReceipt(root, generation); ok { + t.Fatal("receipt with mismatched generation was accepted") + } +} + +func TestReceiptReaderRejectsOversizedTrailingData(t *testing.T) { + root := t.TempDir() + generation := testPackageGenerationPath(t, root, "driver", "generation") + if err := os.Mkdir(generation, 0o700); err != nil { + t.Fatal(err) + } + valid := packageInstallReceipt{ + SchemaVersion: packageInstallReceiptVersion, + RegistrationScope: packageRegistrationFile, + RuntimeID: "driver", + DriverVersion: "1.0.0", + Platform: "linux_amd64", + Generation: filepath.Base(generation), + OwnedLibraryFilename: "driver.so", + OwnedLibrarySHA256: strings.Repeat("c", sha256.Size*2), + RegistrationFingerprintAlgo: registrationFingerprintName, + RegistrationFingerprintVer: registrationFingerprintVer, + RegistrationFingerprintValue: strings.Repeat("c", sha256.Size*2), + } + encoded, err := json.Marshal(valid) + if err != nil { + t.Fatal(err) + } + data := append([]byte(nil), encoded...) + data = append(data, bytes.Repeat([]byte(" "), packageInstallReceiptMaxSize-len(data))...) + data = append(data, '!') + path := filepath.Join(generation, packageInstallReceiptFilename) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + if _, ok := readPackageInstallReceipt(root, generation); ok { + t.Fatal("receipt with data beyond the size limit was accepted") + } +} + +func TestReceiptReaderRejectsSymlinkedEvidence(t *testing.T) { + root := t.TempDir() + actualGeneration := testPackageGenerationPath(t, t.TempDir(), "driver", "actual") + if err := os.Mkdir(actualGeneration, 0o700); err != nil { + t.Fatal(err) + } + receipt := packageInstallReceipt{ + SchemaVersion: packageInstallReceiptVersion, + RegistrationScope: packageRegistrationFile, + RuntimeID: "driver", + DriverVersion: "1.0.0", + Platform: "linux_amd64", + Generation: filepath.Base(actualGeneration), + OwnedLibraryFilename: "driver.so", + OwnedLibrarySHA256: strings.Repeat("d", sha256.Size*2), + RegistrationFingerprintAlgo: registrationFingerprintName, + RegistrationFingerprintVer: registrationFingerprintVer, + RegistrationFingerprintValue: strings.Repeat("d", sha256.Size*2), + } + data, err := json.Marshal(receipt) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(actualGeneration, packageInstallReceiptFilename), data, 0o600); err != nil { + t.Fatal(err) + } + receiptLinkGeneration := testPackageGenerationPath(t, root, "driver", "receipt-link") + if err := os.Mkdir(receiptLinkGeneration, 0o700); err != nil { + t.Fatal(err) + } + receipt.Generation = filepath.Base(receiptLinkGeneration) + data, err = json.Marshal(receipt) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(actualGeneration, packageInstallReceiptFilename), data, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(actualGeneration, packageInstallReceiptFilename), filepath.Join(receiptLinkGeneration, packageInstallReceiptFilename)); err != nil { + t.Skipf("symlink creation is unavailable: %v", err) + } + if _, ok := readPackageInstallReceipt(root, receiptLinkGeneration); ok { + t.Fatal("symlinked receipt was accepted") + } + generationLink := testPackageGenerationPath(t, root, "driver", "generation-link") + if err := os.Symlink(actualGeneration, generationLink); err != nil { + t.Skipf("directory symlink creation is unavailable: %v", err) + } + receipt.Generation = filepath.Base(generationLink) + data, err = json.Marshal(receipt) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(actualGeneration, packageInstallReceiptFilename), data, 0o600); err != nil { + t.Fatal(err) + } + if _, ok := readPackageInstallReceipt(root, generationLink); ok { + t.Fatal("symlinked generation directory was accepted") + } +} + +func fingerprintTestDriverInfo() DriverInfo { + info := DriverInfo{ + ID: "driver", + Name: "Driver", + Publisher: "Publisher", + License: "Apache-2.0", + Source: "dbc", + Version: semver.MustParse("v1.2.3+build.4"), + Driver: struct { + Entrypoint string + Shared driverMap + }{Entrypoint: "connect", Shared: driverMap{}}, + } + info.AdbcInfo.Version = semver.MustParse("1.0.0") + return info +} diff --git a/config/registration_namespace.go b/config/registration_namespace.go new file mode 100644 index 00000000..d10102c5 --- /dev/null +++ b/config/registration_namespace.go @@ -0,0 +1,279 @@ +// 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. + +package config + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/columnar-tech/dbc/internal/hostpath" +) + +func fileRegistrationNamespaceIdentity(path string, windows bool) string { + if windows { + path = strings.ToLower(path) + } + return "file:" + path +} + +func fileRegistrationNamespaceLockSpec(location string) (identity, lockDirectory, registrationLocation string, err error) { + if location == "" { + return "", "", "", errors.New("registration namespace location is empty") + } + absolute, err := hostpath.Abs(location) + if err != nil { + return "", "", "", fmt.Errorf("resolve registration namespace location: %w", err) + } + resolved, err := hostpath.EvalSymlinks(hostpath.Clean(absolute)) + if err != nil { + return "", "", "", fmt.Errorf("resolve registration namespace symlinks: %w", err) + } + info, err := os.Stat(resolved) + if err != nil { + return "", "", "", fmt.Errorf("inspect registration namespace: %w", err) + } + if !info.IsDir() { + return "", "", "", fmt.Errorf("registration namespace %s is not a directory", resolved) + } + resolved = hostpath.Clean(resolved) + return fileRegistrationNamespaceIdentity(resolved, hostpath.IsWindows()), resolved, resolved, nil +} + +func registrationNamespaceLockFilename(identity string) string { + if strings.HasPrefix(identity, "registry-") { + key := sha256.Sum256([]byte(identity)) + return ".dbc.namespace." + hex.EncodeToString(key[:]) + ".lock" + } + return ".dbc.namespace.lock" +} + +// acquireRegistrationNamespaceLock is always called after the driver lock. +// The lock directory and identity are platform-specific because Windows +// registry scopes share registrations independently of package payload roots. +func acquireRegistrationNamespaceLock(ctx context.Context, cfg Config, location string, timeout time.Duration) (*driverInstallLock, string, error) { + identity, lockDirectory, registrationLocation, err := registrationNamespaceLockSpec(cfg, location) + if err != nil { + return nil, "", err + } + canonicalDirectory, err := hostpath.Abs(lockDirectory) + if err != nil { + return nil, "", fmt.Errorf("resolve registration namespace lock directory: %w", err) + } + canonicalDirectory = hostpath.Clean(canonicalDirectory) + if identity == "" { + return nil, "", errors.New("registration namespace identity is empty") + } + // The root-local fixed name lets the filesystem resolve case and Unicode + // aliases according to its own identity rules. + lockPath := hostpath.Join(canonicalDirectory, registrationNamespaceLockFilename(identity)) + lock, err := acquireDriverInstallLock(ctx, lockPath, timeout) + if err != nil { + return nil, "", fmt.Errorf("acquire registration namespace lock: %w", err) + } + return lock, registrationLocation, nil +} + +// collectFileRegistrationSharedMaps returns complete=false if any registration +// cannot be read with confidence. Callers must skip package cleanup in that +// case while continuing the requested registration mutation. +func collectFileRegistrationSharedMaps(location, excludedID string) ([]driverMap, bool, error) { + return collectFileRegistrationSharedMapsExcluding(location, "", excludedID) +} + +func collectFileRegistrationSharedMapsExcluding(location, excludedRoot, excludedID string) ([]driverMap, bool, error) { + entries, err := readDirectoryEntries(location) + if errors.Is(err, os.ErrNotExist) { + return nil, true, nil + } + if err != nil { + return nil, false, fmt.Errorf("read registration namespace %s: %w", location, err) + } + var shared []driverMap + for _, entry := range entries { + name := entry.Name() + extension := filepath.Ext(name) + if !strings.EqualFold(extension, ".toml") { + continue + } + id := strings.TrimSuffix(name, extension) + if sameRuntimeID(id, excludedID) && (excludedRoot == "" || sameResolvedFilesystemPath(location, excludedRoot)) { + continue + } + path := hostpath.Join(location, name) + manifest, err := readRegistrationManifest(path, id) + if err != nil { + return nil, false, err + } + resolvedShared, err := sharedPathsFromRegistrationRoot(location, manifest.DriverInfo.Driver.Shared) + if err != nil { + return nil, false, fmt.Errorf("resolve shared paths in registration %s: %w", path, err) + } + shared = append(shared, resolvedShared) + } + return shared, true, nil +} + +func collectRegistrationSharedMaps(cfg Config, registrationLocation, excludedID string) ([]driverMap, bool, error) { + return collectRegistrationSharedMapsExcluding(cfg, registrationLocation, "", excludedID) +} + +func collectRegistrationSharedMapsExcluding(cfg Config, registrationLocation, excludedRoot, excludedID string) ([]driverMap, bool, error) { + if cfg.Level != ConfigEnv { + return collectScopedRegistrationSharedMaps(cfg, registrationLocation, excludedID) + } + + // ConfigEnv can select a registration from any configured root. Scan them + // read-only without secondary locks; a missing root is empty and any other + // incomplete scan makes the reference set uncertain. This is not a consistent + // snapshot, so a concurrent reference in a secondary root can race cleanup. + roots := configuredRegistrationRoots(cfg.Location, registrationLocation) + var shared []driverMap + for _, root := range roots { + info, err := os.Stat(root) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return nil, false, fmt.Errorf("inspect registration root %s: %w", root, err) + } + if !info.IsDir() { + return nil, false, fmt.Errorf("inspect registration root %s: not a directory", root) + } + maps, certain, err := collectFileRegistrationSharedMapsExcluding(root, excludedRoot, excludedID) + if err != nil || !certain { + return nil, false, err + } + shared = append(shared, maps...) + } + return shared, true, nil +} + +func configuredRegistrationRoots(configured, registrationLocation string) []string { + var roots []string + seen := make(map[string]struct{}) + for _, candidate := range append(splitConfigList(configured), registrationLocation) { + if candidate == "" { + continue + } + canonical, err := hostpath.Abs(hostpath.Clean(candidate)) + if err != nil { + canonical = hostpath.Clean(candidate) + } + if resolved, err := hostpath.EvalSymlinks(canonical); err == nil { + canonical = hostpath.Clean(resolved) + } + if hostpath.IsWindows() { + canonical = strings.ToLower(canonical) + } + if _, ok := seen[canonical]; ok { + continue + } + seen[canonical] = struct{}{} + roots = append(roots, candidate) + } + return roots +} + +func sharedPathsFromRegistrationRoot(root string, shared driverMap) (driverMap, error) { + resolved := driverMap{defaultPath: shared.defaultPath} + if resolved.defaultPath != "" { + path, err := resolveRegistrationSharedPath(root, resolved.defaultPath) + if err != nil { + return driverMap{}, err + } + resolved.defaultPath = path + } + if len(shared.platformMap) > 0 { + resolved.platformMap = make(map[string]string, len(shared.platformMap)) + for platform, sharedPath := range shared.platformMap { + path, err := resolveRegistrationSharedPath(root, sharedPath) + if err != nil { + return driverMap{}, err + } + resolved.platformMap[platform] = path + } + } + return resolved, nil +} + +func resolveRegistrationSharedPath(root, sharedPath string) (string, error) { + if sharedPath == "" { + return "", nil + } + if hasParentTraversal(sharedPath) { + return "", errors.New("shared path contains parent traversal") + } + if !hostpath.IsAbs(sharedPath) { + sharedPath = hostpath.Join(root, sharedPath) + } + resolved, err := hostpath.Abs(hostpath.Clean(sharedPath)) + if err != nil { + return "", err + } + return resolved, nil +} + +// readRegistrationManifest reads a regular manifest file and verifies that it +// is the file that was inspected and that its ID matches id. +func readRegistrationManifest(path, id string) (Manifest, error) { + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() { + if err == nil { + err = fmt.Errorf("registration is not a regular file") + } + return Manifest{}, fmt.Errorf("inspect registration %s: %w", path, err) + } + file, err := os.Open(path) + if err != nil { + return Manifest{}, fmt.Errorf("open registration %s: %w", path, err) + } + openedInfo, statErr := file.Stat() + if statErr != nil || !openedInfo.Mode().IsRegular() || !os.SameFile(info, openedInfo) { + closeErr := file.Close() + if statErr == nil { + statErr = errors.New("registration changed while being read") + } + return Manifest{}, errors.Join(fmt.Errorf("verify registration %s: %w", path, statErr), closeErr) + } + manifest, decodeErr := decodeManifest(file, id, true) + closeErr := file.Close() + if decodeErr != nil || closeErr != nil || manifest.DriverInfo.ID != id { + cause := errors.Join(decodeErr, closeErr) + if cause == nil { + cause = errors.New("registration identity does not match its filename") + } + return Manifest{}, fmt.Errorf("decode registration %s: %w", path, cause) + } + return manifest, nil +} + +func readFileRuntimeRegistration(location, runtimeID string) (DriverInfo, bool) { + if validatePackageFilename(runtimeID) != nil { + return DriverInfo{}, false + } + manifest, err := readRegistrationManifest(hostpath.Join(location, runtimeID+".toml"), runtimeID) + if err != nil { + return DriverInfo{}, false + } + manifest.DriverInfo.FilePath = location + return manifest.DriverInfo, true +} diff --git a/config/registration_namespace_cross_root_test.go b/config/registration_namespace_cross_root_test.go new file mode 100644 index 00000000..60b4b9b3 --- /dev/null +++ b/config/registration_namespace_cross_root_test.go @@ -0,0 +1,328 @@ +// 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. + +package config + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/Masterminds/semver/v3" +) + +func TestCollectRegistrationSharedMapsAcrossConfigEnvRoots(t *testing.T) { + primary := t.TempDir() + secondary := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: primary + string(filepath.ListSeparator) + secondary} + + owner := crossRootRegistration("driver", primary, filepath.Join(primary, "generation", "driver.so")) + if err := createRuntimeRegistrationUnlocked(Config{Level: ConfigEnv, Location: primary}, primary, owner); err != nil { + t.Fatal(err) + } + borrower := crossRootRegistration("driver", secondary, filepath.Join("relative", "borrowed.so")) + if err := createRuntimeRegistrationUnlocked(Config{Level: ConfigEnv, Location: secondary}, secondary, borrower); err != nil { + t.Fatal(err) + } + + shared, certain, err := collectRegistrationSharedMapsExcluding(cfg, primary, primary, "driver") + if err != nil || !certain { + t.Fatalf("collect references = %v, certain=%t, err=%v", shared, certain, err) + } + if len(shared) != 1 { + t.Fatalf("collected %d registrations, want lower-priority same-ID registration", len(shared)) + } + want := filepath.Join(secondary, "relative", "borrowed.so") + if got := shared[0].Get(PlatformTuple()); got != want { + t.Fatalf("relative shared path = %q, want source-root path %q", got, want) + } +} + +func TestCollectRegistrationSharedMapsTreatsMissingAndUnreadableRootsConservatively(t *testing.T) { + primary := t.TempDir() + missing := filepath.Join(t.TempDir(), "not-created") + // Use one configured root per case. JS treats ConfigEnv.Location as a single + // path; the separate registrationLocation still adds the primary root scan. + cfg := Config{Level: ConfigEnv, Location: missing} + if _, certain, err := collectRegistrationSharedMaps(cfg, primary, ""); err != nil || !certain { + t.Fatalf("missing root should have no references: certain=%t, err=%v", certain, err) + } + + t.Run("non-directory root", func(t *testing.T) { + nonDirectory := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(nonDirectory, []byte("file"), 0o600); err != nil { + t.Fatal(err) + } + cfg.Location = nonDirectory + if _, certain, err := collectRegistrationSharedMaps(cfg, primary, ""); err == nil || certain { + t.Fatalf("non-directory root should make references uncertain: certain=%t, err=%v", certain, err) + } + }) + + t.Run("unreadable root", func(t *testing.T) { + unreadable := t.TempDir() + if err := os.WriteFile(filepath.Join(unreadable, "borrower.toml"), []byte("name = \"Borrower\"\nversion = \"1.0.0\"\n"), 0o600); err != nil { + t.Fatal(err) + } + info, err := os.Stat(unreadable) + if err != nil { + t.Fatal(err) + } + if err := os.Chmod(unreadable, info.Mode().Perm()&^0o777); err != nil { + t.Skipf("cannot remove directory permissions: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(unreadable, info.Mode().Perm()) }) + if _, err := os.ReadDir(unreadable); err == nil { + t.Skip("filesystem permissions do not prevent this process from reading the directory") + } + + cfg.Location = unreadable + if _, certain, err := collectRegistrationSharedMaps(cfg, primary, ""); err == nil || certain { + t.Fatalf("unreadable root should make references uncertain: certain=%t, err=%v", certain, err) + } + }) + + t.Run("malformed registration", func(t *testing.T) { + malformed := t.TempDir() + if err := os.WriteFile(filepath.Join(malformed, "broken.toml"), []byte("invalid = ["), 0o600); err != nil { + t.Fatal(err) + } + cfg.Location = malformed + if _, certain, err := collectRegistrationSharedMaps(cfg, primary, ""); err == nil || certain { + t.Fatalf("malformed registration should make references uncertain: certain=%t, err=%v", certain, err) + } + }) +} + +func TestCollectRegistrationSharedMapsExcludesSymlinkAliasOfTargetRoot(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("directory symlink creation is not generally available on Windows") + } + primary := t.TempDir() + alias := filepath.Join(t.TempDir(), "primary-alias") + if err := os.Symlink(primary, alias); err != nil { + t.Skipf("symlink creation is unavailable: %v", err) + } + owner := crossRootRegistration("driver", primary, filepath.Join(primary, "driver.so")) + if err := createRuntimeRegistrationUnlocked(Config{Level: ConfigEnv, Location: primary}, primary, owner); err != nil { + t.Fatal(err) + } + cfg := Config{Level: ConfigEnv, Location: alias} + shared, certain, err := collectRegistrationSharedMapsExcluding(cfg, primary, primary, owner.ID) + if err != nil || !certain { + t.Fatalf("collect references through symlink alias = %v, certain=%t, err=%v", shared, certain, err) + } + if len(shared) != 0 { + t.Fatalf("target registration was counted through its root alias: %v", shared) + } +} + +func TestCollectRegistrationSharedMapsSkipsUnreadableRoot(t *testing.T) { + if runtime.GOOS == "windows" || runtime.GOOS == "js" || runtime.GOOS == "plan9" { + t.Skip("this platform does not enforce the POSIX directory permission fixture") + } + primary := t.TempDir() + secondary := t.TempDir() + borrower := crossRootRegistration("borrower", secondary, filepath.Join(secondary, "shared.so")) + if err := createRuntimeRegistrationUnlocked(Config{Level: ConfigEnv, Location: secondary}, secondary, borrower); err != nil { + t.Fatal(err) + } + info, err := os.Stat(secondary) + if err != nil { + t.Fatal(err) + } + if err := os.Chmod(secondary, info.Mode().Perm()&^0o777); err != nil { + t.Skipf("cannot remove all directory permissions: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(secondary, info.Mode().Perm()) }) + + cfg := Config{Level: ConfigEnv, Location: primary + string(filepath.ListSeparator) + secondary} + _, certain, err := collectRegistrationSharedMaps(cfg, primary, "") + if err == nil && certain { + if _, readErr := os.ReadDir(secondary); readErr == nil { + t.Skip("filesystem permissions do not prevent this process from reading the directory") + } + t.Fatal("collector claimed certainty for a root that cannot be opened") + } + if certain { + t.Fatalf("unreadable root returned certain=true, err=%v", err) + } +} + +func TestUninstallProtectsExternalLibraryReferencedFromSecondaryRoot(t *testing.T) { + primary := t.TempDir() + secondary := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: primary + string(filepath.ListSeparator) + secondary} + shared := filepath.Join(primary, "shared.so") + if err := os.WriteFile(shared, []byte("shared library"), 0o600); err != nil { + t.Fatal(err) + } + owner := crossRootRegistration("owner", primary, shared) + if err := createRuntimeRegistrationUnlocked(Config{Level: ConfigEnv, Location: primary}, primary, owner); err != nil { + t.Fatal(err) + } + aliasDirectory := filepath.Join(secondary, "primary-alias") + if err := os.Symlink(filepath.Dir(shared), aliasDirectory); err != nil { + t.Skipf("symlink creation is unavailable: %v", err) + } + borrower := crossRootRegistration("borrower", secondary, filepath.Join("primary-alias", filepath.Base(shared))) + if err := createRuntimeRegistrationUnlocked(Config{Level: ConfigEnv, Location: secondary}, secondary, borrower); err != nil { + t.Fatal(err) + } + secondaryEntries := makeDirectoryReadOnly(t, secondary) + t.Setenv(adbcEnvVar, primary+string(filepath.ListSeparator)+secondary) + t.Setenv("VIRTUAL_ENV", "") + t.Setenv("CONDA_PREFIX", "") + selected, err := GetDriver(Config{Level: ConfigEnv, Location: primary}, owner.ID) + if err != nil { + t.Fatal(err) + } + if err := UninstallDriver(cfg, selected); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(shared); err != nil { + t.Fatalf("external shared library was removed despite secondary reference: %v", err) + } + if _, err := os.Stat(filepath.Join(primary, owner.ID+".toml")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("target registration remains after uninstall: %v", err) + } + assertDirectoryEntriesUnchanged(t, secondary, secondaryEntries) +} + +func TestInstallPackageGCProtectsGenerationReferencedFromSecondaryRoot(t *testing.T) { + primary := t.TempDir() + secondary := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: primary + string(filepath.ListSeparator) + secondary} + installInitialPackage(t, Config{Level: ConfigEnv, Location: primary}) + owner, err := GetDriver(Config{Level: ConfigEnv, Location: primary}, "driver") + if err != nil { + t.Fatal(err) + } + oldLibrary := owner.Driver.Shared.Get(PlatformTuple()) + aliasDirectory := filepath.Join(secondary, "primary-generation-alias") + if err := os.Symlink(filepath.Dir(oldLibrary), aliasDirectory); err != nil { + t.Skipf("symlink creation is unavailable: %v", err) + } + borrower := crossRootRegistration("borrower", secondary, filepath.Join("primary-generation-alias", filepath.Base(oldLibrary))) + if err := createRuntimeRegistrationUnlocked(Config{Level: ConfigEnv, Location: secondary}, secondary, borrower); err != nil { + t.Fatal(err) + } + secondaryEntries := makeDirectoryReadOnly(t, secondary) + archive := testPackageArchive(t, "new") + if _, err := InstallPackage(context.Background(), cfg, "driver", archive, InstallPackageOptions{}); err != nil { + t.Fatal(err) + } + assertArchiveClosed(t, archive) + if _, err := os.Stat(oldLibrary); err != nil { + t.Fatalf("cross-root referenced generation was collected: %v", err) + } + assertDirectoryEntriesUnchanged(t, secondary, secondaryEntries) +} + +func TestUninstallCountsLowerPriorityRegistrationWithSameID(t *testing.T) { + primary := t.TempDir() + secondary := t.TempDir() + primaryCfg := Config{Level: ConfigEnv, Location: primary} + cfg := Config{Level: ConfigEnv, Location: primary + string(filepath.ListSeparator) + secondary} + installInitialPackage(t, primaryCfg) + owner, err := GetDriver(primaryCfg, "driver") + if err != nil { + t.Fatal(err) + } + ownedLibrary := owner.Driver.Shared.Get(PlatformTuple()) + aliasDirectory := filepath.Join(secondary, "primary-generation-alias") + if err := os.Symlink(filepath.Dir(ownedLibrary), aliasDirectory); err != nil { + t.Skipf("symlink creation is unavailable: %v", err) + } + borrower := crossRootRegistration("driver", secondary, filepath.Join("primary-generation-alias", filepath.Base(ownedLibrary))) + if err := createRuntimeRegistrationUnlocked(Config{Level: ConfigEnv, Location: secondary}, secondary, borrower); err != nil { + t.Fatal(err) + } + + selected, err := GetDriver(cfg, "driver") + if err != nil { + t.Fatal(err) + } + if selected.FilePath != primary { + t.Fatalf("selected registration root = %q, want primary root %q", selected.FilePath, primary) + } + if err := UninstallDriver(cfg, selected); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(ownedLibrary); err != nil { + t.Fatalf("lower-priority same-ID registration lost its library: %v", err) + } + if _, err := os.Stat(filepath.Join(secondary, "driver.toml")); err != nil { + t.Fatalf("lower-priority registration was removed: %v", err) + } +} + +func crossRootRegistration(id, root, sharedPath string) DriverInfo { + info := DriverInfo{ID: id, FilePath: root, Name: "Cross-root test driver", Version: semver.MustParse("1.0.0"), Source: "external"} + info.Driver.Shared.Set(PlatformTuple(), sharedPath) + return info +} + +func makeDirectoryReadOnly(t *testing.T, path string) []string { + t.Helper() + if runtime.GOOS == "windows" || runtime.GOOS == "js" || runtime.GOOS == "plan9" { + t.Skip("this platform does not support the POSIX read-only directory fixture") + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + names := directoryEntryNames(t, path) + if err := os.Chmod(path, info.Mode().Perm()&^0o222); err != nil { + t.Skipf("cannot make secondary root read-only: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(path, info.Mode().Perm()) }) + return names +} + +func assertDirectoryEntriesUnchanged(t *testing.T, path string, want []string) { + t.Helper() + got := directoryEntryNames(t, path) + if len(got) != len(want) { + t.Fatalf("secondary root entries = %v, want unchanged entries %v", got, want) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("secondary root entries = %v, want unchanged entries %v", got, want) + } + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm()&0o222 != 0 { + t.Fatalf("secondary root permissions became writable: %#o", info.Mode().Perm()) + } +} + +func directoryEntryNames(t *testing.T, path string) []string { + t.Helper() + entries, err := os.ReadDir(path) + if err != nil { + t.Fatal(err) + } + names := make([]string, len(entries)) + for i, entry := range entries { + names[i] = entry.Name() + } + return names +} diff --git a/config/registration_namespace_test.go b/config/registration_namespace_test.go new file mode 100644 index 00000000..a66518e7 --- /dev/null +++ b/config/registration_namespace_test.go @@ -0,0 +1,41 @@ +// 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. + +package config + +import "testing" + +func TestFileRegistrationNamespaceIdentityCaseFolding(t *testing.T) { + path := `C:/Program Files/ADBC/Drivers` + if got, want := fileRegistrationNamespaceIdentity(path, true), "file:c:/program files/adbc/drivers"; got != want { + t.Fatalf("Windows namespace identity = %q, want %q", got, want) + } + if got, want := fileRegistrationNamespaceIdentity(path, false), "file:"+path; got != want { + t.Fatalf("case-sensitive namespace identity = %q, want %q", got, want) + } +} + +func TestRegistrationNamespaceLockFilename(t *testing.T) { + if got := registrationNamespaceLockFilename("file:/drivers/one"); got != ".dbc.namespace.lock" { + t.Fatalf("file namespace lock filename = %q", got) + } + if got := registrationNamespaceLockFilename("file:/drivers/two"); got != ".dbc.namespace.lock" { + t.Fatalf("second file namespace lock filename = %q", got) + } + user := registrationNamespaceLockFilename("registry-user:HKCU\\SOFTWARE\\ADBC\\Drivers") + system := registrationNamespaceLockFilename("registry-system:HKLM\\SOFTWARE\\ADBC\\Drivers") + if user == ".dbc.namespace.lock" || system == ".dbc.namespace.lock" || user == system { + t.Fatalf("registry namespace lock filenames should remain distinct hashes: %q, %q", user, system) + } +} diff --git a/config/registration_values.go b/config/registration_values.go new file mode 100644 index 00000000..4e366a62 --- /dev/null +++ b/config/registration_values.go @@ -0,0 +1,138 @@ +// 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. + +package config + +import ( + "errors" + "fmt" +) + +var errRegistrationRollbackFailed = errors.New("driver registration rollback failed") + +type registrationRollbackError struct { + primary error + rollback error +} + +func (e *registrationRollbackError) Error() string { + return fmt.Sprintf("%v; rollback failed: %v", e.primary, e.rollback) +} + +func (e *registrationRollbackError) Unwrap() []error { + return []error{e.primary, e.rollback, errRegistrationRollbackFailed} +} + +func combineRegistrationRollbackErrors(primary, rollback error) error { + if rollback == nil { + return primary + } + return ®istrationRollbackError{primary: primary, rollback: rollback} +} + +type registrationValue struct { + exists bool + kind uint32 + data []byte +} + +type registrationValueStore interface { + read(string) (registrationValue, error) + write(string, registrationValue) error + delete(string) error +} + +type registrationValueChange struct { + name string + value registrationValue +} + +func registryKeyWasCreated(openedExisting bool) bool { + return !openedExisting +} + +func rollbackCreatedRegistryKeys(primary error, cleanup ...func() error) error { + return combineRegistrationRollbackErrors(primary, cleanupRegistryKeys(cleanup...)) +} + +func cleanupRegistryKeys(cleanup ...func() error) error { + var cleanupErr error + for _, cleanupKey := range cleanup { + if err := cleanupKey(); err != nil { + cleanupErr = errors.Join(cleanupErr, fmt.Errorf("clean up created registry key: %w", err)) + } + } + return cleanupErr +} + +func updateRegistrationValues(store registrationValueStore, changes []registrationValueChange) error { + return updateRegistrationValuesWithRollback(store, changes, nil) +} + +func updateRegistrationValuesWithRollback(store registrationValueStore, changes []registrationValueChange, rollbackNewKey func() error) error { + previous := make([]registrationValue, len(changes)) + for i, change := range changes { + value, err := store.read(change.name) + if err != nil { + readErr := fmt.Errorf("read registration value %q: %w", change.name, err) + if rollbackNewKey != nil { + return combineRegistrationRollbackErrors(readErr, wrapRegistrationKeyRollback(rollbackNewKey())) + } + return readErr + } + value.data = append([]byte(nil), value.data...) + previous[i] = value + } + + for i, change := range changes { + var err error + if change.value.exists { + err = store.write(change.name, change.value) + } else { + err = store.delete(change.name) + } + if err == nil { + continue + } + + primaryErr := fmt.Errorf("write registration value %q: %w", change.name, err) + var rollbackErr error + for j := i; j >= 0; j-- { + var restoreErr error + if previous[j].exists { + restoreErr = store.write(changes[j].name, previous[j]) + } else { + restoreErr = store.delete(changes[j].name) + } + if restoreErr != nil { + rollbackErr = errors.Join(rollbackErr, fmt.Errorf("restore registration value %q: %w", changes[j].name, restoreErr)) + } + } + if rollbackNewKey != nil { + rollbackErr = errors.Join(rollbackErr, wrapRegistrationKeyRollback(rollbackNewKey())) + } + if rollbackErr != nil { + return combineRegistrationRollbackErrors(primaryErr, fmt.Errorf("rollback registration values: %w", rollbackErr)) + } + return primaryErr + } + return nil +} + +func wrapRegistrationKeyRollback(err error) error { + if err == nil { + return nil + } + return fmt.Errorf("rollback newly created registration key: %w", err) +} diff --git a/config/registration_values_test.go b/config/registration_values_test.go new file mode 100644 index 00000000..ead11cf2 --- /dev/null +++ b/config/registration_values_test.go @@ -0,0 +1,209 @@ +// 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. + +package config + +import ( + "errors" + "reflect" + "strings" + "testing" +) + +type memoryRegistrationStore struct { + values map[string]registrationValue + failWrite map[string]error + failDelete map[string]error + failRestore map[string]error + writeCalls map[string]int + deleteCalls map[string]int +} + +func (s *memoryRegistrationStore) read(name string) (registrationValue, error) { + value, ok := s.values[name] + value.exists = ok + return value, nil +} + +func (s *memoryRegistrationStore) write(name string, value registrationValue) error { + if s.writeCalls == nil { + s.writeCalls = make(map[string]int) + } + s.writeCalls[name]++ + s.values[name] = registrationValue{exists: true, kind: value.kind, data: append([]byte(nil), value.data...)} + if err := s.failWrite[name]; err != nil { + delete(s.failWrite, name) + return err + } + if s.writeCalls[name] > 1 && s.failRestore != nil { + return s.failRestore[name] + } + return nil +} + +func (s *memoryRegistrationStore) delete(name string) error { + if s.deleteCalls == nil { + s.deleteCalls = make(map[string]int) + } + s.deleteCalls[name]++ + delete(s.values, name) + if err := s.failDelete[name]; err != nil { + delete(s.failDelete, name) + return err + } + if s.deleteCalls[name] > 1 && s.failRestore != nil { + return s.failRestore[name] + } + return nil +} + +func TestUpdateRegistrationValuesRestoresRawValuesAfterEveryWriteFailure(t *testing.T) { + changes := []registrationValueChange{ + {name: "name", value: registrationValue{exists: true, kind: 1, data: []byte("new-name\x00")}}, + {name: "version", value: registrationValue{exists: true, kind: 4, data: []byte{9, 8, 7, 6}}}, + {name: "entrypoint", value: registrationValue{}}, + } + initial := map[string]registrationValue{ + "name": {exists: true, kind: 7, data: []byte{0xff, 0x00, 0x80}}, + "version": {exists: true, kind: 3, data: []byte{1, 2, 3}}, + "entrypoint": {exists: true, kind: 1, data: []byte("old-entry\x00")}, + } + + for _, tc := range []struct { + name string + failWrite bool + failDelete bool + }{ + {name: "name", failWrite: true}, + {name: "version", failWrite: true}, + {name: "entrypoint", failDelete: true}, + } { + t.Run(tc.name, func(t *testing.T) { + wantErr := errors.New("registry write failed") + store := &memoryRegistrationStore{ + values: cloneRegistrationValues(initial), + failWrite: make(map[string]error), + failDelete: make(map[string]error), + failRestore: make(map[string]error), + } + if tc.failWrite { + store.failWrite[tc.name] = wantErr + } + if tc.failDelete { + store.failDelete[tc.name] = wantErr + } + err := updateRegistrationValues(store, changes) + if !errors.Is(err, wantErr) { + t.Fatalf("update error = %v, want wrapped failure", err) + } + if strings.Contains(err.Error(), "rollback registration values") { + t.Fatalf("successful rollback was reported as failed: %v", err) + } + if errors.Is(err, errRegistrationRollbackFailed) { + t.Fatalf("successful rollback has rollback failure marker: %v", err) + } + if !reflect.DeepEqual(store.values, initial) { + t.Fatalf("values after rollback = %#v, want %#v", store.values, initial) + } + }) + } +} + +func TestRegistryKeyCreationDispositionAndCleanupErrors(t *testing.T) { + if !registryKeyWasCreated(false) || registryKeyWasCreated(true) { + t.Fatal("registry CreateKey disposition was interpreted incorrectly") + } + + primaryErr := errors.New("create child failed") + cleanupErr := errors.New("remove newly created parent failed") + cleaned := false + err := rollbackCreatedRegistryKeys(primaryErr, func() error { + cleaned = true + return cleanupErr + }) + if !cleaned || !errors.Is(err, primaryErr) || !errors.Is(err, cleanupErr) || !errors.Is(err, errRegistrationRollbackFailed) { + t.Fatalf("rollback error = %v, cleanup called = %v", err, cleaned) + } +} + +func TestUpdateRegistrationValuesReportsRollbackFailures(t *testing.T) { + writeErr := errors.New("registry write failed") + valueRollbackErr := errors.New("value rollback failed") + keyRollbackErr := errors.New("key rollback failed") + store := &memoryRegistrationStore{ + values: map[string]registrationValue{"name": {exists: true, kind: 7, data: []byte{1}}}, + failWrite: map[string]error{"name": writeErr}, + failRestore: map[string]error{"name": valueRollbackErr}, + } + keyRolledBack := false + err := updateRegistrationValuesWithRollback(store, []registrationValueChange{ + {name: "name", value: registrationValue{exists: true, kind: 1, data: []byte("new\x00")}}, + }, func() error { + keyRolledBack = true + return keyRollbackErr + }) + if !errors.Is(err, writeErr) || !errors.Is(err, valueRollbackErr) || !errors.Is(err, keyRollbackErr) || !errors.Is(err, errRegistrationRollbackFailed) { + t.Fatalf("update error = %v, want primary and rollback errors", err) + } + var typedErr *registrationRollbackError + if !errors.As(err, &typedErr) { + t.Fatalf("update error type = %T, want *registrationRollbackError", err) + } + if !keyRolledBack { + t.Fatal("new key rollback was not attempted") + } +} + +func TestUpdateRegistrationValuesSnapshotsBeforeWriting(t *testing.T) { + readErr := errors.New("registry read failed") + store := &failingReadRegistrationStore{ + memoryRegistrationStore: &memoryRegistrationStore{ + values: map[string]registrationValue{}, + }, + failName: "second", + failErr: readErr, + } + err := updateRegistrationValues(store, []registrationValueChange{ + {name: "first", value: registrationValue{exists: true, kind: 1, data: []byte("new\x00")}}, + {name: "second", value: registrationValue{exists: true, kind: 1, data: []byte("new\x00")}}, + }) + if !errors.Is(err, readErr) { + t.Fatalf("update error = %v, want read failure", err) + } + if len(store.values) != 0 { + t.Fatalf("writes occurred before all snapshots were read: %#v", store.values) + } +} + +type failingReadRegistrationStore struct { + *memoryRegistrationStore + failName string + failErr error +} + +func (s *failingReadRegistrationStore) read(name string) (registrationValue, error) { + if name == s.failName { + return registrationValue{}, s.failErr + } + return s.memoryRegistrationStore.read(name) +} + +func cloneRegistrationValues(values map[string]registrationValue) map[string]registrationValue { + cloned := make(map[string]registrationValue, len(values)) + for name, value := range values { + value.data = append([]byte(nil), value.data...) + cloned[name] = value + } + return cloned +} diff --git a/config/registration_windows_test.go b/config/registration_windows_test.go new file mode 100644 index 00000000..5bc1d43b --- /dev/null +++ b/config/registration_windows_test.go @@ -0,0 +1,282 @@ +// 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 ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc/internal/winregroot" + "golang.org/x/sys/windows/registry" +) + +func TestWindowsRegistrationStorePreservesRawValueTypeAndBytes(t *testing.T) { + key, closeKey := createWindowsRegistrationTestKey(t, "raw") + defer closeKey() + store := windowsRegistrationValueStore{key: key} + want := registrationValue{exists: true, kind: registry.BINARY, data: []byte{0xff, 0, 0x80, 0x7f}} + if err := store.write("raw", want); err != nil { + t.Fatal(err) + } + got, err := store.read("raw") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("raw value = %#v, want %#v", got, want) + } +} + +func TestWindowsRegistryReceiptFingerprintMatchesRoundTrip(t *testing.T) { + cfg := Config{Level: ConfigUser, Location: t.TempDir()} + var suffix [12]byte + if _, err := rand.Read(suffix[:]); err != nil { + t.Fatal(err) + } + id := "dbc-receipt-" + hex.EncodeToString(suffix[:]) + generation := testPackageGenerationPath(t, cfg.Location, id, "generation") + stage := t.TempDir() + if err := os.Mkdir(generation, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stage, "driver.dll"), []byte("library"), 0o600); err != nil { + t.Fatal(err) + } + driver := windowsRegistrationTestDriver() + driver.ID = id + driver.Version = semver.MustParse("1.2.3") + driver.AdbcInfo.Version = semver.MustParse("1.0.0") + driver.AdbcInfo.Features.Supported = []string{"transactions", "bulk_ingest"} + driver.AdbcInfo.Features.Unsupported = []string{"substrait"} + driver.Driver.Shared.Set(PlatformTuple(), filepath.Join(generation, "driver.dll")) + receipt, err := makePackageInstallReceipt(cfg, stage, filepath.Base(generation), id, PlatformTuple(), driver, "driver.dll") + if err != nil { + t.Fatal(err) + } + if receipt.RegistrationFingerprintValue == "" { + t.Fatal("receipt has no registration fingerprint") + } + if registrationFingerprintIncludesADBC(cfg) { + t.Fatal("registry fingerprint projection includes unpersisted ADBC metadata") + } + if err := createRuntimeRegistrationUnlocked(cfg, generation, driver); err != nil { + t.Fatal(err) + } + driversKey, err := registry.OpenKey(winregroot.UserRoot(), regKeyADBC, registry.ALL_ACCESS) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := registry.DeleteKey(driversKey, id); err != nil && !errors.Is(err, registry.ErrNotExist) { + t.Errorf("delete test registration: %v", err) + } + if err := driversKey.Close(); err != nil { + t.Errorf("close driver registry key: %v", err) + } + }() + loaded, err := GetDriver(cfg, strings.ToUpper(id)) + if err != nil { + t.Fatal(err) + } + if loaded.AdbcInfo.Version != nil || len(loaded.AdbcInfo.Features.Supported) != 0 || len(loaded.AdbcInfo.Features.Unsupported) != 0 { + t.Fatalf("registry unexpectedly persisted ADBC metadata: %+v", loaded.AdbcInfo) + } + fingerprint, err := runtimeRegistrationFingerprint(cfg, receipt.RuntimeID, receipt.Platform, loaded, receipt.OwnedLibraryFilename) + if err != nil { + t.Fatal(err) + } + if fingerprint != receipt.RegistrationFingerprintValue { + t.Fatalf("round-trip fingerprint = %s, receipt = %s", fingerprint, receipt.RegistrationFingerprintValue) + } +} + +func TestWindowsRegistryRegistrationRejectsEmbeddedNULBeforeWriting(t *testing.T) { + driver := windowsRegistrationTestDriver() + driver.Name = "bad\x00name" + if _, err := registryRegistrationChanges(driver); err == nil { + t.Fatal("registry registration accepted an embedded NUL") + } +} + +func TestWindowsRegistryKeyHandleClosesOnlyOnce(t *testing.T) { + closeCount := 0 + handle := ®istryKeyHandle{closeFn: func() error { + closeCount++ + return nil + }} + if err := handle.close(); err != nil { + t.Fatal(err) + } + if err := handle.close(); err != nil { + t.Fatalf("second close should be a no-op: %v", err) + } + if !handle.closed || closeCount != 1 { + t.Fatalf("closed = %v, close count = %d; want true and 1", handle.closed, closeCount) + } +} + +func TestWindowsRegistrationUpdateRestoresRawValuesAndDeletesOptionalEntryPoint(t *testing.T) { + key, closeKey := createWindowsRegistrationTestKey(t, "existing") + defer closeKey() + store := windowsRegistrationValueStore{key: key} + oldVersion := registrationValue{exists: true, kind: registry.BINARY, data: []byte{0, 0xff, 4, 9}} + oldEntrypoint := registrationValue{exists: true, kind: registry.EXPAND_SZ, data: []byte{0x41, 0, 0, 0, 0xff}} + if err := store.write("version", oldVersion); err != nil { + t.Fatal(err) + } + if err := store.write("entrypoint", oldEntrypoint); err != nil { + t.Fatal(err) + } + + driver := windowsRegistrationTestDriver() + changes, err := registryRegistrationChanges(driver) + if err != nil { + t.Fatal(err) + } + wantErr := errors.New("injected registry write failure") + failing := &failingWindowsRegistrationStore{inner: store, name: "driver", err: wantErr} + if err := updateRegistrationValues(failing, changes); !errors.Is(err, wantErr) { + t.Fatalf("registration update error = %v, want injected failure", err) + } + gotVersion, err := store.read("version") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(gotVersion, oldVersion) { + t.Fatalf("version after rollback = %#v, want raw prior value %#v", gotVersion, oldVersion) + } + gotEntrypoint, err := store.read("entrypoint") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(gotEntrypoint, oldEntrypoint) { + t.Fatalf("entrypoint after rollback = %#v, want %#v", gotEntrypoint, oldEntrypoint) + } + + if err := updateRegistrationValues(store, changes); err != nil { + t.Fatal(err) + } + gotEntrypoint, err = store.read("entrypoint") + if err != nil { + t.Fatal(err) + } + if gotEntrypoint.exists { + t.Fatalf("optional entrypoint remains after update: %#v", gotEntrypoint) + } +} + +func TestWindowsRegistrationUpdateDeletesNewDriverKeyAfterFailure(t *testing.T) { + parent, closeParent := createWindowsRegistrationTestKey(t, "parent") + defer closeParent() + key, openedExisting, err := registry.CreateKey(parent, "driver", registry.ALL_ACCESS) + if err != nil { + t.Fatal(err) + } + if openedExisting { + t.Fatal("test driver key unexpectedly existed") + } + handle := ®istryKeyHandle{key: key} + store := failingWindowsRegistrationStore{ + inner: windowsRegistrationValueStore{key: key}, + name: "name", + err: errors.New("injected registry write failure"), + } + changes, err := registryRegistrationChanges(windowsRegistrationTestDriver()) + if err != nil { + t.Fatal(err) + } + err = updateRegistrationValuesWithRollback(&store, changes, func() error { + return deleteCreatedRegistryKey(parent, "driver", handle, true) + }) + if !errors.Is(err, store.err) { + t.Fatalf("registration update error = %v, want injected failure", err) + } + if !handle.closed { + t.Fatal("new driver key handle was not closed during rollback") + } + if _, err := registry.OpenKey(parent, "driver", registry.QUERY_VALUE); !errors.Is(err, registry.ErrNotExist) { + t.Fatalf("new driver key remains after rollback: %v", err) + } +} + +type failingWindowsRegistrationStore struct { + inner windowsRegistrationValueStore + name string + err error + failed bool +} + +func (s *failingWindowsRegistrationStore) read(name string) (registrationValue, error) { + return s.inner.read(name) +} + +func (s *failingWindowsRegistrationStore) write(name string, value registrationValue) error { + if err := s.inner.write(name, value); err != nil { + return err + } + if name == s.name && !s.failed { + s.failed = true + return s.err + } + return nil +} + +func (s *failingWindowsRegistrationStore) delete(name string) error { + return s.inner.delete(name) +} + +func windowsRegistrationTestDriver() DriverInfo { + driver := DriverInfo{ + ID: "driver", + Name: "test driver", + Publisher: "test publisher", + License: "MIT", + Version: semver.MustParse("1.2.3"), + Source: "external", + } + driver.Driver.Shared.Set(PlatformTuple(), "C:\\drivers\\driver.dll") + return driver +} + +func createWindowsRegistrationTestKey(t *testing.T, label string) (registry.Key, func()) { + t.Helper() + var suffix [12]byte + if _, err := rand.Read(suffix[:]); err != nil { + t.Fatal(err) + } + path := fmt.Sprintf("Software\\dbc-registration-test-%s-%s", label, hex.EncodeToString(suffix[:])) + key, _, err := registry.CreateKey(winregroot.UserRoot(), path, registry.ALL_ACCESS) + if err != nil { + t.Fatal(err) + } + return key, func() { + if err := key.Close(); err != nil { + t.Errorf("close test registry key: %v", err) + } + if err := registry.DeleteKey(winregroot.UserRoot(), path); err != nil && !errors.Is(err, registry.ErrNotExist) { + t.Errorf("delete test registry key: %v", err) + } + } +} diff --git a/config/splitlist_js.go b/config/splitlist_js.go index 903075fc..649f5fcd 100644 --- a/config/splitlist_js.go +++ b/config/splitlist_js.go @@ -16,10 +16,8 @@ package config -// splitConfigList does not split under GOOS=js: the wasm API passes a single -// explicit directory as the location, and ':' (the Unix list separator that -// filepath.SplitList uses under js) would corrupt a Windows drive-lettered -// path such as "C:/drivers". +// splitConfigList preserves the Wasm API's single-location contract. Splitting +// on either ':' or ';' would corrupt valid Windows drive paths or POSIX names. func splitConfigList(s string) []string { if s == "" { return nil diff --git a/config/splitlist_js_test.go b/config/splitlist_js_test.go new file mode 100644 index 00000000..2024d328 --- /dev/null +++ b/config/splitlist_js_test.go @@ -0,0 +1,38 @@ +// 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 js + +package config + +import ( + "reflect" + "testing" +) + +func TestSplitConfigListKeepsWasmLocationAsOnePath(t *testing.T) { + for _, path := range []string{ + "C:/drivers", + "C:/drivers:alternate", + "//server/share/drivers", + "drivers;alternate", + } { + if got := splitConfigList(path); !reflect.DeepEqual(got, []string{path}) { + t.Errorf("splitConfigList(%q) = %#v, want one unchanged path", path, got) + } + } + if got := splitConfigList(""); got != nil { + t.Errorf("splitConfigList(empty) = %#v, want nil", got) + } +} diff --git a/config/uninstall_secondary_test.go b/config/uninstall_secondary_test.go new file mode 100644 index 00000000..79a9d638 --- /dev/null +++ b/config/uninstall_secondary_test.go @@ -0,0 +1,70 @@ +// 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 && !js + +package config_test + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/columnar-tech/dbc/config" +) + +func TestUninstallDriverUsesSelectedSecondaryRoot(t *testing.T) { + primary := t.TempDir() + secondary := t.TempDir() + cfg := config.Config{ + Level: config.ConfigEnv, + Location: primary + string(filepath.ListSeparator) + secondary, + } + secondaryInfo := makeTestDriverInfo("driver", secondary) + secondaryInfo.Source = "external" + secondaryLibrary := filepath.Join(secondary, "driver.so") + if err := os.WriteFile(secondaryLibrary, []byte("secondary"), 0o600); err != nil { + t.Fatal(err) + } + secondaryInfo.Driver.Shared.Set("linux_amd64", secondaryLibrary) + if err := config.CreateManifest(config.Config{Level: config.ConfigEnv, Location: secondary}, secondaryInfo); err != nil { + t.Fatal(err) + } + selected, err := config.GetDriver(cfg, secondaryInfo.ID) + if err != nil { + t.Fatal(err) + } + + primaryInfo := makeTestDriverInfo("driver", primary) + primaryInfo.Source = "external" + primaryLibrary := filepath.Join(primary, "driver.so") + if err := os.WriteFile(primaryLibrary, []byte("primary"), 0o600); err != nil { + t.Fatal(err) + } + primaryInfo.Driver.Shared.Set("linux_amd64", primaryLibrary) + if err := config.CreateManifest(config.Config{Level: config.ConfigEnv, Location: primary}, primaryInfo); err != nil { + t.Fatal(err) + } + + if err := config.UninstallDriver(cfg, selected); err != nil { + t.Fatalf("UninstallDriver secondary registration: %v", err) + } + if _, err := os.Stat(filepath.Join(secondary, "driver.toml")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("secondary registration remains, stat error=%v", err) + } + if _, err := os.Stat(filepath.Join(primary, "driver.toml")); err != nil { + t.Fatalf("primary registration was removed: %v", err) + } +} diff --git a/docs/concepts/driver_manifest.md b/docs/concepts/driver_manifest.md index dd58108a..36ba69aa 100644 --- a/docs/concepts/driver_manifest.md +++ b/docs/concepts/driver_manifest.md @@ -20,6 +20,8 @@ The term "driver manifest" refers to an [ADBC Driver Manifest](https://arrow.apa In short, a driver manifest is a metadata file that stores key information about a [driver](./driver.md), including the information a [driver manager](./driver_manager.md) needs to load it. +ADBC runtime manifests may point to driver libraries managed outside dbc. + For example, here's an example driver manifest for the [MySQL ADBC driver](https://docs.adbc-drivers.org/drivers/mysql): ```toml @@ -28,14 +30,13 @@ name = 'ADBC Driver Foundry Driver for MySQL' publisher = 'ADBC Drivers Contributors' license = 'Apache-2.0' version = '0.1.0' -source = 'dbc' [ADBC] version = '1.1.0' [Driver] [Driver.shared] -macos_arm64 = '/Users/user/Library/Application Support/ADBC/Drivers/mysql_macos_arm64_v0.1.0' +macos_arm64 = '/usr/local/lib/libadbc_driver_mysql.dylib' ``` Many details about how driver manifests work are outlined in the [ADBC Driver Manifests](https://arrow.apache.org/adbc/current/format/driver_manifests.html) documentation. When you [install a driver](../guides/installing.md) with dbc, it creates and manages driver manifests for you automatically. diff --git a/docs/guides/installing.md b/docs/guides/installing.md index 606d3d6c..435e0f4f 100644 --- a/docs/guides/installing.md +++ b/docs/guides/installing.md @@ -69,6 +69,8 @@ $ dbc install mysql Installed mysql 0.1.0 to /Users/user/Library/Application Support/ADBC/Drivers ``` +Local package archives must include a driver payload named by `Files.driver`. + To confirm the driver is installed, run [`dbc list`](../reference/cli.md#list): ```console @@ -140,7 +142,7 @@ For example, if you were to run `dbc install mysql` and get version 0.1.0, if !!! note - When dbc updates a driver like this, the old driver is uninstalled first. [ADBC driver manifests](../concepts/driver_manifest.md) provide a mechanism to support having multiple versions of the same driver installed at the same time and dbc may provide a convenient way to do this in a future release. + dbc prepares and validates the new package before replacing the driver registration. It then makes a conservative attempt to clean up the previous owned payload. ## Installing System Wide @@ -194,18 +196,12 @@ $ dbc install mysql Installed mysql 0.1.0 to /home/user/drivers -$ tree $ADBC_DRIVER_PATH -/home/user/drivers -├── mysql_linux_amd64_v0.1.0 -│   ├── libadbc_driver_mysql.so -│   ├── libadbc_driver_mysql.so.sig -│   ├── LICENSE -│   └── NOTICE -└── mysql.toml - -2 directories, 5 files +$ ls "$ADBC_DRIVER_PATH" +mysql.toml ``` +dbc stores the package payload in a hidden `.dbc-package-g-*` subdirectory of the same directory. dbc may also leave small hidden lock files (such as `.dbc.*` or `..dbc`) there; they are safe to leave in place. + !!! note If you set `$ADBC_DRIVER_PATH` environment variable with dbc, you will also need to re-use the same shell or set it in your ADBC driver manager code explicitly. For example: @@ -318,3 +314,7 @@ dbc will search in the following order: 3. `CONDA_PREFIX` 2. User 3. System + +Uninstall removes an owned driver payload when safe cleanup is available, while external registrations are removed without deleting their libraries; package files may remain on platforms such as WebAssembly. Metadata from legacy installs may remain after the driver library is removed. + +In Go programs, `config.UninstallDriverShared` was removed in v0.4; use `config.UninstallDriver` or `Client.Uninstall` instead. diff --git a/internal/atomicfile/atomicfile.go b/internal/atomicfile/atomicfile.go new file mode 100644 index 00000000..0ffe1ae6 --- /dev/null +++ b/internal/atomicfile/atomicfile.go @@ -0,0 +1,141 @@ +// 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. + +// Package atomicfile writes a complete file before replacing its destination. +package atomicfile + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/columnar-tech/dbc/internal/hostpath" +) + +// WriteFilePreservingMode atomically replaces path while retaining the +// permission bits of an existing regular file. For a symlink to a regular +// file, it replaces the symlink itself with a regular file using the target's +// permission bits; the target is left unchanged. Dangling symlinks are treated +// as new destinations. Other nonregular destinations are rejected before any +// replacement is attempted. Existing destinations use a 0600 temporary file +// before its mode is changed to the saved permission bits. New destinations +// use perm when creating the temporary file so the process umask applies. +func WriteFilePreservingMode(path string, data []byte, perm os.FileMode) error { + existing, existingPerm, err := destinationMode(path) + if err != nil { + return err + } + createPerm, finalPerm, chmod := preservingTempPermissions(existing, existingPerm, perm) + return writeFileWithTemp(path, finalPerm, chmod, func(dir, base string) (*os.File, error) { + return createUniqueTemp(dir, strings.TrimSuffix(base, "*"), createPerm) + }, func(w io.Writer) error { + _, err := w.Write(data) + return err + }, replace, syncDirectory) +} + +func destinationMode(path string) (bool, os.FileMode, error) { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return false, 0, nil + } + if err != nil { + return false, 0, fmt.Errorf("atomicfile: inspect destination %s: %w", path, err) + } + if info.Mode().IsRegular() { + return true, info.Mode().Perm(), nil + } + if info.Mode()&os.ModeSymlink != 0 { + target, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + return false, 0, nil + } + if err != nil { + return false, 0, fmt.Errorf("atomicfile: inspect symlink target %s: %w", path, err) + } + if target.Mode().IsRegular() { + return true, target.Mode().Perm(), nil + } + } + return false, 0, fmt.Errorf("atomicfile: destination %s is not a regular file", path) +} + +func preservingTempPermissions(existing bool, existingPerm, newPerm os.FileMode) (createPerm, finalPerm os.FileMode, chmod bool) { + if existing { + return 0o600, existingPerm, true + } + return newPerm, newPerm, false +} + +func writeFileWithTemp(path string, perm os.FileMode, chmod bool, createTemp func(string, string) (*os.File, error), write func(io.Writer) error, replaceFile func(string, string) error, syncDir func(string) error) (err error) { + dir := hostpath.Dir(path) + f, err := createTemp(dir, "."+hostpath.Base(path)+".tmp-*") + if err != nil { + return fmt.Errorf("atomicfile: create temporary file for %s: %w", path, err) + } + tmp := f.Name() + replaced := false + defer func() { + if removeErr := os.Remove(tmp); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) && err == nil && !replaced { + err = fmt.Errorf("atomicfile: remove temporary file %s: %w", tmp, removeErr) + } + }() + + if chmod { + if err = f.Chmod(perm); err != nil { + f.Close() + return fmt.Errorf("atomicfile: chmod temporary file for %s: %w", path, err) + } + } + if err = write(f); err != nil { + f.Close() + return fmt.Errorf("atomicfile: write temporary file for %s: %w", path, err) + } + if err = f.Sync(); err != nil { + f.Close() + return fmt.Errorf("atomicfile: sync temporary file for %s: %w", path, err) + } + if err = f.Close(); err != nil { + return fmt.Errorf("atomicfile: close temporary file for %s: %w", path, err) + } + if err = replaceFile(tmp, path); err != nil { + return fmt.Errorf("atomicfile: replace %s: %w", path, err) + } + replaced = true + _ = syncDir(dir) + return nil +} + +func createUniqueTemp(dir, prefix string, perm os.FileMode) (*os.File, error) { + for range 100 { + var random [12]byte + if _, err := rand.Read(random[:]); err != nil { + return nil, fmt.Errorf("atomicfile: generate temporary filename: %w", err) + } + path := hostpath.Join(dir, prefix+hex.EncodeToString(random[:])) + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm) + if errors.Is(err, os.ErrExist) { + continue + } + if err != nil { + return nil, err + } + return f, nil + } + return nil, fmt.Errorf("atomicfile: could not allocate a unique temporary file in %s", dir) +} diff --git a/internal/atomicfile/atomicfile_mode_unix_test.go b/internal/atomicfile/atomicfile_mode_unix_test.go new file mode 100644 index 00000000..74acf496 --- /dev/null +++ b/internal/atomicfile/atomicfile_mode_unix_test.go @@ -0,0 +1,190 @@ +// 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 aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris + +package atomicfile + +import ( + "net" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" +) + +func TestWriteFilePreservingModeKeepsExistingPermissionBits(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.toml") + if err := os.WriteFile(path, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + if err := WriteFilePreservingMode(path, []byte("new"), 0o666); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("replacement permission bits = %o, want 600", got) + } +} + +func TestPreservingTempPermissionsProtectExistingDestination(t *testing.T) { + create, final, chmod := preservingTempPermissions(true, 0o640, 0o666) + if create != 0o600 || final != 0o640 || !chmod { + t.Fatalf("existing temp permissions = create %o, final %o, chmod %v", create, final, chmod) + } + create, final, chmod = preservingTempPermissions(false, 0, 0o666) + if create != 0o666 || final != 0o666 || chmod { + t.Fatalf("new temp permissions = create %o, final %o, chmod %v", create, final, chmod) + } +} + +func TestWriteFilePreservingModeReplacesRegularSymlinkWithoutChangingTarget(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target") + link := filepath.Join(dir, "manifest") + if err := os.WriteFile(target, []byte("target contents"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Base(target), link); err != nil { + t.Fatal(err) + } + targetBefore, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if err := WriteFilePreservingMode(link, []byte("new manifest"), 0o666); err != nil { + t.Fatal(err) + } + + linkInfo, err := os.Lstat(link) + if err != nil { + t.Fatal(err) + } + if !linkInfo.Mode().IsRegular() || linkInfo.Mode()&os.ModeSymlink != 0 { + t.Fatalf("replaced link mode = %v, want regular file", linkInfo.Mode()) + } + if got := linkInfo.Mode().Perm(); got != 0o600 { + t.Fatalf("replacement permission bits = %o, want 600", got) + } + targetAfter, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(targetBefore, targetAfter) { + t.Fatal("symlink target file was replaced") + } + if data, err := os.ReadFile(target); err != nil || string(data) != "target contents" { + t.Fatalf("target content = %q, error = %v", data, err) + } + if data, err := os.ReadFile(link); err != nil || string(data) != "new manifest" { + t.Fatalf("replacement content = %q, error = %v", data, err) + } +} + +func TestWriteFilePreservingModeReplacesDanglingSymlink(t *testing.T) { + dir := t.TempDir() + link := filepath.Join(dir, "manifest") + if err := os.Symlink("missing-target", link); err != nil { + t.Fatal(err) + } + if err := WriteFilePreservingMode(link, []byte("new manifest"), 0o666); err != nil { + t.Fatal(err) + } + info, err := os.Lstat(link) + if err != nil { + t.Fatal(err) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("dangling link replacement mode = %v, want regular file", info.Mode()) + } + if data, err := os.ReadFile(link); err != nil || string(data) != "new manifest" { + t.Fatalf("replacement content = %q, error = %v", data, err) + } +} + +func TestWriteFilePreservingModeRejectsNonregularDestinations(t *testing.T) { + dir := t.TempDir() + directory := filepath.Join(dir, "directory") + if err := os.Mkdir(directory, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "keep"), []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + fifo := filepath.Join(dir, "fifo") + if err := syscall.Mkfifo(fifo, 0o600); err != nil { + t.Fatal(err) + } + socket := filepath.Join(dir, "socket") + listener, err := net.Listen("unix", socket) + paths := []string{directory, fifo} + if err != nil { + t.Logf("skipping socket destination check: %v", err) + } else { + defer listener.Close() + paths = append(paths, socket) + } + + for _, path := range paths { + t.Run(filepath.Base(path), func(t *testing.T) { + before, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + if err := WriteFilePreservingMode(path, []byte("replacement"), 0o666); err == nil { + t.Fatal("nonregular destination was accepted") + } + after, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(before, after) { + t.Fatal("nonregular destination was changed") + } + }) + } + if data, err := os.ReadFile(filepath.Join(directory, "keep")); err != nil || string(data) != "keep" { + t.Fatalf("directory child = %q, error = %v", data, err) + } +} + +func TestWriteFilePreservingModeHonorsRestrictiveUmask(t *testing.T) { + const childEnv = "DBC_ATOMICFILE_UMASK_CHILD" + if os.Getenv(childEnv) == "1" { + path := os.Getenv("DBC_ATOMICFILE_UMASK_PATH") + syscall.Umask(0o077) + if err := WriteFilePreservingMode(path, []byte("new"), 0o666); err != nil { + t.Fatal(err) + } + return + } + + path := filepath.Join(t.TempDir(), "new.toml") + cmd := exec.Command(os.Args[0], "-test.run=^TestWriteFilePreservingModeHonorsRestrictiveUmask$") + cmd.Env = append(os.Environ(), childEnv+"=1", "DBC_ATOMICFILE_UMASK_PATH="+path) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("umask subprocess failed: %v\n%s", err, output) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("new file permission bits = %o, want 600 under umask 077", got) + } +} diff --git a/internal/atomicfile/atomicfile_test.go b/internal/atomicfile/atomicfile_test.go new file mode 100644 index 00000000..70543e3c --- /dev/null +++ b/internal/atomicfile/atomicfile_test.go @@ -0,0 +1,149 @@ +// 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. + +package atomicfile + +import ( + "errors" + "io" + "os" + "path/filepath" + "testing" +) + +// writeFile exercises the generic temporary-file path with injectable +// replacement and directory sync hooks. +func writeFile(path string, perm os.FileMode, write func(io.Writer) error, replaceFile func(string, string) error, syncDir func(string) error) error { + return writeFileWithTemp(path, perm, true, func(dir, base string) (*os.File, error) { + return os.CreateTemp(dir, base) + }, write, replaceFile, syncDir) +} + +func TestWriteFileReplacesExistingFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.toml") + if err := os.WriteFile(path, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + if err := writeFile(path, 0o640, func(w io.Writer) error { + _, err := w.Write([]byte("new")) + return err + }, replace, syncDirectory); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "new" { + t.Fatalf("destination = %q, want %q", got, "new") + } +} + +func TestWriteFailurePreservesExistingFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.toml") + if err := os.WriteFile(path, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + wantErr := errors.New("write failed") + err := writeFile(path, 0o600, func(w io.Writer) error { + if _, err := w.Write([]byte("partial")); err != nil { + return err + } + return wantErr + }, replace, syncDirectory) + if !errors.Is(err, wantErr) { + t.Fatalf("writeFile error = %v, want wrapped %v", err, wantErr) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "old" { + t.Fatalf("destination = %q, want old content", got) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Name() != filepath.Base(path) { + t.Fatalf("temporary file was not cleaned up: %v", entries) + } +} + +func TestReplaceFailurePreservesExistingFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.toml") + if err := os.WriteFile(path, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + wantErr := errors.New("replace failed") + replaceCalled := false + err := writeFile(path, 0o600, func(w io.Writer) error { + _, err := w.Write([]byte("new")) + return err + }, func(source, destination string) error { + replaceCalled = true + if destination != path { + t.Fatalf("replace destination = %q, want %q", destination, path) + } + if _, err := os.Stat(source); err != nil { + t.Fatalf("temporary source is not ready for replacement: %v", err) + } + return wantErr + }, syncDirectory) + if !replaceCalled { + t.Fatal("replace function was not called") + } + if !errors.Is(err, wantErr) { + t.Fatalf("writeFile error = %v, want wrapped %v", err, wantErr) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "old" { + t.Fatalf("destination = %q, want old content", got) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Name() != filepath.Base(path) { + t.Fatalf("temporary file was not cleaned up: %v", entries) + } +} + +func TestDirectorySyncFailureDoesNotReportReplacementFailure(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.toml") + if err := os.WriteFile(path, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + syncErr := errors.New("directory sync failed") + err := writeFile(path, 0o600, func(w io.Writer) error { + _, err := w.Write([]byte("new")) + return err + }, replace, func(string) error { return syncErr }) + if err != nil { + t.Fatalf("writeFile returned post-replacement sync error: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "new" { + t.Fatalf("destination = %q, want new content", got) + } +} diff --git a/internal/atomicfile/replace_unix.go b/internal/atomicfile/replace_unix.go new file mode 100644 index 00000000..809c11df --- /dev/null +++ b/internal/atomicfile/replace_unix.go @@ -0,0 +1,23 @@ +// 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 atomicfile + +import "os" + +func replace(source, destination string) error { + return os.Rename(source, destination) +} diff --git a/internal/atomicfile/replace_windows.go b/internal/atomicfile/replace_windows.go new file mode 100644 index 00000000..42bc536f --- /dev/null +++ b/internal/atomicfile/replace_windows.go @@ -0,0 +1,29 @@ +// 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. + +package atomicfile + +import "golang.org/x/sys/windows" + +func replace(source, destination string) error { + src, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + dst, err := windows.UTF16PtrFromString(destination) + if err != nil { + return err + } + return windows.MoveFileEx(src, dst, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH) +} diff --git a/internal/atomicfile/syncdir_js.go b/internal/atomicfile/syncdir_js.go new file mode 100644 index 00000000..f760e763 --- /dev/null +++ b/internal/atomicfile/syncdir_js.go @@ -0,0 +1,19 @@ +// 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 js + +package atomicfile + +func syncDirectory(string) error { return nil } diff --git a/internal/atomicfile/syncdir_unix.go b/internal/atomicfile/syncdir_unix.go new file mode 100644 index 00000000..6421c964 --- /dev/null +++ b/internal/atomicfile/syncdir_unix.go @@ -0,0 +1,36 @@ +// 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 && !js + +package atomicfile + +import ( + "errors" + "os" + "syscall" +) + +func syncDirectory(path string) error { + dir, err := os.Open(path) + if err != nil { + return err + } + defer dir.Close() + if err := dir.Sync(); err != nil && !errors.Is(err, syscall.EINVAL) && + !errors.Is(err, syscall.ENOTSUP) && !errors.Is(err, syscall.EOPNOTSUPP) { + return err + } + return nil +} diff --git a/internal/atomicfile/syncdir_windows.go b/internal/atomicfile/syncdir_windows.go new file mode 100644 index 00000000..34bac420 --- /dev/null +++ b/internal/atomicfile/syncdir_windows.go @@ -0,0 +1,17 @@ +// 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. + +package atomicfile + +func syncDirectory(string) error { return nil } diff --git a/internal/fslock/canonical_runtime_path_test.go b/internal/fslock/canonical_runtime_path_test.go new file mode 100644 index 00000000..5ac423d3 --- /dev/null +++ b/internal/fslock/canonical_runtime_path_test.go @@ -0,0 +1,36 @@ +// 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 !js + +package fslock + +import "testing" + +func TestCanonicalRuntimePathCaseFolding(t *testing.T) { + path := `C:/Users/Runner/Drivers/.dbc.install.lock` + windowsPath := canonicalRuntimePath(path, true) + if want := `c:/users/runner/drivers/.dbc.install.lock`; windowsPath != want { + t.Fatalf("Windows canonical lock path = %q, want %q", windowsPath, want) + } + if upperPath := canonicalRuntimePath(`C:/USERS/RUNNER/DRIVERS/.DBC.INSTALL.LOCK`, true); windowsPath != upperPath { + t.Fatalf("case variants have different Windows lock keys: %q != %q", windowsPath, upperPath) + } + if got := canonicalRuntimePath(path, false); got != path { + t.Fatalf("case-sensitive canonical lock path = %q, want %q", got, path) + } + if got := canonicalRuntimePath(`C:/USERS/RUNNER/DRIVERS/.DBC.INSTALL.LOCK`, false); got == path { + t.Fatalf("non-Windows canonical lock path unexpectedly folded case: %q", got) + } +} diff --git a/internal/fslock/fslock.go b/internal/fslock/fslock.go index 4684a6bb..8df63f02 100644 --- a/internal/fslock/fslock.go +++ b/internal/fslock/fslock.go @@ -13,18 +13,42 @@ // limitations under the License. // Package fslock provides advisory file locking for coordinating exclusive -// access to shared resources across processes. +// access to shared resources. Native platforms use operating-system file locks +// to coordinate across processes. JavaScript/Wasm locking is limited to one Go +// runtime; it does not coordinate separate Wasm instances or Node workers. package fslock import ( + "context" "errors" "os" + "strings" + "time" ) // Lock represents an acquired advisory file lock. type Lock struct { - f *os.File - path string + f *os.File + path string + releaseFn func() error } var ErrLockContended = errors.New("lock is held by another process") + +func canonicalRuntimePath(path string, windows bool) string { + if windows { + return strings.ToLower(path) + } + return path +} + +// AcquireContext waits for the lock until it is acquired, timeout elapses, or ctx is +// canceled. A non-positive timeout makes a single acquisition attempt. +func AcquireContext(ctx context.Context, path string, timeout time.Duration) (Lock, error) { + return acquireContext(ctx, path, timeout) +} + +// Acquire is the compatibility wrapper for callers without a context. +func Acquire(path string, timeout time.Duration) (Lock, error) { + return AcquireContext(context.Background(), path, timeout) +} diff --git a/internal/fslock/fslock_js.go b/internal/fslock/fslock_js.go new file mode 100644 index 00000000..880441d6 --- /dev/null +++ b/internal/fslock/fslock_js.go @@ -0,0 +1,126 @@ +// 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 js + +package fslock + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/columnar-tech/dbc/internal/hostpath" +) + +type runtimeLock struct { + token chan struct{} + refs int +} + +// runtimeLocks coordinates callers that share this Go/Wasm runtime only. +// Separate Wasm instances and Node workers have independent registries. +var runtimeLocks = struct { + sync.Mutex + byPath map[string]*runtimeLock +}{byPath: make(map[string]*runtimeLock)} + +// dropRuntimeLockRef drops one reference and forgets the entry when unused. +func dropRuntimeLockRef(entry *runtimeLock, canonicalPath string) { + runtimeLocks.Lock() + defer runtimeLocks.Unlock() + entry.refs-- + if entry.refs == 0 { + delete(runtimeLocks.byPath, canonicalPath) + } +} + +func acquireContext(ctx context.Context, path string, timeout time.Duration) (Lock, error) { + if err := ctx.Err(); err != nil { + return Lock{}, err + } + canonicalPath, err := hostpath.Abs(hostpath.Clean(path)) + if err != nil { + return Lock{}, fmt.Errorf("fslock: resolve %s: %w", path, err) + } + // The lock file often does not exist yet, so resolve its existing parent + // and append the basename. This makes symlink aliases of the same directory + // share one key in this runtime's lock registry. + parent := hostpath.Dir(canonicalPath) + resolvedParent, err := hostpath.EvalSymlinks(parent) + if err != nil { + return Lock{}, fmt.Errorf("fslock: resolve lock directory %s: %w", parent, err) + } + canonicalPath = hostpath.Join(resolvedParent, hostpath.Base(canonicalPath)) + canonicalPath = canonicalRuntimePath(canonicalPath, hostpath.IsWindows()) + + runtimeLocks.Lock() + entry := runtimeLocks.byPath[canonicalPath] + if entry == nil { + entry = &runtimeLock{token: make(chan struct{}, 1)} + entry.token <- struct{}{} + runtimeLocks.byPath[canonicalPath] = entry + } + entry.refs++ + runtimeLocks.Unlock() + + acquired := false + if timeout <= 0 { + select { + case <-entry.token: + acquired = true + default: + } + } else { + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case <-ctx.Done(): + case <-timer.C: + case <-entry.token: + acquired = true + } + } + if !acquired { + dropRuntimeLockRef(entry, canonicalPath) + if err := ctx.Err(); err != nil { + return Lock{}, err + } + return Lock{}, fmt.Errorf("fslock: could not acquire lock on %s within %s: %w", path, timeout, ErrLockContended) + } + if err := ctx.Err(); err != nil { + entry.token <- struct{}{} + dropRuntimeLockRef(entry, canonicalPath) + return Lock{}, err + } + + var once sync.Once + return Lock{releaseFn: func() error { + once.Do(func() { + entry.token <- struct{}{} + dropRuntimeLockRef(entry, canonicalPath) + }) + return nil + }}, nil +} + +// Release releases a lock local to this Go/Wasm runtime. It does not coordinate +// separate Wasm instances or Node workers. +func (l Lock) Release() error { + if l.releaseFn == nil { + return nil + } + return l.releaseFn() +} diff --git a/internal/fslock/fslock_js_test.go b/internal/fslock/fslock_js_test.go new file mode 100644 index 00000000..11c1efbf --- /dev/null +++ b/internal/fslock/fslock_js_test.go @@ -0,0 +1,165 @@ +// 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 js + +package fslock_test + +import ( + "context" + "errors" + "io/fs" + "os" + "path/filepath" + "testing" + "time" + + "github.com/columnar-tech/dbc/internal/fslock" + "github.com/columnar-tech/dbc/internal/hostpath" +) + +func TestAcquireContextSerializesSamePath(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.lock") + first, err := fslock.AcquireContext(context.Background(), path, time.Second) + if err != nil { + t.Fatal(err) + } + acquired := make(chan fslock.Lock, 1) + failed := make(chan error, 1) + go func() { + lock, err := fslock.AcquireContext(context.Background(), path, time.Second) + if err != nil { + failed <- err + return + } + acquired <- lock + }() + select { + case lock := <-acquired: + lock.Release() + t.Fatal("second lock acquired before the first was released") + case err := <-failed: + t.Fatalf("second AcquireContext: %v", err) + case <-time.After(30 * time.Millisecond): + } + if err := first.Release(); err != nil { + t.Fatal(err) + } + select { + case lock := <-acquired: + if err := lock.Release(); err != nil { + t.Fatal(err) + } + case err := <-failed: + t.Fatalf("second AcquireContext: %v", err) + case <-time.After(time.Second): + t.Fatal("second lock did not acquire after release") + } +} + +func TestAcquireContextSerializesSymlinkedParentAlias(t *testing.T) { + root := t.TempDir() + realDir := filepath.Join(root, "real") + if err := os.Mkdir(realDir, 0700); err != nil { + t.Fatal(err) + } + aliasDir := filepath.Join(root, "alias") + if err := os.Symlink(realDir, aliasDir); err != nil { + if hostpath.IsWindows() { + t.Skipf("symlink creation is unavailable on this Windows host: %v", err) + } + t.Fatal(err) + } + + lockPath := filepath.Join(realDir, "test.lock") + if _, err := os.Lstat(lockPath); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("lock file before acquisition: error = %v, want fs.ErrNotExist", err) + } + first, err := fslock.AcquireContext(context.Background(), lockPath, time.Second) + if err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(lockPath); !errors.Is(err, fs.ErrNotExist) { + first.Release() + t.Fatalf("lock file after runtime-local acquisition: error = %v, want fs.ErrNotExist", err) + } + _, err = fslock.AcquireContext(context.Background(), filepath.Join(aliasDir, "test.lock"), 20*time.Millisecond) + if !errors.Is(err, fslock.ErrLockContended) { + first.Release() + t.Fatalf("symlink alias AcquireContext error = %v, want ErrLockContended", err) + } + if err := first.Release(); err != nil { + t.Fatal(err) + } + + second, err := fslock.AcquireContext(context.Background(), filepath.Join(aliasDir, "test.lock"), time.Second) + if err != nil { + t.Fatalf("AcquireContext through symlink alias after release: %v", err) + } + if err := second.Release(); err != nil { + t.Fatal(err) + } +} + +func TestAcquireContextMissingParentIsResolutionError(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing", "test.lock") + _, err := fslock.AcquireContext(context.Background(), path, time.Second) + if err == nil { + t.Fatal("AcquireContext succeeded with a missing parent directory") + } + if errors.Is(err, fslock.ErrLockContended) { + t.Fatalf("AcquireContext error = %v, want a path resolution error", err) + } + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("AcquireContext error = %v, want fs.ErrNotExist", err) + } +} + +func TestAcquireContextCancellationWhileWaiting(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.lock") + first, err := fslock.AcquireContext(context.Background(), path, time.Second) + if err != nil { + t.Fatal(err) + } + defer first.Release() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + _, err := fslock.AcquireContext(ctx, path, time.Second) + done <- err + }() + time.Sleep(30 * time.Millisecond) + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("AcquireContext error = %v, want context.Canceled", err) + } + case <-time.After(time.Second): + t.Fatal("AcquireContext did not return after cancellation") + } +} + +func TestAcquireContextTimeout(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.lock") + first, err := fslock.AcquireContext(context.Background(), path, time.Second) + if err != nil { + t.Fatal(err) + } + defer first.Release() + _, err = fslock.AcquireContext(context.Background(), path, 20*time.Millisecond) + if !errors.Is(err, fslock.ErrLockContended) { + t.Fatalf("AcquireContext error = %v, want ErrLockContended", err) + } +} diff --git a/internal/fslock/fslock_test.go b/internal/fslock/fslock_test.go index 52d52207..19ed1e1c 100644 --- a/internal/fslock/fslock_test.go +++ b/internal/fslock/fslock_test.go @@ -12,12 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. +//go:build !js + package fslock_test import ( + "context" "errors" "os" "path/filepath" + "runtime" "sync" "testing" "time" @@ -51,20 +55,34 @@ func TestAcquireTwiceSequential(t *testing.T) { lock2.Release() } -func TestReleaseRemovesFile(t *testing.T) { +func TestReleaseLockFileLifecycle(t *testing.T) { path := filepath.Join(t.TempDir(), "test.lock") lock, err := fslock.Acquire(path, 5*time.Second) if err != nil { t.Fatalf("Acquire: %v", err) } - if _, err := os.Stat(path); err != nil { + before, err := os.Stat(path) + if err != nil { t.Fatalf("lock file missing while held: %v", err) } if err := lock.Release(); err != nil { t.Fatalf("Release: %v", err) } - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("lock file still on disk after Release: stat err=%v", err) + after, err := os.Stat(path) + if runtime.GOOS == "windows" { + if !os.IsNotExist(err) { + t.Fatalf("Windows lock file still exists after Release: stat err=%v", err) + } + return + } + if err != nil { + t.Fatalf("persistent Unix lock file missing after Release: %v", err) + } + if !os.SameFile(before, after) { + t.Fatal("Unix Release replaced or unlinked the persistent lock file") + } + if after.Size() != 0 { + t.Fatalf("persistent lock file size = %d, want 0", after.Size()) } } @@ -123,6 +141,32 @@ func TestAcquireTimeout(t *testing.T) { } } +func TestAcquireContextCancellationWhileWaiting(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.lock") + held, err := fslock.Acquire(path, 5*time.Second) + if err != nil { + t.Fatal(err) + } + defer held.Release() + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + _, err := fslock.AcquireContext(ctx, path, 5*time.Second) + done <- err + }() + time.Sleep(30 * time.Millisecond) + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("AcquireContext error = %v, want context.Canceled", err) + } + case <-time.After(time.Second): + t.Fatal("AcquireContext did not return after cancellation") + } +} + func TestAcquireUnwritableDirIsNotContention(t *testing.T) { // Simulate a lock failure that's due to permissions instead of actual lock // contention by creating a read-only file and later trying to lock on it diff --git a/internal/fslock/fslock_unix.go b/internal/fslock/fslock_unix.go index 5c089841..9529e80d 100644 --- a/internal/fslock/fslock_unix.go +++ b/internal/fslock/fslock_unix.go @@ -12,11 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -//go:build !windows +//go:build !windows && !js package fslock import ( + "context" "errors" "fmt" "os" @@ -24,25 +25,30 @@ import ( "time" ) -// Acquire acquires an exclusive advisory lock on the file at path, retrying -// until timeout elapses. Returns an error if the lock cannot be acquired. -func Acquire(path string, timeout time.Duration) (Lock, error) { +func acquireContext(ctx context.Context, path string, timeout time.Duration) (Lock, error) { deadline := time.Now().Add(timeout) for { + if err := ctx.Err(); err != nil { + return Lock{}, err + } + // Mode 0600 means only same-user coordination is supported. + // TODO: Define cross-user permissions and interrupted-writer recovery. f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600) if err != nil { return Lock{}, fmt.Errorf("fslock: open %s: %w", path, err) } - lock, err := lockFile(f, path, deadline) + lock, err := lockFile(ctx, f, path, deadline) if err == nil { return lock, nil } f.Close() if errors.Is(err, errStaleInode) { if time.Now().Before(deadline) { - // Previous holder unlinked the file between our open and - // our flock; the path now refers to a different inode. + // Defensive: Release never unlinks, but the file may be removed + // externally. The path was replaced between our open and flock; the + // acquired lock would protect a different inode from callers + // that open the current path, so retry against that inode. // Reopen and try again within the remaining budget. continue } @@ -57,13 +63,15 @@ func Acquire(path string, timeout time.Duration) (Lock, error) { // unlinked (or replaced) since we opened it — we need to reopen and retry. var errStaleInode = errors.New("fslock: stale inode") -func lockFile(f *os.File, path string, deadline time.Time) (Lock, error) { +func lockFile(ctx context.Context, f *os.File, path string, deadline time.Time) (Lock, error) { for { + if err := ctx.Err(); err != nil { + return Lock{}, err + } err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) if err == nil { - // Confirm the path still points at our inode. If the previous - // holder unlinked it (or it was replaced), the lock we just took - // is on a dead inode and a new caller could lock the new file. + // Confirm the path still points at our inode. If it was replaced, + // the lock we just took would not protect callers opening the path. if stale, serr := inodeIsStale(f, path); serr != nil { syscall.Flock(int(f.Fd()), syscall.LOCK_UN) return Lock{}, fmt.Errorf("fslock: stat %s: %w", path, serr) @@ -73,14 +81,27 @@ func lockFile(f *os.File, path string, deadline time.Time) (Lock, error) { } return Lock{f: f, path: path}, nil } - if time.Now().After(deadline) { + if !isLockContention(err) { + return Lock{}, fmt.Errorf("fslock: lock %s: %w", path, err) + } + if !time.Now().Before(deadline) { return Lock{}, fmt.Errorf("fslock: could not acquire lock on %s (%v): %w", path, err, ErrLockContended) } - time.Sleep(50 * time.Millisecond) + timer := time.NewTimer(50 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return Lock{}, ctx.Err() + case <-timer.C: + } } } +func isLockContention(err error) bool { + return errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) +} + func inodeIsStale(f *os.File, path string) (bool, error) { var fdStat syscall.Stat_t if err := syscall.Fstat(int(f.Fd()), &fdStat); err != nil { @@ -96,22 +117,13 @@ func inodeIsStale(f *os.File, path string) (bool, error) { return fdStat.Ino != pathStat.Ino || fdStat.Dev != pathStat.Dev, nil } -// Release unlinks the lock file and releases the lock. Unlinking before -// closing ensures no new caller can open the same inode and take the lock -// while we still hold it; combined with the inode recheck in Acquire, this -// guarantees that a fresh file at the same path is never raced against a -// soon-to-be-deleted one. +// Release closes the lock file descriptor, releasing the advisory lock. +// The lock file is intentionally left in place: unlinking it would let a new +// caller create a different inode while another waiter still holds or is +// about to lock the old one. Leftover lock files are therefore expected. func (l Lock) Release() error { if l.f == nil { return nil } - rmErr := os.Remove(l.path) - closeErr := l.f.Close() - if closeErr != nil { - return closeErr - } - if rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) { - return rmErr - } - return nil + return l.f.Close() } diff --git a/internal/fslock/fslock_unix_test.go b/internal/fslock/fslock_unix_test.go new file mode 100644 index 00000000..2ea836c9 --- /dev/null +++ b/internal/fslock/fslock_unix_test.go @@ -0,0 +1,156 @@ +// 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 && !js + +package fslock_test + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/columnar-tech/dbc/internal/fslock" +) + +const subprocessLockModeEnv = "DBC_FSLOCK_TEST_MODE" +const subprocessLockPathEnv = "DBC_FSLOCK_TEST_PATH" + +func TestUnixReleasePreservesExistingZeroByteLockFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.lock") + if err := os.WriteFile(path, nil, 0o640); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o640); err != nil { + t.Fatal(err) + } + before, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + lock, err := fslock.Acquire(path, time.Second) + if err != nil { + t.Fatalf("Acquire existing lock file: %v", err) + } + if err := lock.Release(); err != nil { + t.Fatalf("Release: %v", err) + } + after, err := os.Stat(path) + if err != nil { + t.Fatalf("existing lock file was removed: %v", err) + } + if !os.SameFile(before, after) { + t.Fatal("existing lock file inode changed") + } + if after.Size() != 0 || after.Mode().Perm() != 0o640 { + t.Fatalf("existing lock file changed: size=%d mode=%#o", after.Size(), after.Mode().Perm()) + } +} + +func TestUnixPersistentLockCoordinatesProcessesAndCrashRecovery(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.lock") + lock, err := fslock.Acquire(path, time.Second) + if err != nil { + t.Fatalf("initial Acquire: %v", err) + } + initialInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + + // A second process must contend on the same inode while this process holds it. + runLockSubprocess(t, "contend", path) + if err := lock.Release(); err != nil { + t.Fatalf("initial Release: %v", err) + } + + // Reacquisition in another process must reuse the persistent inode. + runLockSubprocess(t, "acquire", path) + reacquired, err := fslock.Acquire(path, time.Second) + if err != nil { + t.Fatalf("parent reacquisition: %v", err) + } + if err := reacquired.Release(); err != nil { + t.Fatalf("parent reacquisition Release: %v", err) + } + afterRelease, err := os.Stat(path) + if err != nil { + t.Fatalf("lock path missing after release: %v", err) + } + if !os.SameFile(initialInfo, afterRelease) { + t.Fatal("reacquisition replaced the persistent lock inode") + } + + // Process exit without Release closes its descriptor and lets flock recover. + runLockSubprocess(t, "exit-holding", path) + afterExit, err := os.Stat(path) + if err != nil { + t.Fatalf("lock path missing after holder process exit: %v", err) + } + if !os.SameFile(initialInfo, afterExit) { + t.Fatal("crash recovery replaced the persistent lock inode") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + recovered, err := fslock.AcquireContext(ctx, path, time.Second) + if err != nil { + t.Fatalf("Acquire after holder process exit: %v", err) + } + if err := recovered.Release(); err != nil { + t.Fatalf("Release after crash recovery: %v", err) + } +} + +func TestUnixLockSubprocessHelper(t *testing.T) { + mode := os.Getenv(subprocessLockModeEnv) + if mode == "" { + return + } + path := os.Getenv(subprocessLockPathEnv) + switch mode { + case "contend": + _, err := fslock.Acquire(path, 150*time.Millisecond) + if !errors.Is(err, fslock.ErrLockContended) { + t.Fatalf("child Acquire error = %v, want lock contention", err) + } + case "acquire": + lock, err := fslock.Acquire(path, time.Second) + if err != nil { + t.Fatalf("child Acquire: %v", err) + } + if err := lock.Release(); err != nil { + t.Fatalf("child Release: %v", err) + } + case "exit-holding": + if _, err := fslock.Acquire(path, time.Second); err != nil { + t.Fatalf("child Acquire before exit: %v", err) + } + os.Exit(0) + default: + t.Fatalf("unknown subprocess mode %q", mode) + } +} + +func runLockSubprocess(t *testing.T, mode, path string) { + t.Helper() + command := exec.Command(os.Args[0], "-test.run=^TestUnixLockSubprocessHelper$") + command.Env = append(os.Environ(), subprocessLockModeEnv+"="+mode, subprocessLockPathEnv+"="+path) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("lock subprocess (%s): %v\n%s", mode, err, output) + } +} diff --git a/internal/fslock/fslock_windows.go b/internal/fslock/fslock_windows.go index d8d504dd..c21c6eb0 100644 --- a/internal/fslock/fslock_windows.go +++ b/internal/fslock/fslock_windows.go @@ -17,6 +17,7 @@ package fslock import ( + "context" "errors" "fmt" "os" @@ -25,9 +26,10 @@ import ( "golang.org/x/sys/windows" ) -// Acquire acquires an exclusive advisory lock on the file at path, retrying -// until timeout elapses. Returns an error if the lock cannot be acquired. -func Acquire(path string, timeout time.Duration) (Lock, error) { +func acquireContext(ctx context.Context, path string, timeout time.Duration) (Lock, error) { + if err := ctx.Err(); err != nil { + return Lock{}, err + } f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600) if err != nil { return Lock{}, fmt.Errorf("fslock: open %s: %w", path, err) @@ -36,21 +38,40 @@ func Acquire(path string, timeout time.Duration) (Lock, error) { ol := new(windows.Overlapped) deadline := time.Now().Add(timeout) for { + if err := ctx.Err(); err != nil { + f.Close() + return Lock{}, err + } err = windows.LockFileEx(windows.Handle(f.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, ol) if err == nil { return Lock{f: f, path: path}, nil } - if time.Now().After(deadline) { + if !isLockContention(err) { + f.Close() + return Lock{}, fmt.Errorf("fslock: lock %s: %w", path, err) + } + if !time.Now().Before(deadline) { f.Close() return Lock{}, fmt.Errorf("fslock: could not acquire lock on %s within %s (%v): %w", path, timeout, err, ErrLockContended) } - time.Sleep(50 * time.Millisecond) + timer := time.NewTimer(50 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + f.Close() + return Lock{}, ctx.Err() + case <-timer.C: + } } } +func isLockContention(err error) bool { + return errors.Is(err, windows.ERROR_LOCK_VIOLATION) +} + // Release releases the lock and removes the lock file. On Windows, Go opens // files without FILE_SHARE_DELETE, so os.Remove returns ERROR_SHARING_VIOLATION // if another process still has the file open — making the delete inherently diff --git a/internal/fslock/fslock_windows_contention_test.go b/internal/fslock/fslock_windows_contention_test.go new file mode 100644 index 00000000..60069ab6 --- /dev/null +++ b/internal/fslock/fslock_windows_contention_test.go @@ -0,0 +1,35 @@ +// 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 fslock + +import ( + "testing" + + "golang.org/x/sys/windows" +) + +func TestIsLockContention(t *testing.T) { + if !isLockContention(windows.ERROR_LOCK_VIOLATION) { + t.Fatal("ERROR_LOCK_VIOLATION must be classified as contention") + } + if isLockContention(windows.ERROR_ACCESS_DENIED) { + t.Fatal("ERROR_ACCESS_DENIED must not be classified as contention") + } + if isLockContention(windows.ERROR_INVALID_HANDLE) { + t.Fatal("unexpected syscall error must not be classified as contention") + } +} diff --git a/internal/hostpath/path.go b/internal/hostpath/path.go new file mode 100644 index 00000000..0764b0c2 --- /dev/null +++ b/internal/hostpath/path.go @@ -0,0 +1,32 @@ +// 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. + +// Package hostpath provides host-filesystem path operations for runtimes whose +// path syntax differs from the Go target's filepath syntax, such as Node on +// Windows running a js/wasm binary. +package hostpath + +import "os" + +func Abs(path string) (string, error) { return abs(path) } +func IsAbs(path string) bool { return isAbs(path) } +func Clean(path string) string { return clean(path) } +func Join(elem ...string) string { return join(elem...) } +func Dir(path string) string { return dir(path) } +func Base(path string) string { return base(path) } +func EvalSymlinks(path string) (string, error) { return evalSymlinks(path) } +func Equal(a, b string) bool { return equal(a, b) } +func MkdirAll(path string, perm os.FileMode) error { return mkdirAll(path, perm) } +func ListSeparator() rune { return listSeparator() } +func IsWindows() bool { return isWindows() } diff --git a/internal/hostpath/path_js.go b/internal/hostpath/path_js.go new file mode 100644 index 00000000..b78ffde7 --- /dev/null +++ b/internal/hostpath/path_js.go @@ -0,0 +1,157 @@ +// 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 js + +package hostpath + +import ( + "fmt" + "os" + "path/filepath" + "syscall/js" +) + +func adapter() js.Value { + v := js.Global().Get("dbcHostPath") + if v.Type() != js.TypeObject { + return js.Undefined() + } + return v +} + +func call(name string, args ...any) (js.Value, bool) { + a := adapter() + if a.Type() != js.TypeObject { + return js.Undefined(), false + } + f := a.Get(name) + if f.Type() != js.TypeFunction { + return js.Undefined(), false + } + result := f.Invoke(args...) + if result.Type() != js.TypeObject { + return js.Undefined(), false + } + return result, true +} + +func stringCall(name string, args ...any) (string, bool, error) { + result, ok := call(name, args...) + if !ok { + return "", false, nil + } + if message := result.Get("error"); message.Type() == js.TypeString && message.String() != "" { + return "", true, fmt.Errorf("%s", message.String()) + } + value := result.Get("value") + if value.Type() != js.TypeString { + return "", true, fmt.Errorf("host path adapter %s returned a non-string value", name) + } + return value.String(), true, nil +} + +func boolCall(name string, args ...any) (bool, bool) { + result, ok := call(name, args...) + if !ok || result.Get("error").Type() == js.TypeString && result.Get("error").String() != "" { + return false, false + } + value := result.Get("value") + if value.Type() != js.TypeBoolean { + return false, false + } + return value.Bool(), true +} + +func abs(path string) (string, error) { + if value, ok, err := stringCall("abs", path); ok { + return value, err + } + return filepath.Abs(path) +} + +func isAbs(path string) bool { + if value, ok := boolCall("isAbs", path); ok { + return value + } + return filepath.IsAbs(path) +} + +func clean(path string) string { + if value, ok, err := stringCall("clean", path); ok && err == nil { + return value + } + return filepath.Clean(path) +} + +func join(elem ...string) string { + args := make([]any, len(elem)) + for i := range elem { + args[i] = elem[i] + } + if value, ok, err := stringCall("join", args...); ok && err == nil { + return value + } + return filepath.Join(elem...) +} + +func dir(path string) string { + if value, ok, err := stringCall("dir", path); ok && err == nil { + return value + } + return filepath.Dir(path) +} + +func base(path string) string { + if value, ok, err := stringCall("base", path); ok && err == nil { + return value + } + return filepath.Base(path) +} + +func evalSymlinks(path string) (string, error) { + if value, ok, err := stringCall("evalSymlinks", path); ok { + return value, err + } + return filepath.EvalSymlinks(path) +} + +func equal(a, b string) bool { + if value, ok := boolCall("equal", a, b); ok { + return value + } + return filepath.Clean(a) == filepath.Clean(b) +} + +func mkdirAll(path string, perm os.FileMode) error { + if result, ok := call("mkdirAll", path, int(perm)); ok { + if message := result.Get("error"); message.Type() == js.TypeString && message.String() != "" { + return fmt.Errorf("%s", message.String()) + } + return nil + } + return os.MkdirAll(path, perm) +} + +func listSeparator() rune { + if value, ok := boolCall("isWindows"); ok && value { + return ';' + } + return filepath.ListSeparator +} + +func isWindows() bool { + value, ok := boolCall("isWindows") + return ok && value +} diff --git a/internal/hostpath/path_other.go b/internal/hostpath/path_other.go new file mode 100644 index 00000000..0d5c66f7 --- /dev/null +++ b/internal/hostpath/path_other.go @@ -0,0 +1,41 @@ +// 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 !js + +package hostpath + +import ( + "os" + "path/filepath" + "strings" +) + +func abs(path string) (string, error) { return filepath.Abs(path) } +func isAbs(path string) bool { return filepath.IsAbs(path) } +func clean(path string) string { return filepath.Clean(path) } +func join(elem ...string) string { return filepath.Join(elem...) } +func dir(path string) string { return filepath.Dir(path) } +func base(path string) string { return filepath.Base(path) } +func evalSymlinks(path string) (string, error) { return filepath.EvalSymlinks(path) } +func equal(a, b string) bool { + a, b = filepath.Clean(a), filepath.Clean(b) + if filepath.Separator == '\\' { + return strings.EqualFold(a, b) + } + return a == b +} +func mkdirAll(path string, perm os.FileMode) error { return os.MkdirAll(path, perm) } +func listSeparator() rune { return filepath.ListSeparator } +func isWindows() bool { return filepath.Separator == '\\' } diff --git a/packages/npm-wasm/README.md b/packages/npm-wasm/README.md index 113d76f3..3689cd8f 100644 --- a/packages/npm-wasm/README.md +++ b/packages/npm-wasm/README.md @@ -74,6 +74,8 @@ const installed = await dbc.listInstalled("/etc/adbc/drivers"); await dbc.uninstall("snowflake", "/etc/adbc/drivers"); ``` +On WebAssembly, uninstall removes the driver registration while retaining the installed package files. + CommonJS works too: ```js diff --git a/packages/npm-wasm/boot.cjs b/packages/npm-wasm/boot.cjs index 35585909..3d8bf067 100644 --- a/packages/npm-wasm/boot.cjs +++ b/packages/npm-wasm/boot.cjs @@ -17,6 +17,43 @@ const fs = require("fs"); const path = require("path"); +function hostPathResult(fn) { + try { + return { value: fn(), error: "" }; + } catch (e) { + return { value: "", error: e && e.message ? e.message : String(e) }; + } +} + +function createHostPathAdapter(platform, filesystem = fs, pathModule = path) { + if (platform !== "win32") return null; + const win = pathModule.win32; + const slash = (value) => String(value).replace(/\\/g, "/"); + return Object.freeze({ + isWindows: () => ({ value: true, error: "" }), + abs: (p) => hostPathResult(() => slash(win.resolve(String(p)))), + isAbs: (p) => ({ value: win.isAbsolute(String(p)), error: "" }), + clean: (p) => hostPathResult(() => slash(win.normalize(String(p)))), + join: (...parts) => hostPathResult(() => slash(win.join(...parts.map(String)))), + dir: (p) => hostPathResult(() => slash(win.dirname(String(p)))), + base: (p) => hostPathResult(() => win.basename(String(p))), + evalSymlinks: (p) => hostPathResult(() => slash((filesystem.realpathSync.native || filesystem.realpathSync)(String(p)))), + equal: (a, b) => ({ value: win.resolve(String(a)).toLowerCase() === win.resolve(String(b)).toLowerCase(), error: "" }), + mkdirAll: (p) => hostPathResult(() => filesystem.mkdirSync(String(p), { recursive: true })), + }); +} + +function installHostPathAdapter(platform) { + const adapter = createHostPathAdapter(platform); + if (!adapter) return; + Object.defineProperty(globalThis, "dbcHostPath", { + configurable: false, + enumerable: false, + writable: false, + value: adapter, + }); +} + // curateGoEnv builds the minimal environment handed to the Go js/wasm runtime. // wasm_exec.js caps the combined argv+env size; forwarding a full process.env // (notably on Windows CI runners, whose environment is large) overflows that cap @@ -56,6 +93,8 @@ async function bootRuntime() { if (!globalThis.fs) globalThis.fs = fs; if (!globalThis.process) globalThis.process = process; + installHostPathAdapter(process.platform); + require("./wasm_exec.js"); // defines globalThis.Go const go = new globalThis.Go(); @@ -73,3 +112,5 @@ async function bootRuntime() { module.exports = bootRuntime; module.exports.curateGoEnv = curateGoEnv; +module.exports.installHostPathAdapter = installHostPathAdapter; +module.exports.createHostPathAdapter = createHostPathAdapter; diff --git a/packages/npm-wasm/test/normalize.test.cjs b/packages/npm-wasm/test/normalize.test.cjs index 4709d9b0..3e678141 100644 --- a/packages/npm-wasm/test/normalize.test.cjs +++ b/packages/npm-wasm/test/normalize.test.cjs @@ -16,7 +16,7 @@ const assert = require("assert"); const { normalizeLocation } = require("../index.cjs"); -const { curateGoEnv } = require("../boot.cjs"); +const { curateGoEnv, installHostPathAdapter, createHostPathAdapter } = require("../boot.cjs"); function withPlatform(platform, fn) { const orig = Object.getOwnPropertyDescriptor(process, "platform"); @@ -46,6 +46,7 @@ withPlatform("win32", () => { ["C:drivers", "C:/drivers"], ["C:", "C:/"], ["d:\\Lower", "d:/Lower"], + ["\\\\server\\share\\drivers", "//server/share/drivers"], ]; for (const [input, want] of cases) { assert.strictEqual(normalizeLocation(input), want, `win32 ${JSON.stringify(input)}`); @@ -110,3 +111,40 @@ const ALLOWED_ENV_KEYS = ["HOME", "TMPDIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", assert.strictEqual(curateGoEnv({ HOME: "/home/a\\b" }, "linux").HOME, "/home/a\\b", "posix backslash passthrough"); console.log("curateGoEnv: Windows TMPDIR/HOME mapping + POSIX passthrough + env bounded passed"); + +// The js/wasm target uses slash-based filepath semantics even when Node runs +// on Windows. Its immutable host adapter must retain drive and UNC paths. +installHostPathAdapter("win32"); +const hostPath = globalThis.dbcHostPath; +assert(Object.isFrozen(hostPath), "host path adapter is immutable"); +assert.strictEqual(hostPath.abs("C:/drivers").value, "C:/drivers", "absolute drive path remains native"); +assert.strictEqual(hostPath.isAbs("C:/drivers").value, true, "drive path is absolute"); +assert.strictEqual(hostPath.clean("C:/drivers/../shared").value, "C:/shared", "Windows clean semantics"); +assert.strictEqual(hostPath.join("C:/drivers", "generation", "driver.dll").value, "C:/drivers/generation/driver.dll", "Windows join semantics"); +assert.strictEqual(hostPath.dir("C:/drivers/generation").value, "C:/drivers", "Windows dirname semantics"); +assert.strictEqual(hostPath.base("C:/drivers/generation").value, "generation", "Windows basename semantics"); +assert.strictEqual(hostPath.equal("C:/Drivers", "c:/drivers").value, true, "Windows path equality ignores case"); +assert.strictEqual(hostPath.abs("//server/share/drivers").value, "//server/share/drivers", "UNC absolute path remains native"); +assert.strictEqual(hostPath.isAbs("//server/share/drivers").value, true, "UNC path is absolute"); +assert.strictEqual(hostPath.join("//server/share/drivers", "generation", "driver.dll").value, "//server/share/drivers/generation/driver.dll", "UNC join keeps server share"); +assert.strictEqual(hostPath.dir("//server/share/drivers/generation").value, "//server/share/drivers", "UNC dirname keeps server share"); + +const fsCalls = []; +const fakeFS = { + realpathSync: Object.assign((p) => p, { native: (p) => { fsCalls.push(["realpath", p]); return "C:\\real\\drivers"; } }), + mkdirSync: (p, options) => fsCalls.push(["mkdir", p, options]), +}; +const bridge = createHostPathAdapter("win32", fakeFS, require("path")); +assert.strictEqual(bridge.evalSymlinks("C:/drivers").value, "C:/real/drivers", "filesystem canonical path normalized for Go"); +assert.strictEqual(bridge.mkdirAll("C:/drivers").error, "", "native Windows recursive mkdir succeeds"); +assert.deepStrictEqual(fsCalls, [ + ["realpath", "C:/drivers"], + ["mkdir", "C:/drivers", { recursive: true }], +], "filesystem bridge receives native drive paths"); +assert.strictEqual(bridge.evalSymlinks("//server/share/drivers").value, "C:/real/drivers", "UNC canonicalization uses the host filesystem"); +assert.strictEqual(bridge.mkdirAll("//server/share/drivers").error, "", "UNC recursive mkdir succeeds"); +assert.deepStrictEqual(fsCalls.slice(-2), [ + ["realpath", "//server/share/drivers"], + ["mkdir", "//server/share/drivers", { recursive: true }], +], "filesystem bridge preserves UNC paths"); +console.log("host path adapter: Windows and UNC paths preserved through the filesystem bridge"); diff --git a/packages/npm-wasm/test/smoke.cjs b/packages/npm-wasm/test/smoke.cjs index 60a8b466..07e5f5c1 100644 --- a/packages/npm-wasm/test/smoke.cjs +++ b/packages/npm-wasm/test/smoke.cjs @@ -15,6 +15,7 @@ "use strict"; const assert = require("assert"); +const crypto = require("crypto"); const fs = require("fs"); const http = require("http"); const os = require("os"); @@ -25,11 +26,27 @@ const { loadDbc } = require(".."); const REPO_ROOT = path.resolve(__dirname, "..", "..", ".."); const indexData = fs.readFileSync(path.join(REPO_ROOT, "cmd/dbc/testdata/test_index.yaml")); const tarData = fs.readFileSync(path.join(REPO_ROOT, "cmd/dbc/testdata/test-driver-1.tar.gz")); +const manifestOnlyTarData = fs.readFileSync(path.join(REPO_ROOT, "cmd/dbc/testdata/test-driver-manifest-only.tar.gz")); + +function listEntries(root, directory = root, entries = []) { + for (const name of fs.readdirSync(directory)) { + const entryPath = path.join(directory, name); + entries.push(path.relative(root, entryPath)); + if (fs.lstatSync(entryPath).isDirectory()) { + listEntries(root, entryPath, entries); + } + } + return entries.sort(); +} const server = http.createServer((req, res) => { if (req.url.startsWith("/index.yaml")) { res.setHeader("Content-Type", "application/yaml"); res.end(indexData); + } else if (req.url.includes("test-driver-manifest-only")) { + res.setHeader("Content-Type", "application/gzip"); + res.setHeader("Content-Length", String(manifestOnlyTarData.length)); + res.end(manifestOnlyTarData); } else if (req.url.includes(".tar.gz")) { res.setHeader("Content-Type", "application/gzip"); res.setHeader("Content-Length", String(tarData.length)); @@ -40,13 +57,6 @@ const server = http.createServer((req, res) => { } }); -function findFile(dir, suffix) { - for (const entry of fs.readdirSync(dir, { recursive: true })) { - if (entry.toString().endsWith(suffix)) return path.join(dir, entry.toString()); - } - return null; -} - async function main() { await new Promise((r) => server.listen(0, "127.0.0.1", r)); const base = `http://127.0.0.1:${server.address().port}`; @@ -60,23 +70,74 @@ async function main() { assert(resolved.versions.length > 0, "resolve returned no versions"); const installDir = fs.mkdtempSync(path.join(os.tmpdir(), "dbc-wasm-smoke-")); + const externalLibrary = path.join(installDir, "external-driver.so"); + const externalBytes = Buffer.from("externally managed driver library"); + fs.writeFileSync(externalLibrary, externalBytes); const manifest = await dbc.install("test-driver-1", installDir); assert(manifest.driverPath && fs.existsSync(manifest.driverPath), "installed driver missing on disk"); + const generationDir = path.dirname(manifest.driverPath); + assert(fs.existsSync(generationDir), "installed package generation missing on disk"); + const installedBytes = fs.readFileSync(manifest.driverPath); + const receiptPath = path.join(generationDir, "dbc-install-receipt.json"); + const receiptBytes = fs.readFileSync(receiptPath); + const receipt = JSON.parse(receiptBytes); + assert.strictEqual(receipt.generation, path.basename(generationDir), "receipt does not identify the installed generation"); + assert.strictEqual( + receipt.owned_library_sha256, + crypto.createHash("sha256").update(installedBytes).digest("hex"), + "receipt hash does not match the installed library" + ); + + const entriesBeforeManifestOnlyInstall = listEntries(installDir); + await assert.rejects( + dbc.install("test-driver-manifest-only", installDir), + /does not specify Files\.driver/, + "manifest-only package should be rejected without Files.driver" + ); + assert.deepStrictEqual( + listEntries(installDir), + entriesBeforeManifestOnlyInstall, + "rejected manifest-only package published files" + ); + assert(!fs.existsSync(path.join(installDir, "test-driver-manifest-only.toml")), "manifest-only registration was published"); + assert.deepStrictEqual(fs.readFileSync(manifest.driverPath), installedBytes, "rejected package changed the installed driver bytes"); + assert.deepStrictEqual(fs.readFileSync(receiptPath), receiptBytes, "rejected package changed the installed receipt"); + + // Keep a separate registration backed by an external library so uninstall + // continues to cover the existing rule that external files are not owned. + fs.writeFileSync( + path.join(installDir, "external-borrower.toml"), + `name = "External Borrower"\nversion = "1.0.0"\nsource = "external"\n\n[Driver.shared]\nlinux_amd64 = ${JSON.stringify(externalLibrary)}\n` + ); const installed = await dbc.listInstalled(installDir); assert( - installed.length === 1 && installed[0].id === "test-driver-1", + installed.length === 2 && installed.some((driver) => driver.id === "test-driver-1") && installed.some((driver) => driver.id === "external-borrower"), `listInstalled mismatch: got ${JSON.stringify(installed)}; installDir entries: ${JSON.stringify(fs.readdirSync(installDir, { recursive: true }))}` ); - const so = findFile(installDir, ".so"); - const sig = findFile(installDir, ".sig"); + const so = manifest.driverPath; + const sig = `${manifest.driverPath}.sig`; const ok = await dbc.verifySignature(new Uint8Array(fs.readFileSync(so)), new Uint8Array(fs.readFileSync(sig))); assert(ok === true, "verifySignature failed for a valid signature"); await dbc.uninstall("test-driver-1", installDir); const after = await dbc.listInstalled(installDir); - assert(after.length === 0, "driver still listed after uninstall"); + assert( + after.length === 1 && after[0].id === "external-borrower", + `registration state after uninstall mismatch: ${JSON.stringify(after)}` + ); + assert(!fs.existsSync(path.join(installDir, "test-driver-1.toml")), "package registration remains after uninstall"); + assert(fs.existsSync(generationDir), "un-pinned package generation was not retained after uninstall"); + assert.deepStrictEqual(fs.readFileSync(manifest.driverPath), installedBytes, "retained package library bytes changed after uninstall"); + assert.deepStrictEqual(fs.readFileSync(receiptPath), receiptBytes, "retained package receipt changed after uninstall"); + assert.deepStrictEqual(fs.readFileSync(externalLibrary), externalBytes, "external library changed after uninstall"); + assert(fs.existsSync(path.join(installDir, "external-borrower.toml")), "external library registration was removed"); + + await dbc.uninstall("external-borrower", installDir); + assert(!(await dbc.listInstalled(installDir)).some((driver) => driver.id === "external-borrower"), "external registration remains after uninstall"); + assert(!fs.existsSync(path.join(installDir, "external-borrower.toml")), "external registration manifest remains after uninstall"); + assert.deepStrictEqual(fs.readFileSync(externalLibrary), externalBytes, "external library changed when its registration was removed"); // Regression guard (roborev 6562): in-process loadDbc() must namespace // load-time client-construction failures with `dbc-wasm:`, matching the worker diff --git a/packages/npm-wasm/test/worker.test.cjs b/packages/npm-wasm/test/worker.test.cjs index 8279ae95..d0aba082 100644 --- a/packages/npm-wasm/test/worker.test.cjs +++ b/packages/npm-wasm/test/worker.test.cjs @@ -15,6 +15,7 @@ "use strict"; const assert = require("assert"); +const crypto = require("crypto"); const fs = require("fs"); const http = require("http"); const os = require("os"); @@ -25,11 +26,27 @@ const { loadDbc } = require(".."); const REPO_ROOT = path.resolve(__dirname, "..", "..", ".."); const indexData = fs.readFileSync(path.join(REPO_ROOT, "cmd/dbc/testdata/test_index.yaml")); const tarData = fs.readFileSync(path.join(REPO_ROOT, "cmd/dbc/testdata/test-driver-1.tar.gz")); +const manifestOnlyTarData = fs.readFileSync(path.join(REPO_ROOT, "cmd/dbc/testdata/test-driver-manifest-only.tar.gz")); + +function listEntries(root, directory = root, entries = []) { + for (const name of fs.readdirSync(directory)) { + const entryPath = path.join(directory, name); + entries.push(path.relative(root, entryPath)); + if (fs.lstatSync(entryPath).isDirectory()) { + listEntries(root, entryPath, entries); + } + } + return entries.sort(); +} const server = http.createServer((req, res) => { if (req.url.startsWith("/index.yaml")) { res.setHeader("Content-Type", "application/yaml"); res.end(indexData); + } else if (req.url.includes("test-driver-manifest-only")) { + res.setHeader("Content-Type", "application/gzip"); + res.setHeader("Content-Length", String(manifestOnlyTarData.length)); + res.end(manifestOnlyTarData); } else if (req.url.includes(".tar.gz")) { res.setHeader("Content-Type", "application/gzip"); res.setHeader("Content-Length", String(tarData.length)); @@ -40,13 +57,6 @@ const server = http.createServer((req, res) => { } }); -function findFile(dir, suffix) { - for (const entry of fs.readdirSync(dir, { recursive: true })) { - if (entry.toString().endsWith(suffix)) return path.join(dir, entry.toString()); - } - return null; -} - async function main() { await new Promise((r) => server.listen(0, "127.0.0.1", r)); const base = `http://127.0.0.1:${server.address().port}`; @@ -57,19 +67,71 @@ async function main() { assert(search.drivers.length > 0, "search returned no drivers"); const installDir = fs.mkdtempSync(path.join(os.tmpdir(), "dbc-wasm-worker-")); + const externalLibrary = path.join(installDir, "external-driver.so"); + const externalBytes = Buffer.from("externally managed driver library"); + fs.writeFileSync(externalLibrary, externalBytes); const manifest = await dbc.install("test-driver-1", installDir); assert(manifest.driverPath && fs.existsSync(manifest.driverPath), "installed driver missing on disk"); + const generationDir = path.dirname(manifest.driverPath); + assert(fs.existsSync(generationDir), "installed package generation missing on disk"); + const installedBytes = fs.readFileSync(manifest.driverPath); + const receiptPath = path.join(generationDir, "dbc-install-receipt.json"); + const receiptBytes = fs.readFileSync(receiptPath); + const receipt = JSON.parse(receiptBytes); + assert.strictEqual(receipt.generation, path.basename(generationDir), "receipt does not identify the installed generation"); + assert.strictEqual( + receipt.owned_library_sha256, + crypto.createHash("sha256").update(installedBytes).digest("hex"), + "receipt hash does not match the installed library" + ); + + const entriesBeforeManifestOnlyInstall = listEntries(installDir); + await assert.rejects( + dbc.install("test-driver-manifest-only", installDir), + /does not specify Files\.driver/, + "manifest-only package should be rejected without Files.driver" + ); + assert.deepStrictEqual( + listEntries(installDir), + entriesBeforeManifestOnlyInstall, + "rejected manifest-only package published files" + ); + assert(!fs.existsSync(path.join(installDir, "test-driver-manifest-only.toml")), "manifest-only registration was published"); + assert.deepStrictEqual(fs.readFileSync(manifest.driverPath), installedBytes, "rejected package changed the installed driver bytes"); + assert.deepStrictEqual(fs.readFileSync(receiptPath), receiptBytes, "rejected package changed the installed receipt"); + + // Keep a separate registration backed by an external library so uninstall + // continues to cover the existing rule that external files are not owned. + fs.writeFileSync( + path.join(installDir, "external-borrower.toml"), + `name = "External Borrower"\nversion = "1.0.0"\nsource = "external"\n\n[Driver.shared]\nlinux_amd64 = ${JSON.stringify(externalLibrary)}\n` + ); const installed = await dbc.listInstalled(installDir); - assert(installed.length === 1 && installed[0].id === "test-driver-1", "listInstalled mismatch"); + assert( + installed.length === 2 && installed.some((driver) => driver.id === "test-driver-1") && installed.some((driver) => driver.id === "external-borrower"), + `listInstalled mismatch: ${JSON.stringify(installed)}` + ); - const so = findFile(installDir, ".so"); - const sig = findFile(installDir, ".sig"); + const so = manifest.driverPath; + const sig = `${manifest.driverPath}.sig`; const ok = await dbc.verifySignature(new Uint8Array(fs.readFileSync(so)), new Uint8Array(fs.readFileSync(sig))); assert(ok === true, "verifySignature failed for a valid signature"); await dbc.uninstall("test-driver-1", installDir); - assert((await dbc.listInstalled(installDir)).length === 0, "driver still listed after uninstall"); + const after = await dbc.listInstalled(installDir); + assert(after.length === 1 && after[0].id === "external-borrower", `registration state after uninstall mismatch: ${JSON.stringify(after)}`); + assert(!fs.existsSync(path.join(installDir, "test-driver-1.toml")), "package registration remains after uninstall"); + assert(fs.existsSync(generationDir), "un-pinned package generation was not retained after uninstall"); + assert.deepStrictEqual(fs.readFileSync(manifest.driverPath), installedBytes, "retained package library bytes changed after uninstall"); + assert.deepStrictEqual(fs.readFileSync(receiptPath), receiptBytes, "retained package receipt changed after uninstall"); + assert.deepStrictEqual(fs.readFileSync(externalLibrary), externalBytes, "external library changed after uninstall"); + assert(fs.existsSync(path.join(installDir, "external-borrower.toml")), "external library registration was removed"); + + await dbc.uninstall("external-borrower", installDir); + assert(!(await dbc.listInstalled(installDir)).some((driver) => driver.id === "external-borrower"), "external registration remains after uninstall"); + assert(!fs.existsSync(path.join(installDir, "external-borrower.toml")), "external registration manifest remains after uninstall"); + assert.deepStrictEqual(fs.readFileSync(externalLibrary), externalBytes, "external library changed when its registration was removed"); await dbc.close();