diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index 7d6d475a..ef00eb35 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -66,6 +66,11 @@ jobs: go-version-file: "./go.mod" cache: true cache-dependency-path: go.sum + - name: Compile Packslip source resolution for WebAssembly + run: go build ./internal/packslip ./internal/packslipverify ./internal/sourceresolution + env: + GOOS: js + GOARCH: wasm - name: Node version run: node --version - name: Build @columnar-tech/dbc-wasm diff --git a/.typos.toml b/.typos.toml index 72761098..32253543 100644 --- a/.typos.toml +++ b/.typos.toml @@ -13,7 +13,7 @@ # limitations under the License. [files] -extend-exclude = ["columnar.pubkey", "go.mod", "go.sum", "pixi.lock"] +extend-exclude = ["columnar.pubkey", "go.mod", "go.sum", "pixi.lock", "internal/packslipverify/testdata/bundle-provenance.json"] [type.fo] extend-glob = ["*.go", ".goreleaser.yaml"] diff --git a/client_methods.go b/client_methods.go index 4169aa81..70395e65 100644 --- a/client_methods.go +++ b/client_methods.go @@ -137,6 +137,9 @@ func (c *Client) getDriverListFromIndex(ctx context.Context, index *Registry) ([ for i := range drivers.Drivers { drivers.Drivers[i].Registry = index } + if err := validateRegistryMetadata(drivers.Drivers); err != nil { + return nil, fmt.Errorf("failed to parse driver registry index: %w", err) + } result := drivers.Drivers sort.Slice(result, func(i, j int) bool { @@ -271,25 +274,56 @@ func (c *Client) Install(ctx context.Context, cfg config.Config, driverName stri if err != nil { return nil, fmt.Errorf("failed to get package for driver %s: %w", driverName, err) } + expected, err := expectedRegistryPackageMetadata(pkg) + if err != nil { + return nil, fmt.Errorf("failed to install driver %s: %w", driverName, err) + } f, err := c.downloadPackage(ctx, pkg) if err != nil { return nil, fmt.Errorf("failed to download driver %s: %w", driverName, err) } + defer f.Close() defer os.RemoveAll(filepath.Dir(f.Name())) - manifest, err := config.InstallDriver(cfg, driverName, f) + manifest, err := config.InstallPackage(cfg, expected.ID, f, expected, config.InstallOptions{ + Verify: func(stagingDir string, manifest config.Manifest) error { + return VerifyPackageSignature(stagingDir, manifest) + }, + }) 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 } +func expectedRegistryPackageMetadata(pkg PkgInfo) (config.ExpectedPackageMetadata, error) { + hasHash := pkg.ArtifactHash != "" + if pkg.Version == nil { + return config.ExpectedPackageMetadata{}, errors.New("registry package metadata is missing its version") + } + if strings.TrimSpace(pkg.PlatformTuple) == "" { + return config.ExpectedPackageMetadata{}, errors.New("registry package metadata is missing its platform") + } + if pkg.Driver.Registry == nil || pkg.Driver.Registry.BaseURL == nil { + return config.ExpectedPackageMetadata{}, errors.New("registry package metadata is missing its source identity") + } + if strings.TrimSpace(pkg.Driver.Path) == "" { + return config.ExpectedPackageMetadata{}, errors.New("registry package metadata is missing its driver ID") + } + expected := config.ExpectedPackageMetadata{ + ID: pkg.Driver.Path, + Version: pkg.Version.String(), + Platform: pkg.PlatformTuple, + SourceType: "registry", SourceIdentity: pkg.Driver.Registry.BaseURL.String(), + } + if hasHash { + expected.ArchiveHash = pkg.ArtifactHash + } + return expected, nil +} + // Uninstall uninstalls a driver with the given name from the specified configuration. func (c *Client) Uninstall(cfg config.Config, driverName string) error { di, err := config.GetDriver(cfg, driverName) diff --git a/client_methods_test.go b/client_methods_test.go index 744a685b..d945cd55 100644 --- a/client_methods_test.go +++ b/client_methods_test.go @@ -15,6 +15,9 @@ package dbc_test import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" "fmt" "io" "net/http" @@ -31,6 +34,69 @@ import ( "github.com/stretchr/testify/require" ) +func readClientLegacyArchive(t *testing.T) []byte { + t.Helper() + archive, err := os.ReadFile(filepath.Join("cmd", "dbc", "testdata", "test-driver-1.1.tar.gz")) + require.NoError(t, err) + return archive +} + +func newArchiveMetadataInstallServer(t *testing.T, archive []byte, metadata string) *httptest.Server { + t.Helper() + digest := sha256.Sum256(archive) + index := fmt.Sprintf(`drivers: + - name: Test Driver + path: test-driver-1 + pkginfo: + - version: v1.1.0 + packages: + - platform: %s + url: package.tar.gz +`, config.PlatformTuple()) + if metadata == "complete" { + index += fmt.Sprintf(" hash: %q\n future_metadata: ignored\n", "sha256:"+hex.EncodeToString(digest[:])) + } else if metadata == "hash-only" { + index += fmt.Sprintf(" hash: %q\n", "sha256:"+hex.EncodeToString(digest[:])) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/index.yaml": + _, _ = w.Write([]byte(index)) + case "/package.tar.gz": + _, _ = w.Write(archive) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + return server +} + +func newLegacyInstallServer(t *testing.T, archive []byte) *httptest.Server { + t.Helper() + index := fmt.Sprintf(`drivers: + - name: Test Driver + path: test-driver-1 + pkginfo: + - version: v1.1.0 + packages: + - platform: %s + url: package.tar.gz +`, config.PlatformTuple()) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/index.yaml": + _, _ = w.Write([]byte(index)) + case "/package.tar.gz": + _, _ = w.Write(archive) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + return server +} + func newTestClientForServer(t *testing.T, serverURL string) *dbc.Client { t.Helper() c, err := dbc.NewClient( @@ -47,7 +113,7 @@ func newInstallTestServer(t *testing.T) *httptest.Server { indexData, err := os.ReadFile(filepath.Join("cmd", "dbc", "testdata", "test_index.yaml")) require.NoError(t, err) - tarballData, err := os.ReadFile(filepath.Join("cmd", "dbc", "testdata", "test-driver-1.tar.gz")) + tarballData, err := os.ReadFile(filepath.Join("cmd", "dbc", "testdata", "test-driver-1.1.tar.gz")) require.NoError(t, err) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -133,6 +199,132 @@ func TestClientInstall(t *testing.T) { }) } +func TestClientInstallSignatureFailurePreservesExistingInstallation(t *testing.T) { + oldArchive, err := os.ReadFile(filepath.Join("cmd", "dbc", "testdata", "test-driver-1.tar.gz")) + require.NoError(t, err) + badArchive, err := os.ReadFile(filepath.Join("cmd", "dbc", "testdata", "test-driver-no-sig.tar.gz")) + require.NoError(t, err) + archivePath := filepath.Join(t.TempDir(), "old.tar.gz") + require.NoError(t, os.WriteFile(archivePath, oldArchive, 0o600)) + oldFile, err := os.Open(archivePath) + require.NoError(t, err) + defer oldFile.Close() + + root := t.TempDir() + cfg := config.Config{Level: config.ConfigEnv, Location: root} + oldExpected := config.ExpectedPackageMetadata{ + ID: "test-driver-1", Version: "1.0.0", Platform: config.PlatformTuple(), + SourceType: "registry", SourceIdentity: "https://registry.example.test", + } + oldManifest, err := config.InstallPackage(cfg, "test-driver-1", oldFile, oldExpected, config.InstallOptions{ + Verify: func(stagingDir string, manifest config.Manifest) error { + return dbc.VerifyPackageSignature(stagingDir, manifest) + }, + }) + require.NoError(t, err) + oldManifestBytes, err := os.ReadFile(filepath.Join(root, "test-driver-1.toml")) + require.NoError(t, err) + oldLibraryPath := oldManifest.Driver.Shared.Get(config.PlatformTuple()) + oldLibraryBytes, err := os.ReadFile(oldLibraryPath) + require.NoError(t, err) + + server := newLegacyInstallServer(t, badArchive) + client := newTestClientForServer(t, server.URL) + _, err = client.Install(t.Context(), cfg, "test-driver-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "signature file 'test-driver-1-not-valid.so.sig' for driver is missing") + + manifestBytes, err := os.ReadFile(filepath.Join(root, "test-driver-1.toml")) + require.NoError(t, err) + assert.Equal(t, oldManifestBytes, manifestBytes) + libraryBytes, err := os.ReadFile(oldLibraryPath) + require.NoError(t, err) + assert.Equal(t, oldLibraryBytes, libraryBytes) +} + +func TestClientInstallLegacyMetadataMismatchPreservesExistingInstallation(t *testing.T) { + archive, err := os.ReadFile(filepath.Join("cmd", "dbc", "testdata", "test-driver-1.tar.gz")) + require.NoError(t, err) + archivePath := filepath.Join(t.TempDir(), "old.tar.gz") + require.NoError(t, os.WriteFile(archivePath, archive, 0o600)) + oldFile, err := os.Open(archivePath) + require.NoError(t, err) + defer oldFile.Close() + + root := t.TempDir() + cfg := config.Config{Level: config.ConfigEnv, Location: root} + oldManifest, err := config.InstallPackage(cfg, "test-driver-1", oldFile, config.ExpectedPackageMetadata{ + ID: "test-driver-1", Version: "1.0.0", Platform: config.PlatformTuple(), + SourceType: "registry", SourceIdentity: "https://registry.example.test", + }, config.InstallOptions{ + Verify: func(stagingDir string, manifest config.Manifest) error { + return dbc.VerifyPackageSignature(stagingDir, manifest) + }, + }) + require.NoError(t, err) + oldManifestBytes, err := os.ReadFile(filepath.Join(root, "test-driver-1.toml")) + require.NoError(t, err) + oldLibraryPath := oldManifest.Driver.Shared.Get(config.PlatformTuple()) + oldLibraryBytes, err := os.ReadFile(oldLibraryPath) + require.NoError(t, err) + + server := newLegacyInstallServer(t, archive) + client := newTestClientForServer(t, server.URL) + _, err = client.Install(t.Context(), cfg, "test-driver-1") + require.Error(t, err) + assert.Contains(t, err.Error(), `package version mismatch: archive declares "1.0.0", expected "1.1.0"`) + + manifestBytes, err := os.ReadFile(filepath.Join(root, "test-driver-1.toml")) + require.NoError(t, err) + assert.Equal(t, oldManifestBytes, manifestBytes) + libraryBytes, err := os.ReadFile(oldLibraryPath) + require.NoError(t, err) + assert.Equal(t, oldLibraryBytes, libraryBytes) +} + +func TestClientInstallLegacyPackageUsesRegistryMetadata(t *testing.T) { + archive := readClientLegacyArchive(t) + srv := newArchiveMetadataInstallServer(t, archive, "complete") + c := newTestClientForServer(t, srv.URL) + root := t.TempDir() + cfg := config.Config{Level: config.ConfigEnv, Location: root} + + manifest, err := c.Install(t.Context(), cfg, "test-driver-1") + require.NoError(t, err) + require.NotNil(t, manifest) + + var receipt config.InstallReceipt + receiptBytes, err := os.ReadFile(filepath.Join(filepath.Dir(manifest.DriverInfo.Driver.Shared.Get(config.PlatformTuple())), "dbc-install-receipt.json")) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(receiptBytes, &receipt)) + assert.Equal(t, "registry", receipt.SourceType) + assert.Equal(t, srv.URL, receipt.SourceIdentity) + assert.NotEmpty(t, receipt.ArchiveHash) + assert.Positive(t, receipt.ArchiveSize) + + runtimeManifest, err := os.ReadFile(filepath.Join(root, "test-driver-1.toml")) + require.NoError(t, err) + assert.Contains(t, string(runtimeManifest), "manifest_version = 1") +} + +func TestClientInstallAcceptsHashOnlyRegistryArchiveMetadata(t *testing.T) { + archive := readClientLegacyArchive(t) + srv := newArchiveMetadataInstallServer(t, archive, "hash-only") + c := newTestClientForServer(t, srv.URL) + root := t.TempDir() + cfg := config.Config{Level: config.ConfigEnv, Location: root} + + manifest, err := c.Install(t.Context(), cfg, "test-driver-1") + require.NoError(t, err) + require.NotNil(t, manifest) + var receipt config.InstallReceipt + receiptBytes, err := os.ReadFile(filepath.Join(filepath.Dir(manifest.DriverInfo.Driver.Shared.Get(config.PlatformTuple())), "dbc-install-receipt.json")) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(receiptBytes, &receipt)) + assert.NotEmpty(t, receipt.ArchiveHash) + assert.Positive(t, receipt.ArchiveSize, "receipt retains the measured archive size") +} + func TestClientUninstall(t *testing.T) { srv := newInstallTestServer(t) c := newTestClientForServer(t, srv.URL) diff --git a/cmd/dbc/add.go b/cmd/dbc/add.go index eaeb606c..b3590196 100644 --- a/cmd/dbc/add.go +++ b/cmd/dbc/add.go @@ -16,18 +16,24 @@ package main import ( "bytes" + "context" "encoding/json" "errors" "fmt" "os" "path/filepath" + "strings" "time" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc" "github.com/columnar-tech/dbc/config" "github.com/columnar-tech/dbc/internal/jsonschema" + "github.com/columnar-tech/dbc/internal/packslip" + "github.com/columnar-tech/dbc/internal/resolution" + "github.com/columnar-tech/dbc/internal/sourceresolution" "github.com/pelletier/go-toml/v2" ) @@ -62,6 +68,38 @@ func driverListPath(path string) (string, error) { return p, nil } +// exactSourceVersionArgument extracts an exact version from the original add +// argument without allowing Masterminds' permissive constraint parser to +// coerce non-SemVer spellings. The CLI's equality operator is optional after +// the driver/version separator. +func exactSourceVersionArgument(input string, required bool) (string, error) { + input = strings.TrimSpace(input) + split := strings.IndexAny(input, " ~^<>=!") + if split < 0 { + if required { + return "", errors.New("an exact version is required") + } + return "", nil + } + version := strings.TrimSpace(input[split:]) + if strings.HasPrefix(version, "=") { + version = strings.TrimSpace(strings.TrimPrefix(version, "=")) + } + parsed, err := semver.StrictNewVersion(version) + if err != nil || parsed.String() != version { + return "", fmt.Errorf("%q is not an exact canonical SemVer 2.0.0 version", version) + } + return version, nil +} + +func exactSourceVersionConstraint(version string) (*semver.Constraints, error) { + parsed, err := semver.StrictNewVersion(version) + if err != nil || parsed.String() != version { + return nil, fmt.Errorf("%q is not an exact canonical SemVer 2.0.0 version", version) + } + return semver.NewConstraint(version) +} + type AddCmd struct { Driver []string `arg:"positional,required" help:"One or more drivers to add, optionally with a version constraint (for example: mysql, mysql=0.1.0, mysql>=1,<2)"` Path string `arg:"-p" placeholder:"FILE" default:"./dbc.toml" help:"Driver list to add to"` @@ -94,6 +132,11 @@ type addDoneMsg struct { resolvedPath string } +type addTargetSnapshot struct { + present bool + source *dbc.DriverSource +} + type addModel struct { baseModel @@ -108,8 +151,9 @@ type addModel struct { func (m addModel) Init() tea.Cmd { type driverInput struct { - Name string - Vers *semver.Constraints + Name string + Input string + Vers *semver.Constraints } var specs []driverInput @@ -119,7 +163,7 @@ func (m addModel) Init() tea.Cmd { return errCmd("invalid driver constraint '%s': %w", d, err) } - specs = append(specs, driverInput{Name: driverName, Vers: vers}) + specs = append(specs, driverInput{Name: driverName, Input: d, Vers: vers}) } return func() tea.Msg { @@ -129,8 +173,8 @@ func (m addModel) Init() tea.Cmd { } // Take the project lock briefly to read a consistent snapshot of - // dbc.toml, then release it before the registry lookup so a slow - // network fetch doesn't hold the lock. The lock is reacquired + // dbc.toml, then release it before source resolution so slow + // registry/network or local package I/O doesn't hold the lock. The lock is reacquired // below for the final merge/write phase. Without this short-lock // read, a concurrent writer using os.Create could expose partial // contents to this decode path. @@ -152,51 +196,149 @@ func (m addModel) Init() tea.Cmd { readLock.Release() return err } - f.Close() - readLock.Release() - - if err := applyProjectRegistries(m.list); err != nil { + if err := m.list.validateSources(); err != nil { + f.Close() + readLock.Release() return err } - - drivers, registryErr := m.getDriverRegistry() - // If we have no drivers and there's an error, fail immediately - if len(drivers) == 0 && registryErr != nil { - return fmt.Errorf("error getting driver list: %w", registryErr) + originalTargets := make(map[string]addTargetSnapshot, len(specs)) + for _, spec := range specs { + original, present := m.list.Drivers[spec.Name] + snapshot := addTargetSnapshot{present: present} + if original.Source != nil { + sourceCopy := *original.Source + snapshot.source = &sourceCopy + } + originalTargets[spec.Name] = snapshot } - // Store registry errors to use later if driver is not found - // We continue processing if we have some drivers - var registryErrors error = registryErr + f.Close() + readLock.Release() if m.list.Drivers == nil { m.list.Drivers = make(map[string]driverSpec) } + needsRegistry := false + for _, spec := range specs { + current := m.list.Drivers[spec.Name] + if current.Source == nil || current.Source.Type == dbc.DriverSourceRegistry { + needsRegistry = true + } + } + var drivers []dbc.Driver + var registryErrors error + if needsRegistry { + if err := applyProjectRegistries(m.list); err != nil { + return err + } + drivers, registryErrors = m.getDriverRegistry() + // If a registry-backed item needs lookup and no registry is + // available, preserve the existing add error contract. + if len(drivers) == 0 && registryErrors != nil { + return fmt.Errorf("error getting driver list: %w", registryErrors) + } + } var result string + var packslipResolver packslip.Resolver for i, spec := range specs { if i != 0 { result += "\n" } - drv, err := findDriver(spec.Name, drivers) - if err != nil { - return wrapWithRegistryContext(err, registryErrors) - } - - if spec.Vers != nil { - spec.Vers.IncludePrerelease = m.Pre - _, err = drv.GetWithConstraint(spec.Vers, config.PlatformTuple()) + current, ok := m.list.Drivers[spec.Name] + updatedVersion := spec.Vers + switch { + case current.Source != nil && current.Source.Type == dbc.DriverSourcePackslip: + if m.Pre { + return fmt.Errorf("driver %q Packslip source does not support `dbc add --pre`", spec.Name) + } + versionText, err := exactSourceVersionArgument(spec.Input, true) + if err != nil { + return fmt.Errorf("driver %q Packslip source requires an exact SemVer 2.0.0 version: %w", spec.Name, err) + } + updatedVersion, err = exactSourceVersionConstraint(versionText) if err != nil { - return fmt.Errorf("error getting driver: %w", err) + return fmt.Errorf("driver %q Packslip source has an invalid exact version: %w", spec.Name, err) } - } else { - if !m.Pre && !drv.HasNonPrerelease() { + if packslipResolver == nil { + if m.newPackslipResolver == nil { + return errors.New("no Packslip resolver is configured") + } + packslipResolver, err = m.newPackslipResolver() + if err != nil { + return fmt.Errorf("create Packslip resolver: %w", err) + } + } + candidateSpec := current + candidateSpec.Version = updatedVersion + requirement, err := requirementForDriverSpec(spec.Name, candidateSpec) + if err != nil { + return err + } + release, err := sourceresolution.ResolvePackslip( + context.Background(), packslipResolver, current.Source.Project, spec.Name, versionText) + if err != nil { + return fmt.Errorf("error resolving Packslip source: %w", err) + } + if _, err := requirement.ValidateResolverResult(release); err != nil { + return fmt.Errorf("Packslip resolver returned an invalid release: %w", err) + } + case current.Source != nil && current.Source.Type == dbc.DriverSourcePath: + if m.Pre { + return fmt.Errorf("driver %q path source does not support `dbc add --pre`", spec.Name) + } + versionText, err := exactSourceVersionArgument(spec.Input, false) + if err != nil { + return fmt.Errorf("driver %q path source requires an exact SemVer 2.0.0 version when specified: %w", spec.Name, err) + } + if versionText == "" { + updatedVersion = nil + } else { + updatedVersion, err = exactSourceVersionConstraint(versionText) + if err != nil { + return fmt.Errorf("driver %q path source has an invalid exact version: %w", spec.Name, err) + } + } + candidateSpec := current + candidateSpec.Version = updatedVersion + requirement, err := requirementForDriverSpec(spec.Name, candidateSpec) + if err != nil { + return err + } + target, err := resolution.TargetFromPlatformTuple(config.PlatformTuple()) + if err != nil { + return fmt.Errorf("unsupported add platform: %w", err) + } + release, err := sourceresolution.ResolvePath(context.Background(), current.Source.Path, sourceresolution.Request{ + DriverID: spec.Name, Version: versionText, Target: target, + Platform: config.PlatformTuple(), BaseDir: filepath.Dir(p), + }) + if err != nil { + return fmt.Errorf("error resolving path source: %w", err) + } + if _, err := requirement.ValidateResolverResult(release); err != nil { + return fmt.Errorf("path resolver returned an invalid package: %w", err) + } + default: + requirement, err := requirementForDriverSpec(spec.Name, current) + if err != nil { + return err + } + drv, err := registryDriverForRequirement(requirement, drivers, nil) + if err != nil { + return wrapWithRegistryContext(err, registryErrors) + } + if spec.Vers != nil { + spec.Vers.IncludePrerelease = m.Pre + _, err = drv.GetWithConstraint(spec.Vers, config.PlatformTuple()) + if err != nil { + return fmt.Errorf("error getting driver: %w", err) + } + } else if !m.Pre && !drv.HasNonPrerelease() { var err error if len(drv.PkgInfo) > 0 { - // Has packages, but they're all prereleases err = fmt.Errorf("driver `%s` not found in driver registry index (but prerelease versions filtered out); try: dbc add --pre %s", spec.Name, spec.Name) } else { - // No packages. Very unlikely edge case. err = fmt.Errorf("driver `%s` not found in driver registry index", spec.Name) } if registryErrors != nil { @@ -206,11 +348,13 @@ func (m addModel) Init() tea.Cmd { } } - current, ok := m.list.Drivers[spec.Name] - m.list.Drivers[spec.Name] = driverSpec{Version: spec.Vers} + updated := current + updated.Version = updatedVersion + updated.Prerelease = "" if m.Pre { - m.list.Drivers[spec.Name] = driverSpec{Version: spec.Vers, Prerelease: "allow"} + updated.Prerelease = "allow" } + m.list.Drivers[spec.Name] = updated new := m.list.Drivers[spec.Name] currentString := func() string { @@ -256,26 +400,45 @@ func (m addModel) Init() tea.Cmd { } else if !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("error re-reading driver list at %s: %w", m.Path, err) } + if err := current.validateSources(); err != nil { + return fmt.Errorf("error re-reading driver list under lock: %w", err) + } - // If the registry configuration changed while the (unlocked) - // registry lookup was in flight, the drivers we just validated - // may not exist under the new registry set. Abort so the caller - // can retry against the new configuration rather than write a - // potentially-inconsistent dbc.toml. - if registriesChanged(m.list, current) { + // If registry configuration changed while a registry-backed + // selection was being validated, abort rather than write against a + // potentially different registry set. + if needsRegistry && registriesChanged(m.list, current) { return fmt.Errorf("dbc.toml registry configuration changed while resolving drivers; please retry `dbc add`") } // Merge ONLY the driver entries this invocation actually added or // replaced onto whatever is on disk. Leaving untouched drivers, // registries, and replace_defaults alone preserves concurrent - // `dbc remove`/`dbc add` edits that landed while we were doing - // the unlocked registry lookup above. + // `dbc remove`/`dbc add` edits that landed while source resolution + // was in flight. if current.Drivers == nil { current.Drivers = make(map[string]driverSpec) } for _, spec := range specs { - current.Drivers[spec.Name] = m.list.Drivers[spec.Name] + updated := m.list.Drivers[spec.Name] + latest, present := current.Drivers[spec.Name] + original := originalTargets[spec.Name] + if original.present != present { + return fmt.Errorf("driver %q entry presence changed while resolving drivers; please retry `dbc add`", spec.Name) + } + if !sameDriverSourceIdentity(original.source, latest.Source) { + return fmt.Errorf("driver %q source changed while resolving drivers; please retry `dbc add`", spec.Name) + } + if latest.Source != nil { + // `add` only changes the version selection. Preserve the source + // value from the locked re-read after checking it still matches + // the source against which the version was validated. + updated.Source = latest.Source + } + current.Drivers[spec.Name] = updated + } + if err := current.validateSources(); err != nil { + return fmt.Errorf("updated driver list is invalid: %w", err) } wf, err := os.Create(p) diff --git a/cmd/dbc/add_test.go b/cmd/dbc/add_test.go index bcce93f4..03957c26 100644 --- a/cmd/dbc/add_test.go +++ b/cmd/dbc/add_test.go @@ -18,19 +18,31 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" + "net/url" "os" "path/filepath" + "strings" "testing" "time" tea "charm.land/bubbletea/v2" "github.com/columnar-tech/dbc" "github.com/columnar-tech/dbc/internal/jsonschema" + "github.com/columnar-tech/dbc/internal/packslip" + "github.com/columnar-tech/dbc/internal/resolution" + "github.com/pelletier/go-toml/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +type addPackslipResolverFunc func(context.Context, packslip.PackslipSource, packslip.Request) (resolution.ResolvedRelease, error) + +func (f addPackslipResolverFunc) Resolve(ctx context.Context, source packslip.PackslipSource, request packslip.Request) (resolution.ResolvedRelease, error) { + return f(ctx, source, request) +} + func TestAdd(t *testing.T) { dir := t.TempDir() var err error @@ -158,6 +170,515 @@ version = '>=1.0.0' } } +func TestAddUpdatingDriverPreservesSource(t *testing.T) { + t.Setenv("DBC_BASE_URL", "") + + dir := t.TempDir() + path := filepath.Join(dir, "dbc.toml") + const initial = "[drivers]\n" + + "[drivers.test-driver-1]\n" + + "version = '<=1.8.0'\n" + + "[drivers.test-driver-1.source]\n" + + "type = 'registry'\n" + + "url = 'https://registry.example.test/custom'\n" + require.NoError(t, os.WriteFile(path, []byte(initial), 0o644)) + expectedSource := &dbc.DriverSource{ + Type: dbc.DriverSourceRegistry, + URL: "https://registry.example.test/custom", + } + + runAdd := func(cmd AddCmd) DriversList { + t.Helper() + base := testBaseModel() + base.getDriverRegistry = func() ([]dbc.Driver, error) { + return []dbc.Driver{addTestRegistryDriver(t, expectedSource.URL, "1.0.0", "1.1.0")}, nil + } + msg := runTeaCmdToCompletion(t, cmd.GetModelCustom(base).(interface { + Init() tea.Cmd + Update(tea.Msg) (tea.Model, tea.Cmd) + })) + _, failed := msg.(error) + require.False(t, failed, "add failed: %v", msg) + data, err := os.ReadFile(path) + require.NoError(t, err) + var list DriversList + require.NoError(t, toml.Unmarshal(data, &list)) + require.NoError(t, list.validateSources()) + return list + } + + list := runAdd(AddCmd{Path: path, Driver: []string{"test-driver-1>=1.0.0"}}) + got := list.Drivers["test-driver-1"] + require.NotNil(t, got.Source) + assert.Equal(t, *expectedSource, *got.Source) + require.NotNil(t, got.Version) + assert.Equal(t, ">=1.0.0", got.Version.String()) + assert.Empty(t, got.Prerelease) + + prereleaseList := runAdd(AddCmd{Path: path, Driver: []string{"test-driver-1"}, Pre: true}) + got = prereleaseList.Drivers["test-driver-1"] + require.NotNil(t, got.Source) + assert.Equal(t, *expectedSource, *got.Source) + assert.Nil(t, got.Version) + assert.Equal(t, "allow", got.Prerelease) +} + +func addTestRegistryDriver(t *testing.T, registryURL string, versions ...string) dbc.Driver { + t.Helper() + + drivers, err := getTestDriverRegistry() + require.NoError(t, err) + for _, driver := range drivers { + if driver.Path != "test-driver-1" { + continue + } + + allowed := make(map[string]bool, len(versions)) + for _, version := range versions { + allowed[version] = true + } + filtered := driver.PkgInfo[:0] + for _, pkg := range driver.PkgInfo { + if allowed[pkg.Version.String()] { + filtered = append(filtered, pkg) + } + } + driver.PkgInfo = filtered + + baseURL, err := url.Parse(registryURL) + require.NoError(t, err) + driver.Registry = &dbc.Registry{BaseURL: baseURL} + return driver + } + t.Fatal("test driver test-driver-1 is missing from the fixture registry") + return dbc.Driver{} +} + +func TestAddExplicitRegistryUsesDeclaredRegistryForVersionValidation(t *testing.T) { + t.Setenv("DBC_BASE_URL", "") + + const registryA = "https://registry-a.example.test" + const registryB = "https://registry-b.example.test" + tests := []struct { + name string + declaredURL string + drivers []dbc.Driver + wantSuccess bool + }{ + { + name: "explicit B does not accept a version found only in A", + declaredURL: registryB, + drivers: []dbc.Driver{ + addTestRegistryDriver(t, registryA, "1.1.0"), + addTestRegistryDriver(t, registryB, "1.0.0"), + }, + }, + { + name: "explicit B accepts its version and preserves source", + declaredURL: registryB, + drivers: []dbc.Driver{ + addTestRegistryDriver(t, registryA, "1.0.0"), + addTestRegistryDriver(t, registryB, "1.1.0"), + }, + wantSuccess: true, + }, + { + name: "explicit B does not fall back when the driver is absent", + declaredURL: registryB, + drivers: []dbc.Driver{ + addTestRegistryDriver(t, registryA, "1.1.0"), + }, + }, + { + name: "omitted source keeps A precedence", + drivers: []dbc.Driver{ + addTestRegistryDriver(t, registryA, "1.0.0"), + addTestRegistryDriver(t, registryB, "1.1.0"), + }, + }, + { + name: "canonical equivalent URL selects B", + declaredURL: "HTTPS://REGISTRY-B.EXAMPLE.TEST/#client-fragment", + drivers: []dbc.Driver{ + addTestRegistryDriver(t, registryA, "1.0.0"), + addTestRegistryDriver(t, registryB, "1.1.0"), + }, + wantSuccess: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "dbc.toml") + initial := "[drivers.test-driver-1]\n" + if tt.declaredURL != "" { + initial += "[drivers.test-driver-1.source]\n" + + "type = 'registry'\n" + + "url = '" + tt.declaredURL + "'\n" + } + require.NoError(t, os.WriteFile(path, []byte(initial), 0o644)) + + model := AddCmd{Path: path, Driver: []string{"test-driver-1=1.1.0"}}.GetModelCustom(baseModel{ + getDriverRegistry: func() ([]dbc.Driver, error) { return tt.drivers, nil }, + }) + msg := runTeaCmdToCompletion(t, model.(interface { + Init() tea.Cmd + Update(tea.Msg) (tea.Model, tea.Cmd) + })) + _, failed := msg.(error) + data, err := os.ReadFile(path) + require.NoError(t, err) + + if !tt.wantSuccess { + require.True(t, failed, "add unexpectedly succeeded: %v", msg) + assert.Equal(t, initial, string(data), "failed add must leave dbc.toml unchanged") + return + } + + require.False(t, failed, "add failed: %v", msg) + var updated DriversList + require.NoError(t, toml.Unmarshal(data, &updated)) + require.NoError(t, updated.validateSources()) + driver := updated.Drivers["test-driver-1"] + require.NotNil(t, driver.Version) + assert.Equal(t, "=1.1.0", driver.Version.String()) + if tt.declaredURL == "" { + assert.Nil(t, driver.Source) + return + } + require.NotNil(t, driver.Source) + assert.Equal(t, tt.declaredURL, driver.Source.URL) + }) + } +} + +func TestAddUpdatesPackslipVersionWithoutRegistryLookup(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "dbc.toml") + initial := "[drivers.test-driver-1]\n" + + "version = '1.2.3'\n" + + "[drivers.test-driver-1.source]\n" + + "type = 'packslip'\n" + + "project = 'github.com/example/driver'\n" + require.NoError(t, os.WriteFile(path, []byte(initial), 0o644)) + + release := makeSyncPackslipRelease( + "test-driver-1", "1.2.4", "https://assets.example.test/driver.tgz", + "sha256:"+strings.Repeat("a", 64), 10) + resolver := &syncPackslipResolverStub{release: release} + registryCalls := 0 + base := testBaseModel() + base.getDriverRegistry = func() ([]dbc.Driver, error) { + registryCalls++ + return nil, errors.New("Packslip add must not query registries") + } + base.newPackslipResolver = func() (packslip.Resolver, error) { return resolver, nil } + msg := runTeaCmdToCompletion(t, AddCmd{ + Path: path, + Driver: []string{"test-driver-1=1.2.4"}, + }.GetModelCustom(base).(interface { + Init() tea.Cmd + Update(tea.Msg) (tea.Model, tea.Cmd) + })) + _, failed := msg.(error) + require.False(t, failed, "Packslip add failed: %v", msg) + assert.Equal(t, 0, registryCalls) + assert.Equal(t, 1, resolver.calls) + assert.Equal(t, "github.com/example/driver", resolver.project) + assert.Equal(t, packslip.Request{DriverID: "test-driver-1", Version: "1.2.4"}, resolver.request) + + data, readErr := os.ReadFile(path) + require.NoError(t, readErr) + var updated DriversList + require.NoError(t, toml.Unmarshal(data, &updated)) + require.NoError(t, updated.validateSources()) + got := updated.Drivers["test-driver-1"] + assert.Equal(t, "1.2.4", got.Version.String()) + require.NotNil(t, got.Source) + assert.Equal(t, dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "github.com/example/driver"}, *got.Source) +} + +func TestAddPathSourceUsesProjectDirectoryAndCanDeriveVersion(t *testing.T) { + dir := t.TempDir() + projectDir := filepath.Join(dir, "project") + otherDir := filepath.Join(dir, "elsewhere") + require.NoError(t, os.MkdirAll(filepath.Join(projectDir, "packages"), 0o755)) + require.NoError(t, os.MkdirAll(otherDir, 0o755)) + archive, err := os.ReadFile(filepath.Join("testdata", "test-driver-1.tar.gz")) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "packages", "driver.tar.gz"), archive, 0o644)) + path := filepath.Join(projectDir, "dbc.toml") + initial := "[drivers.test-driver-1]\nversion = '1.0.0'\n" + + "[drivers.test-driver-1.source]\n" + + "type = 'path'\n" + + "path = './packages/driver.tar.gz'\n" + require.NoError(t, os.WriteFile(path, []byte(initial), 0o644)) + t.Chdir(otherDir) + + registryCalls := 0 + base := testBaseModel() + base.getDriverRegistry = func() ([]dbc.Driver, error) { + registryCalls++ + return nil, errors.New("path add must not query registries") + } + msg := runTeaCmdToCompletion(t, AddCmd{ + Path: path, Driver: []string{"test-driver-1"}, + }.GetModelCustom(base).(interface { + Init() tea.Cmd + Update(tea.Msg) (tea.Model, tea.Cmd) + })) + _, failed := msg.(error) + require.False(t, failed, "path add failed: %v", msg) + assert.Equal(t, 0, registryCalls) + + data, err := os.ReadFile(path) + require.NoError(t, err) + var updated DriversList + require.NoError(t, toml.Unmarshal(data, &updated)) + require.NoError(t, updated.validateSources()) + got := updated.Drivers["test-driver-1"] + assert.Nil(t, got.Version, "omitted path version should return to metadata-derived selection") + require.NotNil(t, got.Source) + assert.Equal(t, dbc.DriverSource{Type: dbc.DriverSourcePath, Path: "./packages/driver.tar.gz"}, *got.Source) +} + +func TestAddPathSourceRequiresExactVersionToMatchArchive(t *testing.T) { + dir := t.TempDir() + archive, err := os.ReadFile(filepath.Join("testdata", "test-driver-1.tar.gz")) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "driver.tar.gz"), archive, 0o644)) + path := filepath.Join(dir, "dbc.toml") + initial := "[drivers.test-driver-1.source]\n" + + "type = 'path'\n" + + "path = './driver.tar.gz'\n" + require.NoError(t, os.WriteFile(path, []byte(initial), 0o644)) + base := testBaseModel() + base.getDriverRegistry = func() ([]dbc.Driver, error) { + return nil, errors.New("path add must not query registries") + } + matching := runTeaCmdToCompletion(t, AddCmd{ + Path: path, Driver: []string{"test-driver-1=1.0.0"}, + }.GetModelCustom(base).(interface { + Init() tea.Cmd + Update(tea.Msg) (tea.Model, tea.Cmd) + })) + _, failed := matching.(error) + require.False(t, failed, "path source exact version should match archive metadata: %v", matching) + data, err := os.ReadFile(path) + require.NoError(t, err) + var updated DriversList + require.NoError(t, toml.Unmarshal(data, &updated)) + require.NoError(t, updated.validateSources()) + assert.Equal(t, "1.0.0", updated.Drivers["test-driver-1"].Version.String()) + require.NotNil(t, updated.Drivers["test-driver-1"].Source) + assert.Equal(t, "./driver.tar.gz", updated.Drivers["test-driver-1"].Source.Path) + + // Run the mismatch against the original versionless entry to prove failure + // does not modify the project configuration. + require.NoError(t, os.WriteFile(path, []byte(initial), 0o644)) + + msg := runTeaCmdToCompletion(t, AddCmd{ + Path: path, Driver: []string{"test-driver-1=1.1.0"}, + }.GetModelCustom(base).(interface { + Init() tea.Cmd + Update(tea.Msg) (tea.Model, tea.Cmd) + })) + err, ok := msg.(error) + require.True(t, ok, "path source with mismatched version must fail") + assert.ErrorContains(t, err, `local package version "1.0.0" does not match requested version "1.1.0"`) + + data, err = os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, initial, string(data), "version mismatch must leave dbc.toml byte-for-byte unchanged") +} + +func TestAddRejectsPackslipVersionMismatchWithoutMutation(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "dbc.toml") + initial := "[drivers.test-driver-1]\nversion = '1.2.3'\n" + + "[drivers.test-driver-1.source]\ntype = 'packslip'\n" + + "project = 'github.com/example/driver'\n" + require.NoError(t, os.WriteFile(path, []byte(initial), 0o644)) + release := makeSyncPackslipRelease( + "test-driver-1", "1.2.5", "https://assets.example.test/driver.tgz", + "sha256:"+strings.Repeat("a", 64), 10) + resolver := &syncPackslipResolverStub{release: release} + base := testBaseModel() + base.getDriverRegistry = func() ([]dbc.Driver, error) { + return nil, errors.New("Packslip add must not query registries") + } + base.newPackslipResolver = func() (packslip.Resolver, error) { return resolver, nil } + msg := runTeaCmdToCompletion(t, AddCmd{ + Path: path, Driver: []string{"test-driver-1=1.2.4"}, + }.GetModelCustom(base).(interface { + Init() tea.Cmd + Update(tea.Msg) (tea.Model, tea.Cmd) + })) + err, ok := msg.(error) + require.True(t, ok, "invalid Packslip result must fail before mutation") + assert.ErrorContains(t, err, `packslip release version "1.2.5" does not match requested version "1.2.4"`) + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, initial, string(data)) +} + +func TestAddResolvesPackslipOutsideProjectLockAndRejectsSourceDrift(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "dbc.toml") + initial := "[drivers.test-driver-1]\nversion = '1.2.3'\n" + + "[drivers.test-driver-1.source]\ntype = 'packslip'\n" + + "project = 'github.com/example/driver'\n" + require.NoError(t, os.WriteFile(path, []byte(initial), 0o644)) + started := make(chan struct{}) + unblock := make(chan struct{}) + release := makeSyncPackslipRelease( + "test-driver-1", "1.2.4", "https://assets.example.test/driver.tgz", + "sha256:"+strings.Repeat("a", 64), 10) + base := testBaseModel() + base.getDriverRegistry = func() ([]dbc.Driver, error) { + return nil, errors.New("Packslip add must not query registries") + } + base.newPackslipResolver = func() (packslip.Resolver, error) { + return addPackslipResolverFunc(func(_ context.Context, _ packslip.PackslipSource, _ packslip.Request) (resolution.ResolvedRelease, error) { + close(started) + <-unblock + return release, nil + }), nil + } + done := make(chan tea.Msg, 1) + go func() { + model := AddCmd{Path: path, Driver: []string{"test-driver-1=1.2.4"}}.GetModelCustom(base) + done <- runTeaCmdToCompletion(t, model.(interface { + Init() tea.Cmd + Update(tea.Msg) (tea.Model, tea.Cmd) + })) + }() + <-started + + lockPath := filepath.Join(dir, ".dbc.project.lock") + lock, err := acquireLock(lockPath, time.Second) + require.NoError(t, err, "the Packslip resolver must run without holding the project lock") + concurrent := "[drivers.test-driver-1]\nversion = '1.2.3'\n" + + "[drivers.test-driver-1.source]\ntype = 'packslip'\n" + + "project = 'github.com/example/other'\n" + require.NoError(t, os.WriteFile(path, []byte(concurrent), 0o644)) + require.NoError(t, lock.Release()) + close(unblock) + + msg := <-done + err, ok := msg.(error) + require.True(t, ok, "source drift must abort the add operation") + assert.ErrorContains(t, err, "driver \"test-driver-1\" source changed while resolving drivers") + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, concurrent, string(data), "concurrent source edit must remain intact") +} + +func TestAddNonRegistrySourcePreservesConcurrentRegistryChanges(t *testing.T) { + t.Setenv("DBC_BASE_URL", "") + dir := t.TempDir() + path := filepath.Join(dir, "dbc.toml") + initial := "[drivers.test-driver-1]\nversion = '1.2.3'\n" + + "[drivers.test-driver-1.source]\ntype = 'packslip'\n" + + "project = 'github.com/example/driver'\n" + require.NoError(t, os.WriteFile(path, []byte(initial), 0o644)) + started := make(chan struct{}) + unblock := make(chan struct{}) + release := makeSyncPackslipRelease( + "test-driver-1", "1.2.4", "https://assets.example.test/driver.tgz", + "sha256:"+strings.Repeat("a", 64), 10) + base := testBaseModel() + base.getDriverRegistry = func() ([]dbc.Driver, error) { + return nil, errors.New("Packslip add must not query registries") + } + base.newPackslipResolver = func() (packslip.Resolver, error) { + return addPackslipResolverFunc(func(_ context.Context, _ packslip.PackslipSource, _ packslip.Request) (resolution.ResolvedRelease, error) { + close(started) + <-unblock + return release, nil + }), nil + } + done := make(chan tea.Msg, 1) + go func() { + model := AddCmd{Path: path, Driver: []string{"test-driver-1=1.2.4"}}.GetModelCustom(base) + done <- runTeaCmdToCompletion(t, model.(interface { + Init() tea.Cmd + Update(tea.Msg) (tea.Model, tea.Cmd) + })) + }() + <-started + + lock, err := acquireLock(filepath.Join(dir, ".dbc.project.lock"), time.Second) + require.NoError(t, err) + concurrent := "[[registries]]\nurl = 'https://registry.example.test'\n\n" + initial + require.NoError(t, os.WriteFile(path, []byte(concurrent), 0o644)) + require.NoError(t, lock.Release()) + close(unblock) + + msg := <-done + _, failed := msg.(error) + require.False(t, failed, "Packslip add should not depend on concurrent registry changes: %v", msg) + data, err := os.ReadFile(path) + require.NoError(t, err) + var updated DriversList + require.NoError(t, toml.Unmarshal(data, &updated)) + require.NoError(t, updated.validateSources()) + require.Len(t, updated.Registries, 1) + assert.Equal(t, "https://registry.example.test", updated.Registries[0].URL) + assert.Equal(t, "1.2.4", updated.Drivers["test-driver-1"].Version.String()) +} + +func TestAddNonRegistrySourcesRejectInvalidVersionAndPreWithoutMutation(t *testing.T) { + tests := []struct { + name string + source string + input string + pre bool + }{ + {name: "packslip missing", source: "packslip", input: "test-driver-1"}, + {name: "packslip prerelease flag", source: "packslip", input: "test-driver-1=1.2.3", pre: true}, + {name: "path range", source: "path", input: "test-driver-1>=1.0.0"}, + {name: "path coercible", source: "path", input: "test-driver-1=01.2.3"}, + {name: "path prerelease flag", source: "path", input: "test-driver-1", pre: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "dbc.toml") + initial := "[drivers.test-driver-1]\nversion = '1.2.3'\n" + + "[drivers.test-driver-1.source]\n" + if tt.source == "packslip" { + initial += "type = 'packslip'\nproject = 'github.com/example/test-driver'\n" + } else { + initial += "type = 'path'\npath = './driver.tar.gz'\n" + } + require.NoError(t, os.WriteFile(path, []byte(initial), 0o644)) + base := testBaseModel() + base.getDriverRegistry = func() ([]dbc.Driver, error) { + return nil, errors.New("non-registry add must not query registries") + } + resolverConstructed := false + base.newPackslipResolver = func() (packslip.Resolver, error) { + resolverConstructed = true + return nil, errors.New("invalid Packslip request must fail before resolver construction") + } + msg := runTeaCmdToCompletion(t, AddCmd{ + Path: path, Driver: []string{tt.input}, Pre: tt.pre, + }.GetModelCustom(base).(interface { + Init() tea.Cmd + Update(tea.Msg) (tea.Model, tea.Cmd) + })) + _, failed := msg.(error) + require.True(t, failed, "expected validation error, got %v", msg) + assert.False(t, resolverConstructed) + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, initial, string(data), "invalid add must leave dbc.toml byte-for-byte unchanged") + }) + } +} + func TestAddMultiple(t *testing.T) { // Test what happens when we `add` without a constraint and then add with a // constraint. This specifically tests the bubbletea output diff --git a/cmd/dbc/driver_list.go b/cmd/dbc/driver_list.go index 4489ffa4..a21557f1 100644 --- a/cmd/dbc/driver_list.go +++ b/cmd/dbc/driver_list.go @@ -15,16 +15,13 @@ package main import ( - "context" "errors" "fmt" - "net/url" "os" - "strings" "github.com/Masterminds/semver/v3" "github.com/columnar-tech/dbc" - "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/sourceidentity" "github.com/pelletier/go-toml/v2" ) @@ -37,6 +34,34 @@ type DriversList struct { Drivers map[string]driverSpec `toml:"drivers" comment:"dbc driver list"` } +func (m DriversList) validateSources() error { + for id, spec := range m.Drivers { + if spec.Source == nil { + continue + } + if err := spec.Source.Validate(); err != nil { + return fmt.Errorf("driver %q source: %w", id, err) + } + switch spec.Source.Type { + case dbc.DriverSourcePackslip, dbc.DriverSourcePath: + if spec.Prerelease != "" { + return fmt.Errorf("driver %q %s source does not support prerelease policy", id, spec.Source.Type) + } + if spec.Source.Type == dbc.DriverSourcePackslip && spec.Version == nil { + return fmt.Errorf("driver %q packslip source requires an exact SemVer 2.0.0 version", id) + } + if spec.Version != nil { + versionText := spec.Version.String() + parsed, err := semver.StrictNewVersion(versionText) + if err != nil || parsed.String() != versionText { + return fmt.Errorf("driver %q %s source requires an exact SemVer 2.0.0 version, got %q", id, spec.Source.Type, versionText) + } + } + } + } + return nil +} + // registriesChanged reports whether two DriversList values would produce // a different EFFECTIVE registry resolution when combined with the // current process-wide globalRegistryConfig and built-in defaults. This @@ -46,71 +71,101 @@ type DriversList struct { // correctly NOT treated as drift. // // Implemented by running both lists through the same newDBCClient merge -// path and comparing the resulting normalized URL sets. Display-only +// path and comparing the resulting source identity keys. Display-only // fields like RegistryEntry.Name are ignored because they don't appear -// in the merged URL comparison. +// in the effective registry identity comparison. func registriesChanged(a, b DriversList) bool { - urlsA, errA := effectiveRegistryURLs(a) - urlsB, errB := effectiveRegistryURLs(b) + keysA, errA := effectiveRegistryKeys(a) + keysB, errB := effectiveRegistryKeys(b) // If either side fails to produce a merged set (e.g. invalid config), // treat as changed so the command aborts rather than writing against // a config we can't analyze. if errA != nil || errB != nil { - return errA != errB + return true } - if len(urlsA) != len(urlsB) { + if len(keysA) != len(keysB) { return true } - for i := range urlsA { - if urlsA[i] != urlsB[i] { + for i := range keysA { + if keysA[i] != keysB[i] { return true } } return false } -// effectiveRegistryURLs returns the normalized URL list the client would -// use after merging the given DriversList with the current global config -// and built-in defaults. It builds a throwaway client via newDBCClient -// so merge semantics stay in sync with what NewClient actually uses. -func effectiveRegistryURLs(list DriversList) ([]string, error) { +// effectiveRegistryKeys returns the source identity keys the client would use +// after merging the given DriversList with the current global config and +// built-in defaults. It builds a throwaway client via newDBCClient so merge +// semantics stay in sync with what NewClient actually uses. +func effectiveRegistryKeys(list DriversList) ([]sourceidentity.Key, error) { c, err := newDBCClient(list.Registries, list.ReplaceDefaults) if err != nil { return nil, err } regs := c.Registries() - out := make([]string, len(regs)) - for i, r := range regs { - if r.BaseURL != nil { - out[i] = normalizeRegistryURL(r.BaseURL.String()) + out := make([]sourceidentity.Key, 0, len(regs)) + for _, r := range regs { + if r.BaseURL == nil { + continue + } + key, err := sourceidentity.Parse(sourceidentity.Registry, r.BaseURL.String()) + if err != nil { + return nil, fmt.Errorf("invalid effective registry URL %q: %w", r.BaseURL.String(), err) } + out = append(out, key) } return out, nil } -// normalizeRegistryURL returns a canonical form of a registry URL for -// equality comparisons. Only truly no-op differences are collapsed: -// -// - scheme and host are lowercased (case-insensitive per RFC 3986) -// - a trailing slash on the path is stripped -// - fragments are dropped (not sent on HTTP requests) -// -// Query, userinfo, and path segments are preserved because they can -// change the effective registry endpoint (tenant selector in query, -// credential-bearing userinfo, path-addressed registry mount points). -// A concurrent edit that flips any of those MUST still trigger the -// config-drift abort in dbc add. -func normalizeRegistryURL(raw string) string { - u, err := url.Parse(raw) +func driverSourceIdentity(source *dbc.DriverSource) (sourceidentity.Key, error) { + if source == nil { + return sourceidentity.Key{}, errors.New("driver source is omitted") + } + switch source.Type { + case dbc.DriverSourceRegistry: + return sourceidentity.Parse(sourceidentity.Registry, source.URL) + case dbc.DriverSourcePackslip: + return sourceidentity.Parse(sourceidentity.Packslip, source.Project) + case dbc.DriverSourcePath: + return sourceidentity.Parse(sourceidentity.Path, source.Path) + default: + return sourceidentity.Key{}, fmt.Errorf("unsupported driver source type %q", source.Type) + } +} + +func lockSourceIdentity(source lockSource) (sourceidentity.Key, error) { + switch source.Type { + case string(sourceidentity.Registry): + return sourceidentity.Parse(sourceidentity.Registry, source.URL) + case string(sourceidentity.Packslip): + return sourceidentity.Parse(sourceidentity.Packslip, source.Project) + case string(sourceidentity.Path): + return sourceidentity.Parse(sourceidentity.Path, source.Path) + default: + return sourceidentity.Key{}, fmt.Errorf("unsupported driver source type %q", source.Type) + } +} + +func sameDriverSourceIdentity(a, b *dbc.DriverSource) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + keyA, err := driverSourceIdentity(a) + if err != nil { + return false + } + keyB, err := driverSourceIdentity(b) + return err == nil && keyA == keyB +} + +func sameLockSourceIdentity(a, b lockSource) bool { + keyA, err := lockSourceIdentity(a) if err != nil { - return raw - } - u.Scheme = strings.ToLower(u.Scheme) - u.Host = strings.ToLower(u.Host) - u.Path = strings.TrimRight(u.Path, "/") - u.Fragment = "" - u.RawFragment = "" - return u.String() + return false + } + keyB, err := lockSourceIdentity(b) + return err == nil && keyA == keyB } // applyProjectRegistries rebuilds the process-wide dbc client with the @@ -171,59 +226,14 @@ func applyProjectRegistriesFromCWD() error { if err := toml.NewDecoder(f).Decode(&list); err != nil { return fmt.Errorf("error decoding driver list at %s: %w", p, err) } + if err := list.validateSources(); err != nil { + return fmt.Errorf("error decoding driver list at %s: %w", p, err) + } return applyProjectRegistries(list) } type driverSpec struct { Prerelease string `toml:"prerelease,omitempty"` Version *semver.Constraints `toml:"version"` -} - -func GetDriverList(fname string) ([]dbc.PkgInfo, error) { - var m DriversList - f, err := os.Open(fname) - if err != nil { - return nil, fmt.Errorf("error opening driver list %s: %w", fname, err) - } - defer f.Close() - if err = toml.NewDecoder(f).Decode(&m); err != nil { - return nil, fmt.Errorf("error decoding driver list %s: %w", fname, err) - } - - // Build a per-call client scoped to this list's registry overrides so - // repeated calls in the same process don't leak configuration from one - // dbc.toml to another. Unlike add/sync (which own the process for one - // command), GetDriverList is a library helper that may be called - // multiple times. - client, err := newDBCClient(m.Registries, m.ReplaceDefaults) - if err != nil { - return nil, fmt.Errorf("error configuring project registries: %w", err) - } - drivers, err := client.Search(context.Background(), "") - if err != nil { - return nil, err - } - - // create mapping to avoid multiple loops through - dmap := make(map[string]dbc.Driver) - for _, driver := range drivers { - dmap[driver.Path] = driver - } - - var pkgs []dbc.PkgInfo - for name, spec := range m.Drivers { - drv, ok := dmap[name] - if !ok { - return nil, fmt.Errorf("driver `%s` not found", name) - } - - pkg, err := drv.GetWithConstraint(spec.Version, config.PlatformTuple()) - if err != nil { - return nil, fmt.Errorf("error finding version for driver %s: %w", name, err) - } - - pkgs = append(pkgs, pkg) - } - - return pkgs, nil + Source *dbc.DriverSource `toml:"source,omitempty"` } diff --git a/cmd/dbc/driver_list_test.go b/cmd/dbc/driver_list_test.go index 75239125..adcb39e2 100644 --- a/cmd/dbc/driver_list_test.go +++ b/cmd/dbc/driver_list_test.go @@ -16,11 +16,9 @@ package main import ( "bytes" - "cmp" "context" "os" "path/filepath" - "slices" "testing" "time" @@ -32,50 +30,123 @@ import ( "github.com/stretchr/testify/require" ) -func TestUnmarshalDriverList(t *testing.T) { +func TestDriverSourceProjectConfigRoundTrip(t *testing.T) { + tests := []struct { + name string + toml string + want dbc.DriverSource + }{ + { + name: "explicit registry", + toml: "[drivers.example]\n[drivers.example.source]\ntype = 'registry'\nurl = 'https://registry.example.test/custom'\n", + want: dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "https://registry.example.test/custom"}, + }, + { + name: "packslip project", + toml: "[drivers.example]\nversion = '1.2.3'\n[drivers.example.source]\ntype = 'packslip'\nproject = 'github.com/owner/project'\n", + want: dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "github.com/owner/project"}, + }, + { + name: "relative path", + toml: "[drivers.example]\n[drivers.example.source]\ntype = 'path'\npath = '../packages/driver.tar.gz'\n", + want: dbc.DriverSource{Type: dbc.DriverSourcePath, Path: "../packages/driver.tar.gz"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var list DriversList + require.NoError(t, toml.Unmarshal([]byte(tt.toml), &list)) + require.NoError(t, list.validateSources()) + require.NotNil(t, list.Drivers["example"].Source) + assert.Equal(t, tt.want, *list.Drivers["example"].Source) + + encoded, err := toml.Marshal(list) + require.NoError(t, err) + var roundTrip DriversList + require.NoError(t, toml.Unmarshal(encoded, &roundTrip)) + require.NoError(t, roundTrip.validateSources()) + require.NotNil(t, roundTrip.Drivers["example"].Source) + assert.Equal(t, tt.want, *roundTrip.Drivers["example"].Source) + }) + } +} + +func TestDriverSourceProjectConfigValidation(t *testing.T) { tests := []struct { name string contents string - expected []dbc.PkgInfo - err error + wantErr string }{ - {"basic", "[drivers]\nflightsql = {version = '1.8.0'}", []dbc.PkgInfo{ - {Driver: dbc.Driver{Path: "flightsql"}, Version: semver.MustParse("1.8.0")}, - }, nil}, - {"less", "[drivers]\nflightsql = {version = '<=1.8.0'}", []dbc.PkgInfo{ - {Driver: dbc.Driver{Path: "flightsql"}, Version: semver.MustParse("1.8.0")}, - }, nil}, - {"greater", "[drivers]\nflightsql = {version = '>=1.8.0, <=1.10.0'}", []dbc.PkgInfo{ - {Driver: dbc.Driver{Path: "flightsql"}, Version: semver.MustParse("1.10.0")}, - }, nil}, + {name: "legacy entry has no source", contents: "[drivers]\nexample = {version = '>=1.0.0'}"}, + {name: "missing source type", contents: "[drivers.example.source]\nurl = 'https://registry.example.test'", wantErr: "driver source has no type"}, + {name: "unknown source type", contents: "[drivers.example.source]\ntype = 'git'\nurl = 'https://example.test'", wantErr: `unsupported driver source type "git"`}, + {name: "mutually exclusive fields", contents: "[drivers.example.source]\ntype = 'path'\npath = '../package.tar.gz'\nproject = 'github.com/owner/project'", wantErr: "path source contains fields for another source type"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tmpdir := t.TempDir() - driverListPath := filepath.Join(tmpdir, "dbc.toml") - require.NoError(t, os.WriteFile(driverListPath, []byte(tt.contents), 0644)) - - pkgs, err := GetDriverList(driverListPath) - if tt.err != nil { - require.Error(t, err) - assert.ErrorContains(t, err, tt.err.Error()) + var list DriversList + err := toml.Unmarshal([]byte(tt.contents), &list) + if err == nil { + err = list.validateSources() + } + if tt.wantErr == "" { + require.NoError(t, err) + assert.Nil(t, list.Drivers["example"].Source) return } + require.ErrorContains(t, err, tt.wantErr) + }) + } +} - require.NoError(t, err) - assert.Len(t, pkgs, len(tt.expected)) - - slices.SortFunc(pkgs, func(a, b dbc.PkgInfo) int { - return cmp.Compare(a.Driver.Path, b.Driver.Path) - }) - slices.SortFunc(tt.expected, func(a, b dbc.PkgInfo) int { - return cmp.Compare(a.Driver.Path, b.Driver.Path) - }) - - for i, pkg := range pkgs { - assert.Equal(t, tt.expected[i].Driver.Path, pkg.Driver.Path) - assert.Truef(t, tt.expected[i].Version.Equal(pkg.Version), "expected %s to equal %s", tt.expected[i].Version, pkg.Version) +func TestPackslipSourceRequiresVersionInPoCConfig(t *testing.T) { + // Exact-only is a temporary PoC limitation. Before Packslip support is + // generally available, omitted versions should resolve to an exact release. + contents := "[drivers.example.source]\ntype = 'packslip'\nproject = 'github.com/owner/project'\n" + var list DriversList + require.NoError(t, toml.Unmarshal([]byte(contents), &list)) + require.ErrorContains(t, list.validateSources(), "requires an exact SemVer 2.0.0 version") +} + +func TestPathSourceRequiresOptionalExactStrictSemVer(t *testing.T) { + tests := []struct { + name string + version string + flag string + wantErr bool + }{ + {name: "metadata-derived version"}, + {name: "exact release", version: "1.2.3"}, + {name: "exact prerelease", version: "1.2.3-rc.1"}, + {name: "exact build metadata", version: "1.2.3+build.5"}, + {name: "explicit equality", version: "=1.2.3", wantErr: true}, + {name: "range", version: ">=1.2.3", wantErr: true}, + {name: "v prefix", version: "v1.2.3", wantErr: true}, + {name: "abbreviated", version: "1.2", wantErr: true}, + {name: "leading zero", version: "01.2.3", wantErr: true}, + {name: "prerelease policy", flag: "allow", wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + contents := "[drivers.example]\n" + if test.version != "" { + contents += "version = '" + test.version + "'\n" + } + if test.flag != "" { + contents += "prerelease = '" + test.flag + "'\n" + } + contents += "[drivers.example.source]\ntype = 'path'\npath = './example.tgz'\n" + var list DriversList + err := toml.Unmarshal([]byte(contents), &list) + if err == nil { + err = list.validateSources() + } + if test.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) } }) } @@ -103,6 +174,21 @@ version = '>=1.6.0' `, string(data)) } +func TestDriverSpecConstraintTOMLRoundTrip(t *testing.T) { + const source = "[drivers]\n[drivers.flightsql]\nversion = '>=1.6.0, <2.0.0'\n" + var list DriversList + require.NoError(t, toml.Unmarshal([]byte(source), &list)) + require.NotNil(t, list.Drivers["flightsql"].Version) + assert.Equal(t, ">=1.6.0 <2.0.0", list.Drivers["flightsql"].Version.String()) + + encoded, err := toml.Marshal(list) + require.NoError(t, err) + var roundTrip DriversList + require.NoError(t, toml.Unmarshal(encoded, &roundTrip)) + require.NotNil(t, roundTrip.Drivers["flightsql"].Version) + assert.Equal(t, ">=1.6.0 <2.0.0", roundTrip.Drivers["flightsql"].Version.String()) +} + func TestMarshalDriverListEmptyTableSection(t *testing.T) { // Regression test for go-toml v2.2 → v2.4 upgrade: v2.4 drops the // blank line after an empty table section, which changes dbc.toml output. @@ -170,6 +256,16 @@ func TestRegistriesChanged(t *testing.T) { assert.True(t, registriesChanged(a, b)) }) + t.Run("both invalid configs fail closed", func(t *testing.T) { + invalid := DriversList{Registries: []dbc.RegistryEntry{{URL: "ftp://registry.example.com"}}} + _, errA := effectiveRegistryKeys(invalid) + _, errB := effectiveRegistryKeys(invalid) + require.Error(t, errA) + require.Error(t, errB) + assert.True(t, registriesChanged(invalid, invalid), + "two unanalyzable registry configs must be treated as changed") + }) + t.Run("name-only changes are ignored (display-only field)", func(t *testing.T) { a := DriversList{Registries: []dbc.RegistryEntry{{URL: "https://a.example.com", Name: "prod"}}} b := DriversList{Registries: []dbc.RegistryEntry{{URL: "https://a.example.com", Name: "production"}}} @@ -216,6 +312,18 @@ func TestRegistriesChanged(t *testing.T) { assert.True(t, registriesChanged(a, b)) }) + t.Run("escaped separator differs from literal separator", func(t *testing.T) { + a := DriversList{Registries: []dbc.RegistryEntry{{URL: "https://r.example.com/a%2Fb"}}} + b := DriversList{Registries: []dbc.RegistryEntry{{URL: "https://r.example.com/a/b"}}} + assert.True(t, registriesChanged(a, b)) + }) + + t.Run("empty force query is significant", func(t *testing.T) { + a := DriversList{Registries: []dbc.RegistryEntry{{URL: "https://r.example.com?"}}} + b := DriversList{Registries: []dbc.RegistryEntry{{URL: "https://r.example.com"}}} + assert.True(t, registriesChanged(a, b)) + }) + t.Run("replace_defaults tri-state differences compare unequal", func(t *testing.T) { a := DriversList{ReplaceDefaults: bp(true), Registries: []dbc.RegistryEntry{{URL: "https://r.example.com"}}} b := DriversList{ReplaceDefaults: bp(false), Registries: []dbc.RegistryEntry{{URL: "https://r.example.com"}}} @@ -258,6 +366,15 @@ func TestRegistriesChanged(t *testing.T) { }) } +func TestDriverSourceIdentityDistinguishesOmittedSource(t *testing.T) { + explicitRegistry := &dbc.DriverSource{ + Type: dbc.DriverSourceRegistry, + URL: "https://registry.example.test", + } + assert.True(t, sameDriverSourceIdentity(nil, nil)) + assert.False(t, sameDriverSourceIdentity(nil, explicitRegistry)) +} + func TestDriversListRegistries(t *testing.T) { t.Run("existing dbc.toml without registries still decodes (backward compat)", func(t *testing.T) { content := "[drivers]\n[drivers.clickhouse]\nversion = '>=1.0.0'" diff --git a/cmd/dbc/helpers.go b/cmd/dbc/helpers.go index 70ee08bb..f2774a93 100644 --- a/cmd/dbc/helpers.go +++ b/cmd/dbc/helpers.go @@ -23,6 +23,7 @@ import ( "time" "github.com/columnar-tech/dbc/internal/fslock" + "github.com/columnar-tech/dbc/internal/packslip" "github.com/pelletier/go-toml/v2" ) @@ -59,8 +60,13 @@ func wrapWithRegistryContext(err, registryErr error) error { func defaultBaseModel() baseModel { return baseModel{ - getDriverRegistry: getDriverRegistry, - downloadPkg: downloadPkg, + getDriverRegistry: getDriverRegistry, + downloadPkg: downloadPkg, + downloadArtifact: downloadPackage, + fetchPackslipArtifact: fetchPackslipArtifact, + newPackslipResolver: func() (packslip.Resolver, error) { + return packslip.NewResolver(packslip.Config{}) + }, } } @@ -83,5 +89,8 @@ func openAndDecodeDriverList(path string) (DriversList, error) { if err := toml.NewDecoder(f).Decode(&list); err != nil { return DriversList{}, err } + if err := list.validateSources(); err != nil { + return DriversList{}, err + } return list, nil } diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index be06fd35..0f3f1f44 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -15,11 +15,11 @@ package main import ( + "context" "encoding/json" "errors" "fmt" "io" - "io/fs" "os" "path/filepath" "strings" @@ -35,17 +35,6 @@ import ( "github.com/columnar-tech/dbc/internal/jsonschema" ) -func manifestToPackageInfo(m config.Manifest) dbc.PkgInfo { - return dbc.PkgInfo{ - Driver: dbc.Driver{ - Title: m.Name, - Path: m.ID, - License: m.License, - }, - Version: m.Version, - } -} - func parseDriverConstraint(driver string) (string, *semver.Constraints, error) { driver = strings.TrimSpace(driver) splitIdx := strings.IndexAny(driver, " ~^<>=!") @@ -63,8 +52,7 @@ func parseDriverConstraint(driver string) (string, *semver.Constraints, error) { } type InstallCmd struct { - // URI url.URL `arg:"-u" placeholder:"URL" help:"Base URL for fetching drivers"` - Driver string `arg:"positional,required" help:"Driver to install, optionally with a version constraint (for example: mysql, mysql=0.1.0, mysql>=1,<2)"` + Driver string `arg:"positional,required" help:"Driver to install, a version constraint (mysql>=1,<2), a local .tar.gz/.tgz package, or a Packslip source (github.com/owner/repo=1.2.3)"` Level config.ConfigLevel `arg:"-l" help:"Config level to install to (user, system)"` Json bool `arg:"--json" help:"Print output as JSON instead of plaintext"` JsonStreamProgress bool `arg:"--json-stream-progress" help:"Stream progress events as JSON lines (implies --json)"` @@ -75,18 +63,24 @@ type InstallCmd struct { func (InstallCmd) Description() string { return "Install a driver.\n\n" + - "`DRIVER` may include a version constraint, for example `dbc install mysql`, `dbc install \"mysql=0.1.0\"`, or `dbc install \"mysql>=1,<2\"`.\n" + + "`DRIVER` may be a registry driver with a version constraint (for example `dbc install mysql`, `dbc install \"mysql=0.1.0\"`, or `dbc install \"mysql>=1,<2\"`), a local `.tar.gz`/`.tgz` package, or an exact Packslip project release such as `dbc install github.com/owner/repo=1.2.3`.\n" + "See https://docs.columnar.tech/dbc/guides/installing/#version-constraints for more on version constraint syntax." } func (c InstallCmd) GetModelCustom(baseModel baseModel) tea.Model { s := spinner.New() s.Spinner = spinner.MiniDot - isLocal := strings.HasSuffix(c.Driver, ".tar.gz") || strings.HasSuffix(c.Driver, ".tgz") + isLocal := (strings.HasSuffix(c.Driver, ".tar.gz") || strings.HasSuffix(c.Driver, ".tgz")) && !strings.Contains(c.Driver, "://") localPackagePath := "" if isLocal { localPackagePath = c.Driver } + var packslipProject, packslipVersion string + var isPackslip bool + var packslipErr error + if !isLocal { + packslipProject, packslipVersion, isPackslip, packslipErr = parsePackslipInstallArgument(c.Driver) + } return progressiveInstallModel{ Driver: c.Driver, NoVerify: c.NoVerify, @@ -99,6 +93,10 @@ func (c InstallCmd) GetModelCustom(baseModel baseModel) tea.Model { baseModel: baseModel, isLocal: isLocal, localPackagePath: localPackagePath, + isPackslip: isPackslip, + packslipProject: packslipProject, + packslipVersion: packslipVersion, + directInputErr: packslipErr, p: NewFileProgress( progress.WithDefaultBlend(), progress.WithWidth(20), @@ -111,46 +109,19 @@ func (c InstallCmd) GetModel() tea.Model { return c.GetModelCustom(defaultBaseModel()) } -func verifySignature(m config.Manifest, noVerify bool) error { - if m.Files.Driver == "" || 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) - } - defer lib.Close() - - sigFile := m.Files.Signature - if sigFile == "" { - sigFile = m.Files.Driver + ".sig" - } - - sig, err := os.Open(filepath.Join(path, sigFile)) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return fmt.Errorf("signature file '%s' for driver is missing", sigFile) - } - return fmt.Errorf("failed to open signature file: %w", err) - } - defer sig.Close() - - if err := dbc.SignedByColumnar(lib, sig); err != nil { - return fmt.Errorf("signature verification failed: %w", err) - } +type directInstallResolvedMsg struct{ item installItem } - return nil +type directInstallPreparedMsg struct { + item installItem + executor *packageExecutor } -type writeDriverManifestMsg struct { - DriverInfo config.DriverInfo +type directInstallFinishedMsg struct { + item installItem + result config.EnsurePackageResult + err error } -type localInstallMsg struct{} - // alreadyInstalledChecksumMsg carries the checksum computed for an already-installed driver. type alreadyInstalledChecksumMsg string @@ -236,6 +207,11 @@ type progressiveInstallModel struct { width, height int isLocal bool localPackagePath string + isPackslip bool + packslipProject string + packslipVersion string + directInputErr error + installSource *jsonschema.InstallSource registryErrors error alreadyInstalledChecksum string @@ -249,9 +225,19 @@ type driversWithRegistryError struct { } func (m progressiveInstallModel) Init() tea.Cmd { - if strings.HasSuffix(m.Driver, ".tar.gz") || strings.HasSuffix(m.Driver, ".tgz") { + if m.directInputErr != nil { + return errCmd("%w", m.directInputErr) + } + if m.isPackslip && m.Pre { + return errCmd("--pre does not apply to exact Packslip releases") + } + if m.isLocal || m.isPackslip { return tea.Batch(m.spinner.Tick, func() tea.Msg { - return localInstallMsg{} + item, err := m.resolveDirectInstall(context.Background()) + if err != nil { + return err + } + return directInstallResolvedMsg{item: item} }) } @@ -287,6 +273,60 @@ func (m progressiveInstallModel) Init() tea.Cmd { }) } +func (m progressiveInstallModel) startDirectInstall(item installItem) (tea.Model, tea.Cmd) { + m.Driver = item.Release.DriverID + version, err := semver.NewVersion(item.Release.Version) + if err != nil { + return m, errCmd("invalid resolved driver version %q: %w", item.Release.Version, err) + } + m.DriverPackage = dbc.PkgInfo{Driver: dbc.Driver{Path: m.Driver, Title: m.Driver}, Version: version, PlatformTuple: item.Platform} + m.installSource = &jsonschema.InstallSource{Type: item.Release.Source.Type, Reference: item.Release.Source.Reference} + if m.isLocal { + m.state = stInstalling + m = m.addEvent("download.complete") + m = m.addEvent("extract.start") + } else { + m.state = stDownloading + m = m.addEvent("download.start") + } + return m, func() tea.Msg { + executor, err := m.newDirectInstallExecutor(item) + if err != nil { + return err + } + installCfg, err := installConfigForEnsure(executor.cfg) + if err != nil { + if closeErr := closeDirectInstallResources(&item); closeErr != nil { + err = errors.Join(err, fmt.Errorf("close prepared package: %w", closeErr)) + } + return err + } + executor.cfg = installCfg + if err := executor.prepareItem(context.Background(), &item); err != nil { + if closeErr := closeDirectInstallResources(&item); closeErr != nil { + err = errors.Join(err, fmt.Errorf("close prepared package: %w", closeErr)) + } + return err + } + return directInstallPreparedMsg{item: item, executor: executor} + } +} + +func (m progressiveInstallModel) startDirectEnsure(item installItem, executor *packageExecutor) (tea.Model, tea.Cmd) { + if !m.isLocal { + m = m.addEvent("download.complete") + m = m.addEvent("extract.start") + } + m.state = stInstalling + return m, func() tea.Msg { + result, err := executor.ensurePreparedPackage(context.Background(), &item) + if closeErr := closeDirectInstallResources(&item); closeErr != nil { + err = errors.Join(err, fmt.Errorf("close prepared package: %w", closeErr)) + } + return directInstallFinishedMsg{item: item, result: result, err: err} + } +} + func (m progressiveInstallModel) Preamble() string { if m.isLocal { return "Installing from local package: " + m.localPackagePath + "\n\n" @@ -314,6 +354,7 @@ func (m progressiveInstallModel) FinalOutput() string { Driver: m.conflictingInfo.ID, Version: m.conflictingInfo.Version.String(), Location: filepath.SplitList(m.cfg.Location)[0], + Source: m.installSource, } if m.alreadyInstalledChecksum != "" { payload.Checksum = m.alreadyInstalledChecksum @@ -344,6 +385,7 @@ func (m progressiveInstallModel) FinalOutput() string { Driver: m.Driver, Version: m.DriverPackage.Version.String(), Location: filepath.SplitList(m.cfg.Location)[0], + Source: m.installSource, } if m.hasConflict() { installStatus.Conflict = fmt.Sprintf("%s (version: %s)", m.conflictingInfo.ID, m.conflictingInfo.Version) @@ -438,59 +480,29 @@ func (m progressiveInstallModel) searchForDriver(list []dbc.Driver) (tea.Model, } } -func (m progressiveInstallModel) startDownloading() (tea.Model, tea.Cmd) { - m.state = stDownloading - if m.isAlreadyInstalled() { - m.state = stDone - if m.jsonOutput && !m.insecureNoChecksum && m.conflictingInfo.Driver.Shared.Get(config.PlatformTuple()) != "" { - driverPath := m.conflictingInfo.Driver.Shared.Get(config.PlatformTuple()) - return m, func() tea.Msg { - chksum, err := checksum(driverPath) - if err != nil { - return fmt.Errorf("checksum_failed: %w", err) - } - return alreadyInstalledChecksumMsg(chksum) - } - } - return m, tea.Quit - } - - m = m.addEvent("download.start") - return m, func() tea.Msg { - output, err := m.downloadPkg(m.DriverPackage) - if err != nil { - return err - } - return output +func packageVerificationError(err error) error { + const prefix = "package verification failed: " + if strings.HasPrefix(err.Error(), prefix) { + return errors.New(strings.TrimPrefix(err.Error(), prefix)) } + return err } -func (m progressiveInstallModel) startInstalling(downloaded *os.File) (tea.Model, tea.Cmd) { - m.state = stInstalling - if m.isLocal { - driverName := strings.TrimSuffix( - strings.TrimSuffix(filepath.Base(m.Driver), ".tar.gz"), ".tgz") - parts := strings.Split(driverName, "_"+config.PlatformTuple()+"_") - if len(parts) < 2 { - m.Driver = driverName - } else { - m.Driver = parts[0] // drivername_platform_arch_version grab drivername - } - } - - return m, func() tea.Msg { - if m.conflictingInfo.ID != "" { - if err := config.UninstallDriver(m.cfg, m.conflictingInfo); err != nil { - return err - } - } +func isPackageVerificationFailure(err error) bool { + return strings.HasPrefix(err.Error(), "package verification failed: ") +} - manifest, err := config.InstallDriver(m.cfg, m.Driver, downloaded) - if err != nil { - return err - } - return manifest +func (m progressiveInstallModel) fail(err error) (tea.Model, tea.Cmd) { + m.status = 1 + m.err = err + if m.jsonOutput { + m.jsonErrorOutput = marshalEnvelope("error", jsonschema.ErrorResponse{ + Code: "install_failed", + Message: err.Error(), + }) + return m, tea.Quit } + return m, tea.Quit } func (m progressiveInstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -523,65 +535,72 @@ func (m progressiveInstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case []dbc.Driver: // For backwards compatibility, still handle plain driver list return m.searchForDriver(msg) - case localInstallMsg: - m.isLocal = true - if m.localPackagePath == "" { - m.localPackagePath = m.Driver + case directInstallResolvedMsg: + return m.startDirectInstall(msg.item) + case directInstallPreparedMsg: + return m.startDirectEnsure(msg.item, msg.executor) + case directInstallFinishedMsg: + if msg.err != nil { + return m.fail(msg.err) } - return m, func() tea.Msg { - localDrv, err := os.Open(m.Driver) - if err != nil { - return err - } - return localDrv + if msg.result.Installed == nil { + return m.fail(errors.New("package ensure returned no installed driver registration")) } - case dbc.PkgInfo: - m.DriverPackage = msg - di, err := config.GetDriver(m.cfg, m.Driver) - if err == nil { - m.conflictingInfo = di + m.Driver = msg.item.Release.DriverID + version, err := semver.NewVersion(msg.item.Release.Version) + if err != nil { + return m.fail(fmt.Errorf("invalid resolved driver version %q: %w", msg.item.Release.Version, err)) } - - return m.startDownloading() - case *os.File: - 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) + m.DriverPackage = dbc.PkgInfo{Driver: dbc.Driver{Path: m.Driver, Title: m.Driver}, Version: version, PlatformTuple: msg.item.Platform} + m.installSource = &jsonschema.InstallSource{Type: msg.item.Release.Source.Type, Reference: msg.item.Release.Source.Reference} + m.installedDriverInfo = *msg.result.Installed + if msg.result.Skipped { + m.conflictingInfo = *msg.result.Installed + m.state = stDone + if m.jsonOutput && !m.insecureNoChecksum && msg.result.Installed.Driver.Shared.Get(msg.item.Platform) != "" { + libraryPath := msg.result.Installed.Driver.Shared.Get(msg.item.Platform) + return m, func() tea.Msg { + checksumValue, checksumErr := checksum(libraryPath) + if checksumErr != nil { + return fmt.Errorf("checksum_failed: %w", checksumErr) + } + return alreadyInstalledChecksumMsg(checksumValue) + } + } + return m, tea.Quit } - - m.state = stVerifying - m.postInstallMessage = strings.Join(msg.PostInstall.Messages, "\n") - 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 + if msg.result.Manifest == nil { + return m.fail(errors.New("package ensure returned no install manifest")) + } + m.conflictingInfo = config.DriverInfo{} + if msg.result.Previous != nil { + if msg.result.Previous.Version == nil || !msg.result.Previous.Version.Equal(version) { + m.conflictingInfo = *msg.result.Previous } - return writeDriverManifestMsg{DriverInfo: msg.DriverInfo} } - case writeDriverManifestMsg: + m.postInstallMessage = strings.Join(msg.result.Manifest.PostInstall.Messages, "\n") m.state = stDone - m.installedDriverInfo = msg.DriverInfo + m = m.addEvent("extract.complete") + m = m.addEvent("verify.start") 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) - case error: - m.status = 1 - m.err = msg - if m.jsonOutput { - m.jsonErrorOutput = marshalEnvelope("error", jsonschema.ErrorResponse{ - Code: "install_failed", - Message: msg.Error(), - }) - return m, tea.Quit + return m, tea.Quit + case dbc.PkgInfo: + release, err := resolvedReleaseFromRegistryPackage(msg.Driver, msg) + if err == nil { + var artifactIndex int + artifactIndex, err = artifactIndexForPlatform(release, msg.PlatformTuple) + if err == nil { + var item installItem + item, err = newInstallItem(release, artifactIndex, msg.PlatformTuple, nil) + if err == nil { + return m.startDirectInstall(item) + } + } } + return m.fail(err) + case error: + return m.fail(msg) } base, cmd := m.baseModel.Update(msg) diff --git a/cmd/dbc/install_source.go b/cmd/dbc/install_source.go new file mode 100644 index 00000000..11964687 --- /dev/null +++ b/cmd/dbc/install_source.go @@ -0,0 +1,222 @@ +// 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 main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc" + "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/resolution" + "github.com/columnar-tech/dbc/internal/sourceidentity" + "github.com/columnar-tech/dbc/internal/sourceresolution" +) + +func parsePackslipInstallArgument(input string) (project, version string, matched bool, err error) { + trimmed := strings.TrimSpace(input) + lower := strings.ToLower(trimmed) + if !strings.HasPrefix(lower, "github.com/") && + !strings.HasPrefix(lower, "https://github.com/") && + !strings.HasPrefix(lower, "http://github.com/") { + return "", "", false, nil + } + if trimmed != input { + return "", "", true, errors.New("Packslip project must be a canonical github.com host path") + } + if strings.Contains(trimmed, "://") { + return "", "", true, errors.New("Packslip install requires a github.com project path, not a URL") + } + if strings.Count(trimmed, "=") != 1 { + return "", "", true, errors.New("Packslip install syntax is github.com/owner/repo[/tool]=") + } + parts := strings.SplitN(trimmed, "=", 2) + project, version = parts[0], parts[1] + key, parseErr := sourceidentity.Parse(sourceidentity.Packslip, project) + if parseErr != nil || key.Reference != project { + return "", "", true, fmt.Errorf("invalid canonical Packslip project %q", project) + } + parsed, parseErr := semver.StrictNewVersion(version) + if parseErr != nil || parsed.String() != version { + return "", "", true, fmt.Errorf("Packslip version %q must be an exact canonical SemVer 2.0.0 version", version) + } + return project, version, true, nil +} + +func (m progressiveInstallModel) resolveDirectInstall(ctx context.Context) (installItem, error) { + platform := config.PlatformTuple() + target, err := resolution.TargetFromPlatformTuple(platform) + if err != nil { + return installItem{}, fmt.Errorf("unsupported current platform: %w", err) + } + var release resolution.ResolvedRelease + switch { + case m.isPackslip: + if m.newPackslipResolver == nil { + return installItem{}, errors.New("Packslip resolver is unavailable") + } + resolver, err := m.newPackslipResolver() + if err != nil { + return installItem{}, fmt.Errorf("create Packslip resolver: %w", err) + } + release, err = sourceresolution.ResolvePackslip(ctx, resolver, m.packslipProject, "", m.packslipVersion) + if err != nil { + return installItem{}, err + } + case m.isLocal: + cwd, err := os.Getwd() + if err != nil { + return installItem{}, fmt.Errorf("resolve current directory for local package: %w", err) + } + driverID := legacyLocalPackageID(m.localPackagePath, platform) + release, err = sourceresolution.ResolvePath(ctx, m.localPackagePath, sourceresolution.Request{ + DriverID: driverID, + Target: target, + Platform: platform, + BaseDir: cwd, + }) + if err != nil { + return installItem{}, err + } + default: + return installItem{}, errors.New("no direct install source was selected") + } + artifactIndex, err := artifactIndexForPlatform(release, platform) + if err != nil { + return installItem{}, err + } + return newInstallItem(release, artifactIndex, platform, nil) +} + +func legacyLocalPackageID(path, platform string) string { + name := strings.TrimSuffix(strings.TrimSuffix(filepath.Base(path), ".tar.gz"), ".tgz") + parts := strings.Split(name, "_"+platform+"_") + if len(parts) >= 2 { + return parts[0] + } + return name +} + +func artifactIndexForPlatform(release resolution.ResolvedRelease, platform string) (int, error) { + target, err := resolution.TargetFromPlatformTuple(platform) + if err != nil { + return 0, fmt.Errorf("invalid current platform %q: %w", platform, err) + } + selected := -1 + for i := range release.Artifacts { + if release.Artifacts[i].Target == target { + if selected >= 0 { + return 0, fmt.Errorf("resolved release for %s has multiple artifacts for platform %s", release.DriverID, platform) + } + selected = i + } + } + if selected < 0 { + return 0, fmt.Errorf("resolved release for %s has no artifact for platform %s", release.DriverID, platform) + } + return selected, nil +} + +func (m progressiveInstallModel) newDirectInstallExecutor(item installItem) (*packageExecutor, error) { + baseDir := "" + if item.Release.Source.Type == "path" { + var err error + baseDir, err = os.Getwd() + if err != nil { + return nil, fmt.Errorf("resolve current directory for local package: %w", err) + } + } + downloadArtifact := m.downloadArtifact + if downloadArtifact != nil { + underlying := downloadArtifact + downloadArtifact = func(ctx context.Context, pkg dbc.PkgInfo) (io.ReadCloser, error) { + body, err := underlying(ctx, pkg) + if err != nil { + return nil, err + } + var total int64 + if pkg.ArtifactSize != nil { + total = *pkg.ArtifactSize + } + return &installProgressReadCloser{ReadCloser: body, total: total}, nil + } + } + return newPackageExecutor(m.cfg, baseDir, m.NoVerify, downloadArtifact, m.downloadPkg, m.fetchPackslipArtifact, nil), nil +} + +type installProgressReadCloser struct { + io.ReadCloser + written int64 + total int64 +} + +func (reader *installProgressReadCloser) Read(p []byte) (int, error) { + n, err := reader.ReadCloser.Read(p) + reader.written += int64(n) + if n > 0 && prog != nil { + prog.Send(progressMsg{written: reader.written, total: reader.total}) + } + return n, err +} + +func closeDirectInstallResources(item *installItem) error { + if item == nil { + return nil + } + var err error + if item.Archive != nil { + err = errors.Join(err, item.Archive.Close()) + item.Archive = nil + } + if item.Validation != nil && item.Validation.Prepared != nil { + err = errors.Join(err, item.Validation.Prepared.Close()) + item.Validation.Prepared = nil + } + return err +} + +func installConfigForEnsure(cfg config.Config) (config.Config, error) { + primary, err := config.EnsureLocation(cfg) + if err != nil { + return cfg, err + } + if cfg.Level != config.ConfigEnv { + cfg.Location = primary + return cfg, nil + } + roots := filepath.SplitList(cfg.Location) + available := []string{primary} + for _, root := range roots[1:] { + info, statErr := os.Stat(root) + if errors.Is(statErr, os.ErrNotExist) { + continue + } + if statErr != nil { + return cfg, fmt.Errorf("failed to inspect configured driver root %s: %w", root, statErr) + } + if !info.IsDir() { + return cfg, fmt.Errorf("configured driver root %s is not a directory", root) + } + available = append(available, root) + } + cfg.Location = strings.Join(available, string(filepath.ListSeparator)) + return cfg, nil +} diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index decd17c5..bb613b69 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -16,19 +16,72 @@ package main import ( "archive/tar" + "bytes" "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" "encoding/json" + "errors" "fmt" + "io" + "net/url" "os" "path/filepath" "runtime" "strings" + "testing" + + "github.com/stretchr/testify/require" "github.com/columnar-tech/dbc" "github.com/columnar-tech/dbc/config" "github.com/columnar-tech/dbc/internal/jsonschema" + "github.com/columnar-tech/dbc/internal/packslip" + "github.com/columnar-tech/dbc/internal/resolution" ) +type installPackslipResolverStub struct { + release resolution.ResolvedRelease + calls int + project string + request packslip.Request +} + +func (stub *installPackslipResolverStub) Resolve(_ context.Context, source packslip.PackslipSource, request packslip.Request) (resolution.ResolvedRelease, error) { + stub.calls++ + stub.project = source.Project + stub.request = request + return cloneResolvedReleaseForSync(stub.release), nil +} + +func packslipInstallRelease(t *testing.T, id, version, artifactURL string, archive []byte) resolution.ResolvedRelease { + t.Helper() + target, err := resolution.TargetFromPlatformTuple(config.PlatformTuple()) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(archive) + size := int64(len(archive)) + return resolution.ResolvedRelease{ + DriverID: id, + Version: version, + Source: resolution.SourceSpec{Type: "packslip", Reference: "github.com/example/repo"}, + Evidence: []resolution.Evidence{{ + Kind: resolution.EvidenceKindReleaseMetadata, + Location: resolution.ArtifactLocation{ + Kind: resolution.ArtifactLocationURL, Value: "https://github.com/example/repo/releases/download/v1.2.3/packslip.sigstore.json", + }, + Hash: "sha256:" + strings.Repeat("a", 64), + }}, + Artifacts: []resolution.Artifact{{ + Target: target, Format: "tgz", + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: artifactURL}, + Hash: "sha256:" + hex.EncodeToString(digest[:]), Size: &size, + }}, + } +} + func (suite *SubcommandTestSuite) TestInstall() { m := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel}. GetModelCustom(testBaseModel()) @@ -83,19 +136,32 @@ 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)) - 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, + "\nRemoved conflicting 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()) + installed, err := config.GetDriver(config.Config{Level: suite.configLevel, Location: suite.Dir()}, "test-driver-1") + suite.Require().NoError(err) + libraryPath := installed.Driver.Shared.Get(config.PlatformTuple()) + relLibrary, err := filepath.Rel(suite.Dir(), libraryPath) + suite.Require().NoError(err) + relDir := filepath.ToSlash(filepath.Dir(relLibrary)) + relLibrary = filepath.ToSlash(relLibrary) + expectedFiles := []string{filepath.ToSlash(filepath.Join(relDir, "dbc-install-receipt.json")), + relLibrary, relLibrary + ".sig", "test-driver-1.toml"} + if runtime.GOOS == "windows" && (suite.configLevel == config.ConfigUser || suite.configLevel == config.ConfigSystem) { + // User- and system-level Windows installs register the driver in the + // registry rather than writing a manifest alongside the package files. + expectedFiles = expectedFiles[:len(expectedFiles)-1] + } + suite.Equal(expectedFiles, suite.getFilesInDir(suite.Dir())) } func (suite *SubcommandTestSuite) TestReinstallDowngradeVersion() { @@ -104,6 +170,9 @@ 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) + previous, err := config.GetDriver(config.Get()[suite.configLevel], "test-driver-1") + suite.Require().NoError(err) + previousLibrary := previous.Driver.Shared.Get(config.PlatformTuple()) m = InstallCmd{Driver: "test-driver-1<=1.0.0", Level: suite.configLevel}. GetModelCustom(testBaseModel()) @@ -112,9 +181,16 @@ func (suite *SubcommandTestSuite) TestReinstallDowngradeVersion() { 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") + installed, err := config.GetDriver(config.Get()[suite.configLevel], "test-driver-1") + suite.Require().NoError(err) + currentLibrary := installed.Driver.Shared.Get(config.PlatformTuple()) + currentRelative, err := filepath.Rel(suite.Dir(), currentLibrary) + suite.Require().NoError(err) + previousRelative, err := filepath.Rel(suite.Dir(), previousLibrary) + suite.Require().NoError(err) + suite.Contains(files, filepath.ToSlash(currentRelative)) + suite.Contains(files, filepath.ToSlash(currentRelative)+".sig") + suite.NotContains(files, filepath.ToSlash(previousRelative)) suite.driverIsInstalledWithVersion("test-driver-1", "1.0.0", true) } @@ -196,7 +272,7 @@ func (suite *SubcommandTestSuite) TestInstallDriverNoSignature() { m = InstallCmd{Driver: "test-driver-no-sig", NoVerify: true}. GetModelCustom(testBaseModel()) 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)) } func (suite *SubcommandTestSuite) TestInstallGitignoreDefaultBehavior() { @@ -314,8 +390,10 @@ func (suite *SubcommandTestSuite) TestInstallLocalPackageNotFound() { if runtime.GOOS == "windows" { errmsg = "The system cannot find the file specified." } + absolutePath, err := filepath.Abs(packagePath) + suite.Require().NoError(err) suite.validateOutput("Installing from local package: "+packagePath+ - "\r\n\r\n\r ", "\nError: open "+packagePath+": "+errmsg, out) + "\r\n\r\n\r ", fmt.Sprintf("\nError: open local artifact %q: open %s: %s", packagePath, absolutePath, errmsg), out) suite.driverIsNotInstalled("test-driver-2") } @@ -351,6 +429,258 @@ func (suite *SubcommandTestSuite) TestInstallLocalPackageFixUpName() { suite.driverIsInstalled("test-driver-1", true) } +func (suite *SubcommandTestSuite) TestInstallPackslipDirectUsesSignedIDWithoutRegistryLookup() { + archive := legacyArchiveForInstall(suite.T(), "1.2.3") + artifactURL := "https://assets.example.test/releases/package.tgz?sig=preserve-me" + resolver := &installPackslipResolverStub{release: packslipInstallRelease(suite.T(), "signed-driver-id", "1.2.3", artifactURL, archive)} + base := testBaseModel() + registryCalls, artifactCalls := 0, 0 + base.getDriverRegistry = func() ([]dbc.Driver, error) { + registryCalls++ + return nil, errors.New("registry lookup must not be used for Packslip") + } + base.newPackslipResolver = func() (packslip.Resolver, error) { return resolver, nil } + base.fetchPackslipArtifact = func(_ context.Context, resolvedURL *url.URL) (io.ReadCloser, error) { + artifactCalls++ + suite.Equal("sig=preserve-me", resolvedURL.RawQuery) + return io.NopCloser(bytes.NewReader(archive)), nil + } + projectDir := suite.T().TempDir() + originalDir, err := os.Getwd() + suite.Require().NoError(err) + suite.Require().NoError(os.Chdir(projectDir)) + suite.T().Cleanup(func() { suite.Require().NoError(os.Chdir(originalDir)) }) + + model := InstallCmd{Driver: "github.com/example/repo=1.2.3", Level: suite.configLevel, Json: true}. + GetModelCustom(base) + out := suite.runCmd(model) + suite.Equal(0, registryCalls) + suite.Equal(1, resolver.calls) + suite.Equal("github.com/example/repo", resolver.project) + suite.Empty(resolver.request.DriverID, "direct install must adopt the signed driver ID") + suite.Equal("1.2.3", resolver.request.Version) + suite.Equal(1, artifactCalls) + + lines := strings.Split(strings.TrimSpace(out), "\n") + var envelope jsonschema.Envelope + suite.Require().NoError(json.Unmarshal([]byte(lines[len(lines)-1]), &envelope)) + suite.Equal("install.status", envelope.Kind) + var status jsonschema.InstallStatus + suite.Require().NoError(json.Unmarshal(envelope.Payload, &status)) + suite.Equal("signed-driver-id", status.Driver) + suite.Equal("1.2.3", status.Version) + suite.Equal(&jsonschema.InstallSource{Type: "packslip", Reference: "github.com/example/repo"}, status.Source) + suite.driverIsInstalled("signed-driver-id", true) + for _, path := range []string{"dbc.toml", "dbc.lock", ".dbc.project.lock"} { + _, statErr := os.Stat(filepath.Join(projectDir, path)) + suite.True(os.IsNotExist(statErr), "%s should not be created by direct install", path) + } +} + +func (suite *SubcommandTestSuite) TestInstallPackslipDirectInspectsLegacyManifestArchive() { + archive, err := os.ReadFile(filepath.Join("testdata", "test-driver-no-sig.tar.gz")) + suite.Require().NoError(err) + artifactURL := "https://assets.example.test/releases/legacy-package.tgz" + resolver := &installPackslipResolverStub{release: packslipInstallRelease(suite.T(), "legacy-signed-id", "1.1.0", artifactURL, archive)} + base := testBaseModel() + base.newPackslipResolver = func() (packslip.Resolver, error) { return resolver, nil } + base.fetchPackslipArtifact = func(context.Context, *url.URL) (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(archive)), nil + } + command := InstallCmd{Driver: "github.com/example/repo=1.1.0", Level: suite.configLevel}. + GetModelCustom(base) + out := suite.runCmd(command) + suite.Contains(out, "Installed legacy-signed-id 1.1.0") + suite.driverIsInstalled("legacy-signed-id", true) +} + +func (suite *SubcommandTestSuite) TestInstallPackslipDirectFailsClosedBeforeRuntimeMutation() { + tests := []struct { + name string + packageID string + badHash bool + hostReq bool + wantError string + wantFetchCall int + }{ + {name: "signed archive hash mismatch despite skip flags", packageID: "bad-hash-id", badHash: true, wantError: "package archive hash mismatch", wantFetchCall: 1}, + {name: "unsupported host requirements", packageID: "host-req-id", hostReq: true, wantError: "unsupported host requirements", wantFetchCall: 0}, + } + for _, tc := range tests { + suite.Run(tc.name, func() { + archive := legacyArchiveForInstall(suite.T(), "1.2.3") + artifactURL := "https://assets.example.test/releases/" + tc.packageID + ".tgz" + resolver := &installPackslipResolverStub{release: packslipInstallRelease(suite.T(), tc.packageID, "1.2.3", artifactURL, archive)} + if tc.badHash { + resolver.release.Artifacts[0].Hash = "sha256:" + strings.Repeat("0", 64) + } + if tc.hostReq { + resolver.release.Artifacts[0].HostRequirements.OSMin = "99" + } + base := testBaseModel() + registryCalls, fetchCalls := 0, 0 + base.getDriverRegistry = func() ([]dbc.Driver, error) { + registryCalls++ + return nil, errors.New("registry lookup must not be used") + } + base.newPackslipResolver = func() (packslip.Resolver, error) { return resolver, nil } + base.fetchPackslipArtifact = func(context.Context, *url.URL) (io.ReadCloser, error) { + fetchCalls++ + return io.NopCloser(bytes.NewReader(archive)), nil + } + command := InstallCmd{ + Driver: "github.com/example/repo=1.2.3", Level: suite.configLevel, + NoVerify: true, InsecureNoChecksum: true, + }.GetModelCustom(base) + out := suite.runCmdErr(command) + suite.Contains(out, tc.wantError) + suite.Equal(0, registryCalls) + suite.Equal(tc.wantFetchCall, fetchCalls) + _, err := config.GetDriver(config.Config{Level: suite.configLevel, Location: suite.Dir()}, tc.packageID) + suite.Error(err, "failed Packslip proof must not create a runtime registration") + }) + } +} + +func (suite *SubcommandTestSuite) TestInstallPackslipRejectsInvalidInputsAndPreBeforeResolver() { + for _, input := range []string{ + "github.com/example/repo>=1.2.3", + "https://github.com/example/repo/releases/download/v1.2.3/package.tgz", + } { + suite.Run(input, func() { + resolverCalls, registryCalls := 0, 0 + base := testBaseModel() + base.newPackslipResolver = func() (packslip.Resolver, error) { + resolverCalls++ + return nil, errors.New("resolver must not be created") + } + base.getDriverRegistry = func() ([]dbc.Driver, error) { + registryCalls++ + return nil, errors.New("registry must not be queried") + } + out := suite.runCmdErr(InstallCmd{Driver: input, Level: suite.configLevel}.GetModelCustom(base)) + suite.Contains(out, "Packslip") + suite.Zero(resolverCalls) + suite.Zero(registryCalls) + }) + } + resolverCalls, registryCalls := 0, 0 + base := testBaseModel() + base.newPackslipResolver = func() (packslip.Resolver, error) { + resolverCalls++ + return nil, errors.New("resolver must not be created") + } + base.getDriverRegistry = func() ([]dbc.Driver, error) { + registryCalls++ + return nil, errors.New("registry must not be queried") + } + out := suite.runCmdErr(InstallCmd{Driver: "github.com/example/repo=1.2.3", Level: suite.configLevel, Pre: true}.GetModelCustom(base)) + suite.Contains(out, "--pre does not apply") + suite.Zero(resolverCalls) + suite.Zero(registryCalls) +} + +func (suite *SubcommandTestSuite) TestInstallLocalLegacyPackageUsesFilenameIDAndDoesNotReadProjectFiles() { + archive := legacyArchiveForInstall(suite.T(), "1.2.3") + archiveName := "local-driver_" + config.PlatformTuple() + "_v1.2.3.tar.gz" + projectDir := suite.T().TempDir() + originalDir, err := os.Getwd() + suite.Require().NoError(err) + suite.Require().NoError(os.Chdir(projectDir)) + suite.T().Cleanup(func() { suite.Require().NoError(os.Chdir(originalDir)) }) + suite.Require().NoError(os.WriteFile(archiveName, archive, 0o600)) + registryCalls := 0 + base := testBaseModel() + base.getDriverRegistry = func() ([]dbc.Driver, error) { + registryCalls++ + return nil, errors.New("registry lookup must not be used for a local package") + } + out := suite.runCmd(InstallCmd{Driver: archiveName, Level: suite.configLevel, Json: true}.GetModelCustom(base)) + suite.Equal(0, registryCalls) + lines := strings.Split(strings.TrimSpace(out), "\n") + var envelope jsonschema.Envelope + suite.Require().NoError(json.Unmarshal([]byte(lines[len(lines)-1]), &envelope)) + var status jsonschema.InstallStatus + suite.Require().NoError(json.Unmarshal(envelope.Payload, &status)) + suite.Equal("local-driver", status.Driver) + suite.Equal(&jsonschema.InstallSource{Type: "path", Reference: archiveName}, status.Source) + suite.driverIsInstalled("local-driver", true) + for _, path := range []string{"dbc.toml", "dbc.lock", ".dbc.project.lock"} { + _, statErr := os.Stat(filepath.Join(projectDir, path)) + suite.True(os.IsNotExist(statErr), "%s should not be created by direct install", path) + } +} + +func TestParsePackslipInstallArgumentRecognizesSupportedSyntax(t *testing.T) { + project, version, matched, err := parsePackslipInstallArgument("github.com/owner/repo/tool=1.2.3") + if err != nil || !matched || project != "github.com/owner/repo/tool" || version != "1.2.3" { + t.Fatalf("valid Packslip install argument parsed as %q, %q, %v, %v", project, version, matched, err) + } + if _, _, matched, err := parsePackslipInstallArgument("owner/repo=1.2.3"); matched || err != nil { + t.Fatalf("short owner/repo syntax should not be interpreted as Packslip: matched=%v err=%v", matched, err) + } +} + +func TestDirectLocalPackageRejectsReplacementAfterResolution(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "driver.tgz") + first := legacyArchiveForInstall(t, "1.2.3") + if err := os.WriteFile(path, first, 0o600); err != nil { + t.Fatal(err) + } + model := progressiveInstallModel{ + isLocal: true, + localPackagePath: path, + NoVerify: true, + cfg: config.Config{Level: config.ConfigEnv, Location: filepath.Join(root, "install")}, + } + item, err := model.resolveDirectInstall(context.Background()) + if err != nil { + t.Fatal(err) + } + _, _ = makeSyncLegacyPackageArchive(t, path, "1.2.3") + executor, err := model.newDirectInstallExecutor(item) + if err != nil { + t.Fatal(err) + } + if err := executor.prepareItem(context.Background(), &item); err == nil || !strings.Contains(err.Error(), "package archive hash mismatch") { + t.Fatalf("replacement archive error = %v, want expected-hash mismatch", err) + } + if item.Archive != nil { + if err := item.Archive.Close(); err != nil { + t.Fatal(err) + } + } + if _, err := config.GetDriver(config.Config{Level: config.ConfigEnv, Location: filepath.Join(root, "install")}, "local-driver"); err == nil { + t.Fatal("replacement archive should not mutate the runtime installation") + } +} + +func TestInstallConfigForEnsurePreservesPrimaryWithMissingSecondary(t *testing.T) { + root := t.TempDir() + primary := filepath.Join(root, "primary") + secondary := filepath.Join(root, "secondary") + missing := filepath.Join(root, "missing") + if err := os.MkdirAll(secondary, 0o755); err != nil { + t.Fatal(err) + } + + cfg, err := installConfigForEnsure(config.Config{ + Level: config.ConfigEnv, + Location: strings.Join([]string{primary, secondary, missing}, string(filepath.ListSeparator)), + }) + if err != nil { + t.Fatal(err) + } + want := strings.Join([]string{primary, secondary}, string(filepath.ListSeparator)) + if cfg.Location != want { + t.Fatalf("ensure install roots = %q, want %q", cfg.Location, want) + } + if info, err := os.Stat(primary); err != nil || !info.IsDir() { + t.Fatalf("primary install root was not created: info=%v err=%v", info, err) + } +} + func (suite *SubcommandTestSuite) TestInstallWithPreOnlyPrereleaseDriver() { // Install test-driver-only-pre with --pre flag, should succeed m := InstallCmd{Driver: "test-driver-only-pre", Level: suite.configLevel, Pre: true}. @@ -483,7 +813,60 @@ func (suite *SubcommandTestSuite) TestInstallDriverWithSubdirectories() { out := suite.runCmdErr(m) // and return an error with this - suite.Contains(out, "driver archives shouldn't contain subdirectories") + suite.Contains(out, "driver archives must be flat") +} + +func legacyArchiveForInstall(t *testing.T, version string) []byte { + t.Helper() + var buffer bytes.Buffer + gz := gzip.NewWriter(&buffer) + tw := tar.NewWriter(gz) + manifest := fmt.Sprintf(`manifest_version = 1 +name = "Example Driver" +version = %q + +[Driver] +entrypoint = "AdbcDriverExampleInit" + +[Files] +driver = "driver.so" +signature = "driver.so.sig" +`, version) + library, signature := signedLegacyPayload(t) + for _, entry := range []struct { + name string + data []byte + }{{"MANIFEST", []byte(manifest)}, {"driver.so", library}, {"driver.so.sig", signature}} { + if err := tw.WriteHeader(&tar.Header{Name: entry.name, Mode: 0o644, Size: int64(len(entry.data)), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(entry.data); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} + +func openInstallArchive(t *testing.T, data []byte) *os.File { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "package-*.tar.gz") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = f.Close() }) + if _, err := f.Write(data); err != nil { + t.Fatal(err) + } + if _, err := f.Seek(0, 0); err != nil { + t.Fatal(err) + } + return f } func (suite *SubcommandTestSuite) TestInstallJSON() { @@ -506,6 +889,7 @@ func (suite *SubcommandTestSuite) TestInstallJSON() { suite.Equal("test-driver-1", status.Driver) suite.NotEmpty(status.Version) suite.NotEmpty(status.Location) + suite.Equal(&jsonschema.InstallSource{Type: "registry", Reference: testRegistry.BaseURL.String()}, status.Source) } func (suite *SubcommandTestSuite) TestInstall_ChecksumInStatus() { @@ -581,17 +965,15 @@ func (suite *SubcommandTestSuite) TestInstall_JSONProgressStream() { suite.True(hasDownloadStart, "expected download.start event") } -// 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 -// FinalOutput() must not emit an install.status success envelope. -func (suite *SubcommandTestSuite) TestInstallJSON_AlreadyInstalledChecksumFailure() { +// TestInstallJSON_AlreadyInstalledLibraryTamperingIsRepaired verifies that +// receipt/library integrity changes force repair instead of version-only skip. +func (suite *SubcommandTestSuite) TestInstallJSON_AlreadyInstalledLibraryTamperingIsRepaired() { // First install the driver normally. m := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel}. GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) suite.runCmd(m) - // Locate and delete the shared library so checksum() will fail. + // Locate and delete the shared library to simulate runtime tampering. cfg := config.Get()[suite.configLevel] driver, err := config.GetDriver(cfg, "test-driver-1") suite.Require().NoError(err) @@ -599,33 +981,49 @@ func (suite *SubcommandTestSuite) TestInstallJSON_AlreadyInstalledChecksumFailur suite.Require().NotEmpty(sharedPath, "shared library path should not be empty") suite.Require().NoError(os.Remove(sharedPath)) - // Reinstall with --json. The already-installed path fires, but checksum - // fails because the file is gone. runCmdErr now appends FinalOutput() so - // the JSON error envelope is captured through the shared harness path, - // matching how main.go emits it. + // Reinstall with --json. The package executor must notice that the current + // library no longer matches its receipt and repair it from the registry. m2 := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel, Json: true}. GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) - out := suite.runCmdErr(m2) - - // The combined output must contain the structured error envelope. - suite.NotEmpty(out, "expected error output from install JSON error path") - suite.NotContains(out, `"install.status"`, "must not emit success envelope when checksum fails") - - // Decode the envelope and assert the correct kind and code. - // runCmdErr appends FinalOutput(), so the last non-empty line is the JSON. + out := suite.runCmd(m2) lines := strings.Split(strings.TrimSpace(out), "\n") - var jsonLine string - for i := len(lines) - 1; i >= 0; i-- { - if strings.HasPrefix(strings.TrimSpace(lines[i]), "{") { - jsonLine = strings.TrimSpace(lines[i]) + var env jsonschema.Envelope + suite.Require().NoError(json.Unmarshal([]byte(lines[len(lines)-1]), &env)) + suite.Equal("install.status", env.Kind) + var status jsonschema.InstallStatus + suite.Require().NoError(json.Unmarshal(env.Payload, &status)) + suite.Equal("installed", status.Status) + repaired, err := config.GetDriver(config.Get()[suite.configLevel], "test-driver-1") + suite.Require().NoError(err) + suite.FileExists(repaired.Driver.Shared.Get(config.PlatformTuple())) +} + +func signedLegacyPayload(t *testing.T) ([]byte, []byte) { + t.Helper() + f, err := os.Open(filepath.Join("testdata", "test-driver-1.tar.gz")) + require.NoError(t, err) + defer f.Close() + gz, err := gzip.NewReader(f) + require.NoError(t, err) + defer gz.Close() + tr := tar.NewReader(gz) + var library, signature []byte + for { + header, err := tr.Next() + if err == io.EOF { break } + require.NoError(t, err) + data, err := io.ReadAll(tr) + require.NoError(t, err) + if strings.HasSuffix(header.Name, ".so") { + library = data + } + if strings.HasSuffix(header.Name, ".sig") { + signature = data + } } - suite.NotEmpty(jsonLine, "expected a JSON line in output: %s", out) - var errEnv jsonschema.Envelope - suite.Require().NoError(json.Unmarshal([]byte(jsonLine), &errEnv), "must be valid JSON: %s", jsonLine) - suite.Equal("error", errEnv.Kind, "expected kind=error") - var errPayload jsonschema.ErrorResponse - suite.Require().NoError(json.Unmarshal(errEnv.Payload, &errPayload)) - suite.Equal("install_failed", errPayload.Code, "expected install_failed error code") + require.NotEmpty(t, library) + require.NotEmpty(t, signature) + return library, signature } diff --git a/cmd/dbc/lockfile.go b/cmd/dbc/lockfile.go index f5b52dc1..352d4ad7 100644 --- a/cmd/dbc/lockfile.go +++ b/cmd/dbc/lockfile.go @@ -19,61 +19,306 @@ import ( "fmt" "io" "os" + "strings" "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc/internal/resolution" + "github.com/columnar-tech/dbc/internal/sourceidentity" "github.com/pelletier/go-toml/v2" ) -const lockFileVersion = 1 +const ( + lockFileVersionV1 = 1 + lockFileVersion = 2 +) + +// LockFile is the normalized in-memory form of supported lockfile versions. +// Version 1 entries are retained as legacy library proofs until a source has +// resolved the corresponding archive metadata. +type LockFile struct { + Version int `toml:"version" comment:"This file is automatically @generated by dbc. Not intended for manual editing"` + Revision int `toml:"revision"` + Drivers []lockInfo `toml:"drivers"` + + lockinfo map[string]lockInfo `toml:"-"` +} type lockInfo struct { + Name string `toml:"name"` + Version *semver.Version `toml:"version"` + Source lockSource `toml:"source"` + Evidence []lockEvidence `toml:"evidence,omitempty"` + Artifacts []lockArtifact `toml:"artifacts,omitempty"` + Legacy *legacyLibraryProof `toml:"legacy,omitempty"` + // These fields are transient adapters for the current sync pipeline. They + // are populated only while reading a v1 lock and are never serialized. + Platform string `toml:"-"` + Checksum string `toml:"-"` +} + +type lockSource struct { + Type string `toml:"type"` + Project string `toml:"project,omitempty"` + URL string `toml:"url,omitempty"` + Path string `toml:"path,omitempty"` +} + +type lockEvidence struct { + Kind resolution.EvidenceKind `toml:"kind"` + Location resolution.ArtifactLocation `toml:"location"` + Hash string `toml:"hash"` +} + +type lockArtifact struct { + Target resolution.Target `toml:"target"` + Format string `toml:"format,omitempty"` + Location resolution.ArtifactLocation `toml:"location"` + Hash string `toml:"hash"` + Size *int64 `toml:"size"` + HostRequirements lockHostRequirements `toml:"host_requirements,omitempty"` +} + +type lockHostRequirements struct { + OSMin string `toml:"os_min,omitempty"` + GLibCMin string `toml:"glibc_min,omitempty"` + Libs []string `toml:"libs,omitempty"` + Bins []lockNamedRequirement `toml:"bins,omitempty"` +} + +type lockNamedRequirement struct { + Name string `toml:"name"` + Min string `toml:"min,omitempty"` +} + +// legacyLibraryProof is deliberately distinct from lockArtifact.Hash: v1 +// stored the hash of the installed shared library, not the archive. +type legacyLibraryProof struct { + Platform string `toml:"platform"` + LibraryHash string `toml:"library_hash"` +} + +type lockFileHeader struct { + Version *int `toml:"version"` +} + +type lockFileV1 struct { + Version int `toml:"version"` + Drivers []lockDriverV1 `toml:"drivers"` +} + +type lockDriverV1 struct { Name string `toml:"name"` Version *semver.Version `toml:"version"` Platform string `toml:"platform,omitempty"` Checksum string `toml:"checksum,omitempty"` } -type LockFile struct { - Version int `toml:"version" comment:"This file is automatically @generated by dbc. Not intended for manual editing"` - Drivers []lockInfo `toml:"drivers"` - - lockinfo map[string]lockInfo `toml:"-"` -} +type lockFileV2 LockFile func loadLockFile(p string) (LockFile, error) { var lf LockFile - f, err := os.Open(p) + data, err := os.ReadFile(p) if err != nil { return lf, fmt.Errorf("error opening lock file %s: %w", p, err) } - defer f.Close() - if err := toml.NewDecoder(f).Decode(&lf); err != nil { + var header lockFileHeader + if err := toml.Unmarshal(data, &header); err != nil { return lf, fmt.Errorf("error decoding lock file %s: %w", p, err) } - if lf.Version != lockFileVersion { - return lf, fmt.Errorf("unsupported lock file version %d; this version of dbc supports lock file version %d", lf.Version, lockFileVersion) + if header.Version == nil { + return lf, fmt.Errorf("error decoding lock file %s: missing version", p) } - lf.lockinfo = make(map[string]lockInfo) - for _, d := range lf.Drivers { - lf.lockinfo[d.Name] = d + switch *header.Version { + case lockFileVersionV1: + var wire lockFileV1 + if err := toml.Unmarshal(data, &wire); err != nil { + return lf, fmt.Errorf("error decoding lock file %s: %w", p, err) + } + lf, err = normalizeLockFileV1(wire) + if err != nil { + return LockFile{}, fmt.Errorf("invalid lock file %s: %w", p, err) + } + case lockFileVersion: + var wire lockFileV2 + if err := toml.Unmarshal(data, &wire); err != nil { + return lf, fmt.Errorf("error decoding lock file %s: %w", p, err) + } + lf = LockFile(wire) + if err := validateLockFileV2(lf); err != nil { + return LockFile{}, fmt.Errorf("invalid lock file %s: %w", p, err) + } + default: + return lf, fmt.Errorf("unsupported lock file version %d", *header.Version) } + lf.indexDrivers() + return lf, nil +} + +func normalizeLockFileV1(wire lockFileV1) (LockFile, error) { + lf := LockFile{Version: lockFileVersionV1} + seenNames := make(map[string]struct{}, len(wire.Drivers)) + for i, old := range wire.Drivers { + if old.Name == "" { + return LockFile{}, fmt.Errorf("driver %d has no name", i) + } + if _, exists := seenNames[old.Name]; exists { + return LockFile{}, fmt.Errorf("duplicate driver %q", old.Name) + } + seenNames[old.Name] = struct{}{} + if old.Version == nil { + return LockFile{}, fmt.Errorf("driver %q has no version", old.Name) + } + driver := lockInfo{Name: old.Name, Version: old.Version} + if (old.Platform == "") != (old.Checksum == "") { + return LockFile{}, fmt.Errorf("driver %d must have both platform and checksum or neither", i) + } + if old.Platform != "" && old.Checksum != "" { + if err := validateLegacyLibraryHash(old.Checksum); err != nil { + return LockFile{}, fmt.Errorf("driver %q has invalid v1 checksum: %w", old.Name, err) + } + driver.Legacy = &legacyLibraryProof{Platform: old.Platform, LibraryHash: old.Checksum} + driver.Platform = old.Platform + driver.Checksum = old.Checksum + } + lf.Drivers = append(lf.Drivers, driver) + } return lf, nil } +func (d lockInfo) legacyChecksumFor(platform string) string { + if d.Legacy == nil || !samePlatformTarget(d.Legacy.Platform, platform) { + return "" + } + return d.Legacy.LibraryHash +} + +func (lf *LockFile) indexDrivers() { + lf.lockinfo = make(map[string]lockInfo, len(lf.Drivers)) + for _, driver := range lf.Drivers { + lf.lockinfo[driver.Name] = driver + } +} + +func validateLockFileV2(lf LockFile) error { + if lf.Version != lockFileVersion { + return fmt.Errorf("expected lock file version %d, got %d", lockFileVersion, lf.Version) + } + if lf.Revision < 0 { + return fmt.Errorf("lock file revision must not be negative") + } + seenDrivers := make(map[string]struct{}, len(lf.Drivers)) + for i, driver := range lf.Drivers { + if driver.Name == "" { + return fmt.Errorf("driver %d has no name", i) + } + if _, exists := seenDrivers[driver.Name]; exists { + return fmt.Errorf("duplicate driver %q", driver.Name) + } + seenDrivers[driver.Name] = struct{}{} + if driver.Version == nil { + return fmt.Errorf("driver %q has no version", driver.Name) + } + if err := validateLockInfo(driver); err != nil { + return fmt.Errorf("driver %q: %w", driver.Name, err) + } + } + return nil +} + +func validateLockSource(source lockSource) error { + if source.Type == "" { + return fmt.Errorf("missing type") + } + switch source.Type { + case "packslip": + if source.Project == "" { + return fmt.Errorf("packslip source has no project") + } + if _, err := sourceidentity.Parse(sourceidentity.Packslip, source.Project); err != nil { + return err + } + if source.URL != "" || source.Path != "" { + return fmt.Errorf("packslip source contains fields for another source type") + } + case "registry": + if source.URL == "" { + return fmt.Errorf("registry source has no URL") + } + if _, err := sourceidentity.Parse(sourceidentity.Registry, source.URL); err != nil { + return err + } + if source.Project != "" || source.Path != "" { + return fmt.Errorf("registry source contains fields for another source type") + } + case "path": + if _, err := sourceidentity.Parse(sourceidentity.Path, source.Path); err != nil { + return err + } + if source.Project != "" || source.URL != "" { + return fmt.Errorf("path source contains fields for another source type") + } + default: + return fmt.Errorf("unsupported source type %q", source.Type) + } + return nil +} + +func parsePlatformTuple(platform string) (osName, arch, libc, variant string, ok bool) { + target, err := resolution.TargetFromPlatformTuple(platform) + if err != nil { + return "", "", "", "", false + } + return target.OS, target.Arch, target.LibC, target.Variant, true +} + +func targetIdentity(target resolution.Target) string { + return strings.Join([]string{target.OS, target.Arch, target.LibC, target.Variant}, "\x00") +} + +func samePlatformTarget(left, right string) bool { + leftTarget, leftErr := resolution.TargetFromPlatformTuple(left) + rightTarget, rightErr := resolution.TargetFromPlatformTuple(right) + if leftErr != nil || rightErr != nil { + return left == right + } + return leftTarget == rightTarget +} + +func validateLegacyLibraryHash(hash string) error { + if len(hash) != 64 { + return fmt.Errorf("installed-library checksum must contain 64 lowercase hexadecimal characters") + } + for _, ch := range hash { + if (ch < '0' || ch > '9') && (ch < 'a' || ch > 'f') { + return fmt.Errorf("installed-library checksum must contain 64 lowercase hexadecimal characters") + } + } + return nil +} + func checksum(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", fmt.Errorf("error opening file %s for checksum: %w", path, err) } defer f.Close() + return checksumFile(f, path) +} +func checksumFile(file *os.File, path string) (string, error) { + if _, err := file.Seek(0, io.SeekStart); err != nil { + return "", fmt.Errorf("error seeking file %s for checksum: %w", path, err) + } h := sha256.New() - if _, err := io.Copy(h, f); err != nil { + if _, err := io.Copy(h, file); err != nil { return "", fmt.Errorf("error calculating checksum for file %s: %w", path, err) } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return "", fmt.Errorf("error rewinding file %s after checksum: %w", path, err) + } return fmt.Sprintf("%x", h.Sum(nil)), nil } diff --git a/cmd/dbc/lockfile_test.go b/cmd/dbc/lockfile_test.go index c127cee3..46402b37 100644 --- a/cmd/dbc/lockfile_test.go +++ b/cmd/dbc/lockfile_test.go @@ -23,56 +23,24 @@ import ( "github.com/stretchr/testify/require" ) -func TestLoadLockFileVersion(t *testing.T) { - tests := []struct { - name string - contents string - wantErr string - }{ - { - name: "version 1", - contents: "version = 1\n[[drivers]]\nname = 'test-driver-1'\nversion = '1.0.0'\n", - }, - { - name: "version 2", - contents: "version = 2\n", - wantErr: "unsupported lock file version 2", - }, - { - name: "version 2 with driver metadata", - contents: "version = 2\nrevision = 0\n" + - "[[drivers]]\nname = 'test-driver-1'\nversion = '1.0.0'\n" + - "[drivers.source]\ntype = 'packslip'\n" + - "[[drivers.artifacts]]\nformat = 'tar.gz'\n", - wantErr: "unsupported lock file version 2", - }, - { - name: "future version", - contents: "version = 99\n", - wantErr: "unsupported lock file version 99", - }, - { - name: "missing version", - contents: "[[drivers]]\nname = 'test-driver-1'\nversion = '1.0.0'\n", - wantErr: "unsupported lock file version 0", - }, - } +func TestLoadLockFileRejectsFutureVersionBeforeIndexing(t *testing.T) { + path := filepath.Join(t.TempDir(), "dbc.lock") + contents := "version = 3\nrevision = 0\n" + + "[[drivers]]\nname = 'test-driver-1'\nversion = '1.0.0'\n" + + "[drivers.source]\ntype = 'packslip'\nproject = 'github.com/example/test-driver-1'\n" + + "[[drivers.artifacts]]\nformat = 'tar.gz'\n" + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - path := filepath.Join(t.TempDir(), "dbc.lock") - require.NoError(t, os.WriteFile(path, []byte(tt.contents), 0o644)) + lock, err := loadLockFile(path) + require.ErrorContains(t, err, "unsupported lock file version 3") + assert.Nil(t, lock.lockinfo) +} - lock, err := loadLockFile(path) - if tt.wantErr != "" { - require.ErrorContains(t, err, tt.wantErr) - assert.Nil(t, lock.lockinfo) - return - } +func TestLoadLockFileRejectsMissingVersion(t *testing.T) { + path := filepath.Join(t.TempDir(), "dbc.lock") + require.NoError(t, os.WriteFile(path, []byte("[[drivers]]\nname = 'test-driver-1'\nversion = '1.0.0'\n"), 0o600)) - require.NoError(t, err) - assert.Equal(t, lockFileVersion, lock.Version) - assert.Equal(t, "1.0.0", lock.lockinfo["test-driver-1"].Version.String()) - }) - } + lock, err := loadLockFile(path) + require.ErrorContains(t, err, "missing version") + assert.Nil(t, lock.lockinfo) } diff --git a/cmd/dbc/lockfile_v2.go b/cmd/dbc/lockfile_v2.go new file mode 100644 index 00000000..be9947d1 --- /dev/null +++ b/cmd/dbc/lockfile_v2.go @@ -0,0 +1,537 @@ +// 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 main + +import ( + "bytes" + "errors" + "fmt" + "reflect" + "sort" + + "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc/internal/atomicfile" + "github.com/columnar-tech/dbc/internal/resolution" + "github.com/columnar-tech/dbc/internal/sourceidentity" + "github.com/columnar-tech/dbc/internal/sourceresolution" + "github.com/pelletier/go-toml/v2" +) + +var ( + ErrLockedArtifactMissing = errors.New("locked artifact missing") + ErrLockRefreshRequired = errors.New("lock refresh required") + ErrLockedModeArtifactMissing = errors.New("locked mode artifact missing") + ErrArtifactAmbiguous = errors.New("locked artifact selection is ambiguous") +) + +func validateLockArtifacts(artifacts []lockArtifact) error { + seenTargets := make(map[resolution.Target]struct{}, len(artifacts)) + seenLocations := make(map[resolution.ArtifactLocation]lockArtifact, len(artifacts)) + for i, artifact := range artifacts { + if err := resolution.ValidateConcreteTarget(artifact.Target); err != nil { + return fmt.Errorf("artifact %d has invalid target: %w", i, err) + } + if canonical := resolution.CanonicalTarget(artifact.Target); canonical != artifact.Target { + return fmt.Errorf("artifact %d target is not canonical", i) + } + if err := resolution.ValidateArtifactLocation(artifact.Location); err != nil { + return fmt.Errorf("artifact %d has invalid location: %w", i, err) + } + if artifact.Hash == "" { + return fmt.Errorf("artifact %d must have a finalized hash", i) + } + if err := validateLockHash(artifact.Hash); err != nil { + return fmt.Errorf("artifact %d: %w", i, err) + } + if artifact.Size != nil && *artifact.Size < 0 { + return fmt.Errorf("artifact %d has negative size", i) + } + if _, exists := seenTargets[artifact.Target]; exists { + return fmt.Errorf("duplicate artifact target %q", artifactSelectorIdentity(artifact)) + } + seenTargets[artifact.Target] = struct{}{} + if prior, exists := seenLocations[artifact.Location]; exists { + if prior.Hash != artifact.Hash || !compatibleLockSize(prior.Size, artifact.Size) { + return fmt.Errorf("artifacts sharing location %q have conflicting hash or size", artifact.Location.Value) + } + if prior.Size == nil && artifact.Size != nil { + seenLocations[artifact.Location] = artifact + } + } else { + seenLocations[artifact.Location] = artifact + } + } + return nil +} + +func sameLockSize(left, right *int64) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + return *left == *right +} + +func compatibleLockSize(left, right *int64) bool { + return left == nil || right == nil || *left == *right +} + +func validateLockHash(hash string) error { + // Share the resolution layer's canonical digest contract. + return resolution.ValidateArtifactMetadata(hash, nil) +} + +func artifactSelectorIdentity(artifact lockArtifact) string { + return targetIdentity(artifact.Target) +} + +// LockRefreshRequiredError distinguishes a normal install that can be fixed +// by explicitly refreshing the lock from a locked-mode failure. +type LockRefreshRequiredError struct { + DriverID string + Platform string + Reason string +} + +func (e *LockRefreshRequiredError) Error() string { + if e.Reason != "" { + return fmt.Sprintf("lock file for %s on %s requires refresh: %s", e.DriverID, e.Platform, e.Reason) + } + return fmt.Sprintf("lock file has no artifact for %s on %s; refresh the lock file explicitly", e.DriverID, e.Platform) +} + +func (e *LockRefreshRequiredError) Is(target error) bool { + return target == ErrLockRefreshRequired || target == ErrLockedArtifactMissing +} + +type LockedModeArtifactMissingError struct { + DriverID string + Platform string +} + +func (e *LockedModeArtifactMissingError) Error() string { + return fmt.Sprintf("%s for %s on %s in locked mode", ErrLockedArtifactMissing, e.DriverID, e.Platform) +} + +func (e *LockedModeArtifactMissingError) Is(target error) bool { + return target == ErrLockedModeArtifactMissing || target == ErrLockedArtifactMissing +} + +// VerifiedLegacyLibrary is evidence from the installed shared library itself. +// It must never be populated from an archive hash. +type VerifiedLegacyLibrary struct { + Platform string + LibraryHash string +} + +// lockInfoFromResolvedRelease is the snapshot boundary: a lock entry may only +// be created from a source-resolved release with finalized archive metadata. +// Source identity and source-specific artifact requirements are validated by +// sourceresolution before this conversion; load/write boundaries validate the +// complete lock representation. +func lockInfoFromResolvedRelease(name string, release resolution.ResolvedRelease) (lockInfo, error) { + var entry lockInfo + if name == "" || release.DriverID != name { + return entry, fmt.Errorf("resolved release driver ID %q does not match lock entry %q", release.DriverID, name) + } + if err := resolution.ValidateResolvedRelease(release); err != nil { + return entry, fmt.Errorf("cannot snapshot unresolved release: %w", err) + } + version, err := semver.NewVersion(release.Version) + if err != nil { + return entry, fmt.Errorf("invalid resolved version %q: %w", release.Version, err) + } + entry = lockInfo{ + Name: name, + Version: version, + Source: lockSource{ + Type: release.Source.Type, + }, + Evidence: lockEvidenceFromResolution(release.Evidence), + } + switch release.Source.Type { + case "packslip": + entry.Source.Project = release.Source.Reference + case "registry": + entry.Source.URL = release.Source.Reference + case "path": + entry.Source.Path = release.Source.Reference + default: + return lockInfo{}, fmt.Errorf("unsupported resolved source type %q", release.Source.Type) + } + for _, artifact := range release.Artifacts { + entry.Artifacts = append(entry.Artifacts, lockArtifactFromResolved(artifact)) + } + return entry, nil +} + +func lockArtifactFromResolved(artifact resolution.Artifact) lockArtifact { + result := lockArtifact{ + Target: resolution.CanonicalTarget(artifact.Target), + Format: artifact.Format, + Location: artifact.Location, + Hash: artifact.Hash, + Size: cloneInt64(artifact.Size), + HostRequirements: lockHostRequirementsFromResolution(artifact.HostRequirements), + } + return result +} + +func lockHostRequirementsFromResolution(requirements resolution.HostRequirements) lockHostRequirements { + result := lockHostRequirements{ + OSMin: requirements.OSMin, + GLibCMin: requirements.GLibCMin, + Libs: append([]string(nil), requirements.Libs...), + } + for _, bin := range requirements.Bins { + result.Bins = append(result.Bins, lockNamedRequirement{Name: bin.Name, Min: bin.Min}) + } + return result +} + +func resolutionHostRequirementsFromLock(requirements lockHostRequirements) resolution.HostRequirements { + result := resolution.HostRequirements{ + OSMin: requirements.OSMin, + GLibCMin: requirements.GLibCMin, + Libs: append([]string(nil), requirements.Libs...), + } + for _, bin := range requirements.Bins { + result.Bins = append(result.Bins, resolution.NamedRequirement{Name: bin.Name, Min: bin.Min}) + } + return result +} + +func cloneLockInfo(entry lockInfo) lockInfo { + result := entry + result.Evidence = append([]lockEvidence(nil), entry.Evidence...) + result.Artifacts = append([]lockArtifact(nil), entry.Artifacts...) + for i := range result.Artifacts { + result.Artifacts[i].Size = cloneInt64(entry.Artifacts[i].Size) + result.Artifacts[i].HostRequirements.Libs = append([]string(nil), entry.Artifacts[i].HostRequirements.Libs...) + result.Artifacts[i].HostRequirements.Bins = append([]lockNamedRequirement(nil), entry.Artifacts[i].HostRequirements.Bins...) + } + if entry.Legacy != nil { + legacy := *entry.Legacy + result.Legacy = &legacy + } + return result +} + +func (d lockInfo) resolvedRelease() resolution.ResolvedRelease { + sourceReference := "" + switch d.Source.Type { + case "packslip": + sourceReference = d.Source.Project + case "registry": + sourceReference = d.Source.URL + case "path": + sourceReference = d.Source.Path + } + release := resolution.ResolvedRelease{ + DriverID: d.Name, + Source: resolution.SourceSpec{Type: d.Source.Type, Reference: sourceReference}, + Evidence: resolutionEvidenceFromLock(d.Evidence), + } + if d.Version != nil { + release.Version = d.Version.String() + } + for _, artifact := range d.Artifacts { + resolved := resolution.Artifact{ + Target: artifact.Target, + Format: artifact.Format, + Location: artifact.Location, + Hash: artifact.Hash, + Size: cloneInt64(artifact.Size), + HostRequirements: resolutionHostRequirementsFromLock(artifact.HostRequirements), + } + release.Artifacts = append(release.Artifacts, resolved) + } + return release +} + +func validateLockInfo(entry lockInfo) error { + if entry.Name == "" { + return errors.New("lock entry has no driver name") + } + if entry.Version == nil { + return fmt.Errorf("driver %q has no version", entry.Name) + } + if entry.Legacy != nil { + if entry.Legacy.Platform == "" { + return fmt.Errorf("driver %q legacy proof has no platform", entry.Name) + } + if err := validateLegacyLibraryHash(entry.Legacy.LibraryHash); err != nil { + return fmt.Errorf("driver %q legacy proof: %w", entry.Name, err) + } + } + if err := validateLockSource(entry.Source); err != nil { + return fmt.Errorf("driver %q source: %w", entry.Name, err) + } + if err := resolution.ValidateEvidence(resolutionEvidenceFromLock(entry.Evidence)); err != nil { + return fmt.Errorf("driver %q evidence: %w", entry.Name, err) + } + if len(entry.Artifacts) == 0 { + return fmt.Errorf("driver %q has no locked artifacts", entry.Name) + } + if err := validateLockArtifacts(entry.Artifacts); err != nil { + return err + } + return nil +} + +func cloneInt64(value *int64) *int64 { + if value == nil { + return nil + } + copy := *value + return © +} + +func lockEvidenceFromResolution(evidence []resolution.Evidence) []lockEvidence { + if len(evidence) == 0 { + return nil + } + result := make([]lockEvidence, len(evidence)) + for i, item := range evidence { + result[i] = lockEvidence{Kind: item.Kind, Location: item.Location, Hash: item.Hash} + } + return result +} + +func resolutionEvidenceFromLock(evidence []lockEvidence) []resolution.Evidence { + if len(evidence) == 0 { + return nil + } + result := make([]resolution.Evidence, len(evidence)) + for i, item := range evidence { + result[i] = resolution.Evidence{Kind: item.Kind, Location: item.Location, Hash: item.Hash} + } + return result +} + +// migrateV1Entry upgrades metadata without changing the resolved version. +// If this machine currently uses the legacy platform, the installed-library +// checksum must be explicitly re-verified before producing a v2 snapshot. +func migrateV1Entry(old lockInfo, release resolution.ResolvedRelease, currentPlatform string, verified *VerifiedLegacyLibrary) (lockInfo, error) { + if old.Version == nil { + return lockInfo{}, fmt.Errorf("v1 lock entry %q has no version", old.Name) + } + if old.Name != release.DriverID || release.Version != old.Version.String() { + return lockInfo{}, fmt.Errorf("migration must preserve driver %q version %s", old.Name, old.Version) + } + if old.Legacy != nil && samePlatformTarget(old.Legacy.Platform, currentPlatform) { + if verified == nil || !samePlatformTarget(verified.Platform, old.Legacy.Platform) || verified.LibraryHash != old.Legacy.LibraryHash { + return lockInfo{}, fmt.Errorf("cannot migrate %s on %s without a matching verified installed-library hash", old.Name, currentPlatform) + } + } + entry, err := lockInfoFromResolvedRelease(old.Name, release) + if err != nil { + return lockInfo{}, err + } + if old.Legacy != nil { + proof := *old.Legacy + entry.Legacy = &proof + } + return entry, nil +} + +// verifyLegacyLibraryProof checks the installed library against the v1 proof +// when an artifact for its original platform is used after migration. +func verifyLegacyLibraryProof(entry lockInfo, platform, installedLibraryHash string) error { + if entry.Legacy == nil || !samePlatformTarget(entry.Legacy.Platform, platform) { + return nil + } + if installedLibraryHash != entry.Legacy.LibraryHash { + return fmt.Errorf("legacy installed-library checksum mismatch for %s on %s", entry.Name, platform) + } + return nil +} + +// refreshLockEntry merges metadata for the same source and version. Existing +// artifacts are immutable: a refresh may add a target but cannot replace an +// existing target's location, format, digest, size, or host requirements. +func refreshLockEntry(existing, refreshed lockInfo) (lockInfo, error) { + if existing.Name != refreshed.Name || !sourceresolution.SameReleaseVersion( + sourceidentity.Kind(existing.Source.Type), existing.Version, refreshed.Version, + ) { + return lockInfo{}, errors.New("metadata refresh must keep the locked driver version") + } + if !sameLockSourceIdentity(existing.Source, refreshed.Source) { + return lockInfo{}, errors.New("metadata refresh must keep the locked source identity") + } + if err := validateLockInfo(existing); err != nil { + return lockInfo{}, fmt.Errorf("existing lock entry: %w", err) + } + if err := validateLockInfo(refreshed); err != nil { + return lockInfo{}, fmt.Errorf("refreshed lock entry: %w", err) + } + merged := existing + merged.Artifacts = append([]lockArtifact(nil), existing.Artifacts...) + for _, candidate := range refreshed.Artifacts { + candidateIdentity := artifactSelectorIdentity(candidate) + found := false + for _, prior := range merged.Artifacts { + if artifactSelectorIdentity(prior) != candidateIdentity { + continue + } + found = true + if prior.Location != candidate.Location || prior.Format != candidate.Format || + prior.Hash != candidate.Hash || !sameLockSize(prior.Size, candidate.Size) || + !reflect.DeepEqual(canonicalHostRequirements(prior.HostRequirements), canonicalHostRequirements(candidate.HostRequirements)) { + return lockInfo{}, fmt.Errorf("metadata refresh contradicts locked artifact %q", candidateIdentity) + } + break + } + if !found { + merged.Artifacts = append(merged.Artifacts, candidate) + } + } + merged.Evidence = canonicalLockEvidence(refreshed.Evidence) + if existing.Legacy != nil { + proof := *existing.Legacy + merged.Legacy = &proof + } + if err := validateLockInfo(merged); err != nil { + return lockInfo{}, err + } + return merged, nil +} + +func canonicalHostRequirements(requirements lockHostRequirements) lockHostRequirements { + if len(requirements.Libs) == 0 { + requirements.Libs = nil + } else { + requirements.Libs = append([]string(nil), requirements.Libs...) + sort.Strings(requirements.Libs) + } + if len(requirements.Bins) == 0 { + requirements.Bins = nil + } else { + requirements.Bins = append([]lockNamedRequirement(nil), requirements.Bins...) + sort.Slice(requirements.Bins, func(i, j int) bool { + if requirements.Bins[i].Name != requirements.Bins[j].Name { + return requirements.Bins[i].Name < requirements.Bins[j].Name + } + return requirements.Bins[i].Min < requirements.Bins[j].Min + }) + } + return requirements +} + +// upgradeLockEntry changes the driver version as an explicit operation. It +// never carries a legacy installed-library proof across versions. +func upgradeLockEntry(existing lockInfo, upgraded lockInfo) (lockInfo, error) { + if existing.Name != upgraded.Name || existing.Version == nil || upgraded.Version == nil { + return lockInfo{}, errors.New("version upgrade must keep the driver identity") + } + if sourceresolution.SameReleaseVersion(sourceidentity.Kind(existing.Source.Type), existing.Version, upgraded.Version) { + return lockInfo{}, errors.New("version upgrade requires a different version") + } + if upgraded.Legacy != nil { + return lockInfo{}, errors.New("version upgrade cannot carry a legacy library proof") + } + if err := validateLockInfo(upgraded); err != nil { + return lockInfo{}, err + } + return upgraded, nil +} + +// selectLockedArtifact performs pure target selection from lock data. It does +// not consult a registry, a release API, or persistent trust state. +func selectLockedArtifact(entry lockInfo, platform string, locked bool) (lockArtifact, error) { + target, targetErr := resolution.TargetFromPlatformTuple(platform) + var matches []lockArtifact + if targetErr == nil { + for _, artifact := range entry.Artifacts { + if artifact.Target == target { + matches = append(matches, artifact) + } + } + } + if len(matches) > 1 { + return lockArtifact{}, fmt.Errorf("%w for %s on %s", ErrArtifactAmbiguous, entry.Name, platform) + } + if len(matches) == 1 { + return matches[0], nil + } + if locked { + return lockArtifact{}, &LockedModeArtifactMissingError{DriverID: entry.Name, Platform: platform} + } + return lockArtifact{}, &LockRefreshRequiredError{DriverID: entry.Name, Platform: platform} +} + +func writeLockFileAtomic(path string, lock LockFile) error { + if lock.Version != 0 && lock.Version != lockFileVersion { + return fmt.Errorf("cannot write lock file version %d", lock.Version) + } + canonical := canonicalLockFile(lock) + canonical.Version = lockFileVersion + if err := validateLockFileV2(canonical); err != nil { + return fmt.Errorf("cannot write invalid lock file: %w", err) + } + var buf bytes.Buffer + if err := toml.NewEncoder(&buf).Encode(lockFileV2(canonical)); err != nil { + return fmt.Errorf("failed to encode lock file: %w", err) + } + if err := atomicfile.WriteFile(path, buf.Bytes(), 0o644); err != nil { + return fmt.Errorf("failed to atomically write lock file %s: %w", path, err) + } + return nil +} + +func canonicalLockFile(lock LockFile) LockFile { + result := lock + result.Version = lockFileVersion + result.Drivers = append([]lockInfo(nil), lock.Drivers...) + result.lockinfo = nil + sort.Slice(result.Drivers, func(i, j int) bool { return result.Drivers[i].Name < result.Drivers[j].Name }) + for i := range result.Drivers { + result.Drivers[i].Evidence = canonicalLockEvidence(result.Drivers[i].Evidence) + result.Drivers[i].Artifacts = append([]lockArtifact(nil), result.Drivers[i].Artifacts...) + sort.Slice(result.Drivers[i].Artifacts, func(a, b int) bool { + return artifactSelectorIdentity(result.Drivers[i].Artifacts[a]) < artifactSelectorIdentity(result.Drivers[i].Artifacts[b]) + }) + for j := range result.Drivers[i].Artifacts { + result.Drivers[i].Artifacts[j].HostRequirements.Libs = append([]string(nil), result.Drivers[i].Artifacts[j].HostRequirements.Libs...) + sort.Strings(result.Drivers[i].Artifacts[j].HostRequirements.Libs) + result.Drivers[i].Artifacts[j].HostRequirements.Bins = append([]lockNamedRequirement(nil), result.Drivers[i].Artifacts[j].HostRequirements.Bins...) + sort.Slice(result.Drivers[i].Artifacts[j].HostRequirements.Bins, func(x, y int) bool { + left, right := result.Drivers[i].Artifacts[j].HostRequirements.Bins[x], result.Drivers[i].Artifacts[j].HostRequirements.Bins[y] + if left.Name != right.Name { + return left.Name < right.Name + } + return left.Min < right.Min + }) + } + } + return result +} + +func canonicalLockEvidence(evidence []lockEvidence) []lockEvidence { + result := append([]lockEvidence(nil), evidence...) + sort.Slice(result, func(i, j int) bool { + left, right := result[i], result[j] + if left.Kind != right.Kind { + return left.Kind < right.Kind + } + if left.Location.Kind != right.Location.Kind { + return left.Location.Kind < right.Location.Kind + } + if left.Location.Value != right.Location.Value { + return left.Location.Value < right.Location.Value + } + return left.Hash < right.Hash + }) + return result +} diff --git a/cmd/dbc/lockfile_v2_test.go b/cmd/dbc/lockfile_v2_test.go new file mode 100644 index 00000000..58e85133 --- /dev/null +++ b/cmd/dbc/lockfile_v2_test.go @@ -0,0 +1,931 @@ +// 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 main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc" + "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/resolution" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testLockArtifact(platform, digest string, size int64) lockArtifact { + return lockArtifact{ + Target: testTarget(platform), + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/" + platform + ".tar.gz"}, + Hash: "sha256:" + strings.Repeat(digest, 64), + Size: &size, + Format: "tar.gz", + } +} + +func testTarget(platform string) resolution.Target { + target, err := resolution.TargetFromPlatformTuple(platform) + if err != nil { + panic(err) + } + return target +} + +func testLockEntry() lockInfo { + version := semver.MustParse("1.2.3") + return lockInfo{ + Name: "example", + Version: version, + Source: lockSource{Type: "packslip", Project: "github.com/example/driver"}, + Evidence: []lockEvidence{{ + Kind: resolution.EvidenceKindReleaseMetadata, + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/release.packslip"}, + Hash: "sha256:" + strings.Repeat("e", 64), + }}, + Artifacts: []lockArtifact{ + { + Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu", Variant: "v1"}, Format: "tar.gz", + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/linux.tar.gz"}, Hash: "sha256:" + strings.Repeat("a", 64), Size: int64Pointer(128), + HostRequirements: lockHostRequirements{ + OSMin: "3.2.0", GLibCMin: "2.17", Libs: []string{"libssl.so.3", "libc.so.6"}, + Bins: []lockNamedRequirement{{Name: "git", Min: "2.40"}}, + }, + }, + { + Target: resolution.Target{OS: "macos", Arch: "arm64"}, Format: "tar.gz", + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/macos.tar.gz"}, Hash: "sha256:" + strings.Repeat("b", 64), Size: int64Pointer(256), + }, + }, + } +} + +func int64Pointer(value int64) *int64 { return &value } + +func TestLockFileV2DeterministicRoundTripPreservesArtifactMetadata(t *testing.T) { + dir := t.TempDir() + entry := testLockEntry() + entry.Artifacts[0].Location.Value = "HTTPS://Example.test/a%2Fb?token=x%2Fy" + entry.Evidence = []lockEvidence{ + {Kind: resolution.EvidenceKindReleaseMetadata, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/z-metadata"}, Hash: "sha256:" + strings.Repeat("a", 64)}, + {Kind: resolution.EvidenceKindReleaseIndex, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/a-index"}, Hash: "sha256:" + strings.Repeat("b", 64)}, + {Kind: resolution.EvidenceKindReleaseIndex, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: "./b-index"}, Hash: "sha256:" + strings.Repeat("c", 64)}, + } + originalEvidence := append([]lockEvidence(nil), entry.Evidence...) + lock := LockFile{Version: lockFileVersion, Revision: 0, Drivers: []lockInfo{entry, { + Name: "local", Version: semver.MustParse("0.1.0"), + Source: lockSource{Type: "path", Path: "./packages/local.tar.gz"}, + Artifacts: []lockArtifact{{ + Target: resolution.Target{OS: "windows", Arch: "amd64"}, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: "./packages/local.tar.gz"}, Hash: "sha256:" + strings.Repeat("c", 64), Size: int64Pointer(0), Format: "tar.gz", + }, { + Target: resolution.Target{OS: "macos", Arch: "arm64"}, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: "/var/cache/local.tar.gz"}, Hash: "sha256:" + strings.Repeat("d", 64), Size: int64Pointer(1), Format: "tar.gz", + }}, + }}} + firstPath := filepath.Join(dir, "first.lock") + require.NoError(t, writeLockFileAtomic(firstPath, lock)) + first, err := os.ReadFile(firstPath) + require.NoError(t, err) + assert.Equal(t, originalEvidence, lock.Drivers[0].Evidence, "canonical serialization must not reorder caller evidence") + + lock.Drivers[0].Artifacts[0], lock.Drivers[0].Artifacts[1] = lock.Drivers[0].Artifacts[1], lock.Drivers[0].Artifacts[0] + for i, j := 0, len(lock.Drivers[0].Evidence)-1; i < j; i, j = i+1, j-1 { + lock.Drivers[0].Evidence[i], lock.Drivers[0].Evidence[j] = lock.Drivers[0].Evidence[j], lock.Drivers[0].Evidence[i] + } + reversedEvidence := append([]lockEvidence(nil), lock.Drivers[0].Evidence...) + secondPath := filepath.Join(dir, "second.lock") + require.NoError(t, writeLockFileAtomic(secondPath, lock)) + second, err := os.ReadFile(secondPath) + require.NoError(t, err) + assert.Equal(t, reversedEvidence, lock.Drivers[0].Evidence, "canonical serialization must preserve caller evidence order") + assert.Equal(t, string(first), string(second), "serialization should be canonical regardless of input order") + + loaded, err := loadLockFile(firstPath) + require.NoError(t, err) + assert.Equal(t, lockFileVersion, loaded.Version) + assert.Len(t, loaded.Drivers, 2) + linux := loaded.lockinfo["example"].Artifacts[0] + assert.Equal(t, "gnu", linux.Target.LibC) + assert.Equal(t, "v1", linux.Target.Variant) + assert.Equal(t, "HTTPS://Example.test/a%2Fb?token=x%2Fy", linux.Location.Value, + "URL case, percent encoding, query, and spelling are preserved") + assert.Equal(t, []string{"libc.so.6", "libssl.so.3"}, linux.HostRequirements.Libs) + assert.Equal(t, "2.17", linux.HostRequirements.GLibCMin) + assert.Equal(t, []lockNamedRequirement{{Name: "git", Min: "2.40"}}, linux.HostRequirements.Bins) + assert.Equal(t, []lockEvidence{ + {Kind: resolution.EvidenceKindReleaseIndex, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: "./b-index"}, Hash: "sha256:" + strings.Repeat("c", 64)}, + {Kind: resolution.EvidenceKindReleaseIndex, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/a-index"}, Hash: "sha256:" + strings.Repeat("b", 64)}, + {Kind: resolution.EvidenceKindReleaseMetadata, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/z-metadata"}, Hash: "sha256:" + strings.Repeat("a", 64)}, + }, loaded.lockinfo["example"].Evidence) + locations := []resolution.ArtifactLocation{ + loaded.lockinfo["local"].Artifacts[0].Location, + loaded.lockinfo["local"].Artifacts[1].Location, + } + assert.Contains(t, locations, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: "./packages/local.tar.gz"}) + assert.Contains(t, locations, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: "/var/cache/local.tar.gz"}) + assert.NotContains(t, string(first), "platform =") + assert.Contains(t, string(first), "[drivers.artifacts.target]") + var zeroSizePreserved bool + for _, artifact := range loaded.lockinfo["local"].Artifacts { + if artifact.Target == (resolution.Target{OS: "windows", Arch: "amd64"}) { + zeroSizePreserved = artifact.Size != nil && *artifact.Size == 0 + } + } + assert.True(t, zeroSizePreserved, "an explicit zero size is distinct from an omitted size") + localRelease := loaded.lockinfo["local"].resolvedRelease() + localRoundTrip, err := lockInfoFromResolvedRelease("local", localRelease) + require.NoError(t, err) + locations = []resolution.ArtifactLocation{localRoundTrip.Artifacts[0].Location, localRoundTrip.Artifacts[1].Location} + assert.Contains(t, locations, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: "./packages/local.tar.gz"}) + assert.Contains(t, locations, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: "/var/cache/local.tar.gz"}) +} + +func TestLockArtifactRejectsInvalidLocations(t *testing.T) { + tests := []struct { + name string + location resolution.ArtifactLocation + }{ + {name: "empty kind", location: resolution.ArtifactLocation{Value: "https://example.test/archive"}}, + {name: "unknown kind", location: resolution.ArtifactLocation{Kind: "other", Value: "value"}}, + {name: "empty URL", location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL}}, + {name: "relative URL", location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "archive.tar.gz"}}, + {name: "file URL", location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "file:///tmp/archive.tar.gz"}}, + {name: "opaque URL", location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https:archive.tar.gz"}}, + {name: "userinfo URL", location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://user@example.test/archive"}}, + {name: "fragment URL", location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/archive#part"}}, + {name: "empty fragment URL", location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/archive#"}}, + {name: "empty path", location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath}}, + {name: "NUL path", location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: "archive\x00.tar.gz"}}, + {name: "URI path", location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: "https://example.test/archive"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + artifact := testLockArtifact("linux_amd64", "a", 10) + artifact.Location = test.location + assert.Error(t, validateLockArtifacts([]lockArtifact{artifact})) + }) + } +} + +func TestLockFileV2AllowsPartialArtifactSetAndReplayNeedsNoDiscovery(t *testing.T) { + entry := testLockEntry() + entry.Artifacts = entry.Artifacts[:1] + artifact, err := selectLockedArtifact(entry, "linux_amd64_v1", false) + require.NoError(t, err) + assert.Equal(t, entry.Artifacts[0].Hash, artifact.Hash) + assert.Equal(t, entry.Artifacts[0].HostRequirements, artifact.HostRequirements) + _, err = selectLockedArtifact(entry, "linux_amd64", false) + assert.ErrorIs(t, err, ErrLockRefreshRequired, "lock replay requires an exact target including variant") + + _, err = selectLockedArtifact(entry, "macos_arm64", false) + var refreshErr *LockRefreshRequiredError + assert.ErrorAs(t, err, &refreshErr) + assert.ErrorIs(t, err, ErrLockRefreshRequired) + assert.NotErrorIs(t, err, ErrLockedModeArtifactMissing) + + _, err = selectLockedArtifact(entry, "macos_arm64", true) + assert.ErrorIs(t, err, ErrLockedArtifactMissing) + assert.ErrorIs(t, err, ErrLockedModeArtifactMissing) + assert.NotErrorIs(t, err, ErrLockRefreshRequired) + assert.Contains(t, err.Error(), "locked mode") +} + +func TestLockFileV2RejectsUnknownVersion(t *testing.T) { + path := filepath.Join(t.TempDir(), "dbc.lock") + require.NoError(t, os.WriteFile(path, []byte("version = 99\n"), 0o600)) + _, err := loadLockFile(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported lock file version 99") +} + +func TestLockFileV2UsesTypedSourceFieldsAndRequiresEvidencePairs(t *testing.T) { + path := filepath.Join(t.TempDir(), "dbc.lock") + require.NoError(t, writeLockFileAtomic(path, LockFile{Version: 2, Drivers: []lockInfo{testLockEntry()}})) + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(data), "project = 'github.com/example/driver'") + assert.NotContains(t, string(data), "reference =") + assert.Contains(t, string(data), "revision = 0") + assert.Contains(t, string(data), "[[drivers.evidence]]") + assert.Contains(t, string(data), "[drivers.evidence.location]") + assert.Contains(t, string(data), "kind = 'release-metadata'") + assert.NotContains(t, string(data), "bundle_url =") + + entry := testLockEntry() + entry.Evidence[0].Hash = "" + err = validateLockInfo(entry) + require.Error(t, err) + assert.Contains(t, err.Error(), "no hash") +} + +func TestLockFileV1MigrationPreservesLibraryProofAndRequiresSamePlatformVerification(t *testing.T) { + path := filepath.Join(t.TempDir(), "dbc.lock") + legacyHash := strings.Repeat("d", 64) + body := "version = 1\n\n[[drivers]]\nname = \"example\"\nversion = \"1.2.3\"\nplatform = \"darwin_aarch64\"\nchecksum = \"" + legacyHash + "\"\n" + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) + old, err := loadLockFile(path) + require.NoError(t, err) + require.Equal(t, lockFileVersionV1, old.Version) + require.NotNil(t, old.lockinfo["example"].Legacy) + + release := testResolvedRelease() + _, err = migrateV1Entry(old.lockinfo["example"], release, "macos_arm64", nil) + require.Error(t, err, "same-platform migration must require installed-library proof") + + verified := &VerifiedLegacyLibrary{Platform: "macos_arm64", LibraryHash: legacyHash} + migrated, err := migrateV1Entry(old.lockinfo["example"], release, "macos_arm64", verified) + require.NoError(t, err) + require.NotNil(t, migrated.Legacy) + assert.Equal(t, legacyHash, migrated.Legacy.LibraryHash) + assert.Equal(t, "darwin_aarch64", migrated.Legacy.Platform, "the original v1 tuple remains intact") + assert.NotEqual(t, "sha256:"+legacyHash, migrated.Artifacts[0].Hash, "v1 library checksum must not become an archive hash") + assert.NoError(t, verifyLegacyLibraryProof(migrated, "macos_arm64", legacyHash)) + assert.Error(t, verifyLegacyLibraryProof(migrated, "macos_arm64", strings.Repeat("0", 64))) + assert.Equal(t, legacyHash, migrated.legacyChecksumFor("macos_arm64"), "legacy tuple aliases compare canonically") + v2Path := filepath.Join(t.TempDir(), "migrated.lock") + require.NoError(t, writeLockFileAtomic(v2Path, LockFile{Version: 2, Drivers: []lockInfo{migrated}})) + reloaded, err := loadLockFile(v2Path) + require.NoError(t, err) + require.NotNil(t, reloaded.lockinfo["example"].Legacy) + assert.Equal(t, legacyHash, reloaded.lockinfo["example"].Legacy.LibraryHash) + + otherPlatform, err := migrateV1Entry(old.lockinfo["example"], release, "linux_amd64", nil) + require.NoError(t, err, "migration on another platform may retain but not apply the old proof") + require.NotNil(t, otherPlatform.Legacy) + assert.Equal(t, "darwin_aarch64", otherPlatform.Legacy.Platform) +} + +func TestAtomicLockWriterKeepsOldFileWhenReplacementFails(t *testing.T) { + path := filepath.Join(t.TempDir(), "dbc.lock") + old := []byte("old lock contents\n") + require.NoError(t, os.WriteFile(path, old, 0o600)) + err := writeLockFileAtomic(path, LockFile{Version: 2, Drivers: []lockInfo{{Name: "missing source"}}}) + require.Error(t, err) + actual, readErr := os.ReadFile(path) + require.NoError(t, readErr) + assert.Equal(t, old, actual) +} + +func TestRefreshRejectsArtifactContradictionAndAllowsNewTarget(t *testing.T) { + existing := testLockEntry() + refreshed := existing + newArtifact := testLockArtifact("windows_amd64", "c", 22) + contradictory := existing.Artifacts[0] + contradictory.Hash = "sha256:" + strings.Repeat("f", 64) + contradictory.Size = int64Pointer(999) + refreshed.Artifacts = []lockArtifact{contradictory, newArtifact} + _, err := refreshLockEntry(existing, refreshed) + require.Error(t, err) + assert.Contains(t, err.Error(), "contradicts") + + refreshed.Artifacts = []lockArtifact{existing.Artifacts[0], newArtifact} + merged, err := refreshLockEntry(existing, refreshed) + require.NoError(t, err) + assert.Len(t, merged.Artifacts, 3) +} + +func TestRefreshReplacesEvidenceWithLatestCanonicalSnapshot(t *testing.T) { + oldIndex := lockEvidence{ + Kind: resolution.EvidenceKindReleaseIndex, + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/index"}, + Hash: "sha256:" + strings.Repeat("a", 64), + } + oldMetadata := lockEvidence{ + Kind: resolution.EvidenceKindReleaseMetadata, + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/old"}, + Hash: "sha256:" + strings.Repeat("b", 64), + } + existing := testLockEntry() + existing.Evidence = []lockEvidence{oldMetadata, oldIndex} + + newIndex := oldIndex + newIndex.Hash = "sha256:" + strings.Repeat("c", 64) + newMetadata := lockEvidence{ + Kind: resolution.EvidenceKindReleaseMetadata, + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/new"}, + Hash: "sha256:" + strings.Repeat("d", 64), + } + refreshed := testLockEntry() + refreshed.Evidence = []lockEvidence{newMetadata, newIndex} + want := canonicalLockEvidence(refreshed.Evidence) + + merged, err := refreshLockEntry(existing, refreshed) + require.NoError(t, err, "an index's observed hash may change across refresh snapshots") + assert.Equal(t, want, merged.Evidence) + assert.NotContains(t, merged.Evidence, oldMetadata, "refresh replaces stale evidence entries") + + refreshed.Evidence[0].Hash = "sha256:" + strings.Repeat("f", 64) + assert.Equal(t, want, merged.Evidence, "the merged snapshot must not alias the refreshed evidence slice") + + refreshed.Evidence = []lockEvidence{newMetadata} + latestOnly, err := refreshLockEntry(existing, refreshed) + require.NoError(t, err) + assert.Equal(t, []lockEvidence{newMetadata}, latestOnly.Evidence, + "refresh replaces the full evidence set rather than retaining omitted records") +} + +func TestRefreshCannotReplaceSameTargetArtifactMetadata(t *testing.T) { + tests := []struct { + name string + mutate func(*lockArtifact) + }{ + {name: "location", mutate: func(a *lockArtifact) { a.Location.Value = "https://other.example.test/archive.tar.gz" }}, + {name: "format", mutate: func(a *lockArtifact) { a.Format = "tgz" }}, + {name: "requirements", mutate: func(a *lockArtifact) { a.HostRequirements.OSMin = "99" }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + existing := testLockEntry() + refreshed := testLockEntry() + test.mutate(&refreshed.Artifacts[0]) + _, err := refreshLockEntry(existing, refreshed) + assert.ErrorContains(t, err, "contradicts locked artifact") + }) + } +} + +func TestRefreshAcceptsEquivalentHostRequirementsAfterLockRoundTrip(t *testing.T) { + dir := t.TempDir() + original := testLockEntry() + original.Artifacts[0].HostRequirements.Bins = append(original.Artifacts[0].HostRequirements.Bins, + lockNamedRequirement{Name: "java", Min: "17"}) + path := filepath.Join(dir, "dbc.lock") + require.NoError(t, writeLockFileAtomic(path, LockFile{Version: lockFileVersion, Drivers: []lockInfo{original}})) + loaded, err := loadLockFile(path) + require.NoError(t, err) + _, err = refreshLockEntry(loaded.lockinfo["example"], original) + require.NoError(t, err, "persisted sorted requirements must match the same unsorted source metadata") + + withoutRequirements := testLockEntry() + withoutRequirements.Artifacts[0].HostRequirements = lockHostRequirements{} + require.NoError(t, writeLockFileAtomic(path, LockFile{Version: lockFileVersion, Drivers: []lockInfo{withoutRequirements}})) + loaded, err = loadLockFile(path) + require.NoError(t, err) + withoutRequirements.Artifacts[0].HostRequirements.Libs = []string{} + withoutRequirements.Artifacts[0].HostRequirements.Bins = []lockNamedRequirement{} + _, err = refreshLockEntry(loaded.lockinfo["example"], withoutRequirements) + require.NoError(t, err, "empty requirement slices are equivalent to omitted slices") +} + +func TestLockArtifactsAllowSharedLocationOnlyWithConsistentMetadata(t *testing.T) { + first := testLockArtifact("linux_amd64", "a", 10) + second := testLockArtifact("macos_arm64", "a", 10) + second.Location = first.Location + assert.NoError(t, validateLockArtifacts([]lockArtifact{first, second})) + second.Size = nil + assert.NoError(t, validateLockArtifacts([]lockArtifact{first, second}), "an omitted size is compatible with a known size") + second.Size = int64Pointer(11) + assert.ErrorContains(t, validateLockArtifacts([]lockArtifact{first, second}), "conflicting hash or size") + second.Size = int64Pointer(10) + second.Hash = "sha256:" + strings.Repeat("b", 64) + assert.ErrorContains(t, validateLockArtifacts([]lockArtifact{first, second}), "conflicting hash or size") + second = testLockArtifact("macos_arm64", "b", 11) + second.Location = resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: first.Location.Value} + assert.NotEqual(t, first.Location, second.Location, "same value with distinct kinds has a distinct identity") +} + +func TestLockHashOnlyArtifactRoundTrips(t *testing.T) { + entry := testLockEntry() + entry.Artifacts[0].Size = nil + require.NoError(t, validateLockInfo(entry)) + path := filepath.Join(t.TempDir(), "dbc.lock") + require.NoError(t, writeLockFileAtomic(path, LockFile{Version: lockFileVersion, Drivers: []lockInfo{entry}})) + reloaded, err := loadLockFile(path) + require.NoError(t, err) + require.Len(t, reloaded.lockinfo[entry.Name].Artifacts, len(entry.Artifacts)) + assert.Nil(t, reloaded.lockinfo[entry.Name].Artifacts[0].Size) +} + +func TestLockRoundTripPreservesOneArtifactLocationSharedBySeveralTargets(t *testing.T) { + release := testResolvedRelease() + shared := release.Artifacts[0] + shared.Target = resolution.Target{OS: "macos", Arch: "arm64"} + release.Artifacts = append(release.Artifacts, shared) + + entry, err := lockInfoFromResolvedRelease("example", release) + require.NoError(t, err) + path := filepath.Join(t.TempDir(), "dbc.lock") + require.NoError(t, writeLockFileAtomic(path, LockFile{Version: lockFileVersion, Drivers: []lockInfo{entry}})) + loaded, err := loadLockFile(path) + require.NoError(t, err) + assert.Equal(t, release, loaded.lockinfo["example"].resolvedRelease()) +} + +func TestVersionUpgradeIsSeparateFromMetadataRefresh(t *testing.T) { + old := testLockEntry() + old.Legacy = &legacyLibraryProof{Platform: "macos_arm64", LibraryHash: strings.Repeat("d", 64)} + newVersion := testLockEntry() + newVersion.Version = semver.MustParse("1.3.0") + + upgraded, err := upgradeLockEntry(old, newVersion) + require.NoError(t, err) + assert.Nil(t, upgraded.Legacy, "installed-library proofs must not cross release versions") + + _, err = upgradeLockEntry(old, old) + require.Error(t, err) + newVersion.Legacy = old.Legacy + _, err = upgradeLockEntry(old, newVersion) + require.Error(t, err) +} + +func TestRefreshAndUpgradeUseSourceAwareReleaseVersionIdentity(t *testing.T) { + for _, source := range []lockSource{ + {Type: "packslip", Project: "github.com/example/driver"}, + {Type: "path", Path: "./driver.tgz"}, + } { + t.Run(source.Type, func(t *testing.T) { + existing := testLockEntry() + existing.Source = source + existing.Version = semver.MustParse("1.2.3+foo") + refreshed := cloneLockInfo(existing) + refreshed.Version = semver.MustParse("1.2.3+bar") + + _, err := refreshLockEntry(existing, refreshed) + assert.ErrorContains(t, err, "must keep the locked driver version") + + upgraded, err := upgradeLockEntry(existing, refreshed) + require.NoError(t, err) + assert.Equal(t, "1.2.3+bar", upgraded.Version.String(), "different exact source release is a valid upgrade") + }) + } + + t.Run("registry retains precedence equality", func(t *testing.T) { + existing := testLockEntry() + existing.Source = lockSource{Type: "registry", URL: "https://registry.example.test"} + existing.Version = semver.MustParse("1.2.3+foo") + refreshed := cloneLockInfo(existing) + refreshed.Version = semver.MustParse("1.2.3+bar") + + _, err := refreshLockEntry(existing, refreshed) + assert.NoError(t, err, "registry metadata refresh keeps SemVer precedence equality") + _, err = upgradeLockEntry(existing, refreshed) + assert.ErrorContains(t, err, "requires a different version") + }) +} + +func TestSyncAdapterReusesCompleteV2SnapshotWithoutRegistryHashes(t *testing.T) { + platform := config.PlatformTuple() + registryURL, err := url.Parse("https://registry.example.test") + require.NoError(t, err) + entry := lockInfo{ + Name: "example", + Version: semver.MustParse("1.2.3"), + Source: lockSource{Type: "registry", URL: registryURL.String()}, + Artifacts: []lockArtifact{{ + Target: testTarget(platform), + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://assets.example.test/archive.tar.gz"}, Hash: "sha256:" + strings.Repeat("a", 64), Size: int64Pointer(10), Format: "tar.gz", + }}, + } + item, err := installItemFromLockedArtifact("example", entry, entry.Artifacts[0]) + require.NoError(t, err) + assert.Equal(t, registryURL.String(), item.Release.Source.Reference) + selected, err := item.selectedArtifact() + require.NoError(t, err) + assert.Equal(t, "https://assets.example.test/archive.tar.gz", selected.Location.Value, "artifact host is independent of source identity") + assert.Equal(t, entry.Artifacts[0].Hash, selected.Hash) + assert.Equal(t, *entry.Artifacts[0].Size, *selected.Size) + assert.True(t, canReuseLockedEntry(item)) + selected.Format = "tgz" + assert.False(t, canReuseLockedEntry(item), "a locked artifact with a different format is not the selected artifact proof") + selected.Format = entry.Artifacts[0].Format + selected.HostRequirements.Libs = []string{"libc.so.6"} + assert.False(t, canReuseLockedEntry(item), "a locked artifact with different host requirements is not the selected artifact proof") + selected.HostRequirements.Libs = nil + item.InstalledLibraryHash = strings.Repeat("f", 64) + + updated, err := lockEntryForItem(item) + require.NoError(t, err) + assert.Equal(t, entry, updated, "the existing archive snapshot must not require optional registry hash/size") + assert.Empty(t, updated.legacyChecksumFor(platform), "archive hash is not an installed-library checksum") + + path := filepath.Join(t.TempDir(), "dbc.lock") + require.NoError(t, writeLockFileAtomic(path, LockFile{Version: 2, Drivers: []lockInfo{updated}})) + reloaded, err := loadLockFile(path) + require.NoError(t, err) + assert.Equal(t, entry.Artifacts[0].Hash, reloaded.lockinfo["example"].Artifacts[0].Hash, + "post-install lock rewrite must keep the archive snapshot rather than the installed-library hash") +} + +func TestSyncAdapterReplaysAndReusesHashOnlyRegistryLock(t *testing.T) { + platform := config.PlatformTuple() + entry := lockInfo{ + Name: "example", Version: semver.MustParse("1.2.3"), + Source: lockSource{Type: "registry", URL: "https://registry.example.test"}, + Artifacts: []lockArtifact{{ + Target: testTarget(platform), Format: "tar.gz", + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://assets.example.test/archive.tar.gz"}, + Hash: "sha256:" + strings.Repeat("a", 64), + }}, + } + item, err := installItemFromLockedArtifact("example", entry, entry.Artifacts[0]) + require.NoError(t, err) + assert.True(t, canReuseLockedEntry(item)) + expected, hasMetadata, err := expectedSyncPackageMetadata(item) + require.NoError(t, err) + assert.True(t, hasMetadata) + assert.Equal(t, entry.Artifacts[0].Hash, expected.ArchiveHash) + assert.False(t, expected.ArchiveSizePresent) + + updated, err := lockEntryForItem(item) + require.NoError(t, err) + assert.Nil(t, updated.Artifacts[0].Size, "replay does not invent a registry size") +} + +func TestSyncAdapterPreservesLockedURLSpellingWhenReusingSnapshot(t *testing.T) { + platform := config.PlatformTuple() + const rawURL = "HTTPS://Example.test/a%2Fb?token=x%2Fy" + registryURL, err := url.Parse("https://registry.example.test") + require.NoError(t, err) + entry := lockInfo{ + Name: "example", + Version: semver.MustParse("1.2.3"), + Source: lockSource{Type: "registry", URL: registryURL.String()}, + Artifacts: []lockArtifact{{ + Target: testTarget(platform), + Location: resolution.ArtifactLocation{ + Kind: resolution.ArtifactLocationURL, Value: rawURL, + }, + Hash: "sha256:" + strings.Repeat("a", 64), Size: int64Pointer(10), Format: "tar.gz", + }}, + } + item, err := installItemFromLockedArtifact("example", entry, entry.Artifacts[0]) + require.NoError(t, err) + + updated, err := lockEntryForItem(item) + require.NoError(t, err) + require.Len(t, updated.Artifacts, 1) + assert.Equal(t, rawURL, updated.Artifacts[0].Location.Value, + "reusing a locked artifact must preserve the original URL spelling") +} + +func TestSyncAdapterRejectsFreshEntryWithoutArchiveMetadata(t *testing.T) { + item := mustTestInstallItem(t, resolution.ResolvedRelease{ + DriverID: "example", Version: "1.2.3", Source: resolution.SourceSpec{Type: "registry", Reference: "https://registry.example.test"}, + Artifacts: []resolution.Artifact{{ + Target: testTarget("linux_amd64"), Format: "tar.gz", + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://registry.example.test/archive.tar.gz"}, + }}, + }, "linux_amd64", nil) + _, err := lockEntryForItem(item) + require.Error(t, err) + assert.Contains(t, err.Error(), "has no finalized hash") +} + +func TestSyncAdapterPreservesLockedPathArtifactLocation(t *testing.T) { + entry := testRegistryLockEntryForPlatform(config.PlatformTuple()) + entry.Artifacts[0].Location = resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: "./archive.tar.gz"} + item, err := installItemFromLockedArtifact("example", entry, entry.Artifacts[0]) + require.NoError(t, err) + selected, err := item.selectedArtifact() + require.NoError(t, err) + assert.Equal(t, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: "./archive.tar.gz"}, selected.Location) +} + +func TestSyncAdapterVerifiesLegacyLibraryProofBeforeReusingV2Entry(t *testing.T) { + platform := config.PlatformTuple() + registryURL, err := url.Parse("https://registry.example.test") + require.NoError(t, err) + entry := lockInfo{ + Name: "example", + Version: semver.MustParse("1.2.3"), + Source: lockSource{Type: "registry", URL: registryURL.String()}, + Legacy: &legacyLibraryProof{Platform: platform, LibraryHash: strings.Repeat("d", 64)}, + Artifacts: []lockArtifact{{ + Target: testTarget(platform), + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://registry.example.test/archive.tar.gz"}, Hash: "sha256:" + strings.Repeat("a", 64), Size: int64Pointer(10), + }}, + } + item, err := installItemFromLockedArtifact("example", entry, entry.Artifacts[0]) + require.NoError(t, err) + item.InstalledLibraryHash = strings.Repeat("0", 64) + _, err = lockEntryForItem(item) + require.Error(t, err) + assert.Contains(t, err.Error(), "legacy installed-library checksum mismatch") +} + +func testRegistryLockEntryForPlatform(platform string) lockInfo { + return lockInfo{ + Name: "example", + Version: semver.MustParse("1.2.3"), + Source: lockSource{Type: "registry", URL: "https://registry.example.test"}, + Artifacts: []lockArtifact{{ + Target: testTarget(platform), + Format: "tar.gz", Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://assets.example.test/" + platform + ".tar.gz"}, + Hash: "sha256:" + strings.Repeat("a", 64), Size: int64Pointer(10), + }}, + } +} + +func TestLockReplayRejectsMuslForGenericLinuxTargetAndAmbiguousArtifacts(t *testing.T) { + entry := testLockEntry() + entry.Artifacts = []lockArtifact{{ + Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "musl"}, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/musl.tar.gz"}, + Hash: "sha256:" + strings.Repeat("a", 64), Size: int64Pointer(10), + }} + _, err := selectLockedArtifact(entry, "linux_amd64", false) + assert.Error(t, err, "a musl build must not match generic linux_amd64") + + entry.Artifacts = []lockArtifact{testLockArtifact("linux_amd64", "a", 10), testLockArtifact("linux_amd64", "b", 11)} + _, err = selectLockedArtifact(entry, "linux_amd64", false) + assert.ErrorIs(t, err, ErrArtifactAmbiguous) + + duplicateSelector := testLockEntry().Artifacts[0] + otherFormat := duplicateSelector + otherFormat.Format = "zip" + assert.Error(t, validateLockArtifacts([]lockArtifact{duplicateSelector, otherFormat}), + "same target selector with different format must be rejected as ambiguous") +} + +func TestLockResolvedReleaseRoundTripPreservesEvidenceAndSelectors(t *testing.T) { + release := testResolvedRelease() + entry, err := lockInfoFromResolvedRelease("example", release) + require.NoError(t, err) + got := entry.resolvedRelease() + assert.Equal(t, release, got) + release.Evidence[0].Hash = "sha256:" + strings.Repeat("f", 64) + assert.NotEqual(t, release.Evidence[0].Hash, entry.Evidence[0].Hash, + "lock conversion must copy the evidence slice") + entry.Evidence[0].Hash = "sha256:" + strings.Repeat("0", 64) + assert.NotEqual(t, entry.Evidence[0].Hash, got.Evidence[0].Hash, + "resolved release conversion must copy the evidence slice") + release.Artifacts[0].HostRequirements.Libs[0] = "changed.so" + release.Artifacts[0].HostRequirements.Bins[0].Name = "changed" + assert.Equal(t, []string{"libc.so.6"}, entry.Artifacts[0].HostRequirements.Libs, + "lock conversion must copy host library requirements") + assert.Equal(t, []lockNamedRequirement{{Name: "git", Min: "2.40"}}, entry.Artifacts[0].HostRequirements.Bins, + "lock conversion must copy host binary requirements") + got.Artifacts[0].HostRequirements.Libs[0] = "changed-again.so" + got.Artifacts[0].HostRequirements.Bins[0].Name = "changed-again" + assert.Equal(t, []string{"libc.so.6"}, entry.resolvedRelease().Artifacts[0].HostRequirements.Libs, + "resolved release conversion must copy host library requirements") + assert.Equal(t, []resolution.NamedRequirement{{Name: "git", Min: "2.40"}}, entry.resolvedRelease().Artifacts[0].HostRequirements.Bins, + "resolved release conversion must copy host binary requirements") +} + +func TestResolvedTGZArtifactReplaysThroughPackageValidation(t *testing.T) { + archivePath := filepath.Join("testdata", "test-driver-1.tar.gz") + archive, err := os.Open(archivePath) + require.NoError(t, err) + defer archive.Close() + info, err := archive.Stat() + require.NoError(t, err) + size := info.Size() + hash, err := checksumFile(archive, archive.Name()) + require.NoError(t, err) + require.NoError(t, archive.Close()) + + packageURL, err := url.Parse("https://assets.example.test/test-driver-1.tgz") + require.NoError(t, err) + release := resolution.ResolvedRelease{ + DriverID: "test-driver-1", + Version: "1.0.0", + Source: resolution.SourceSpec{Type: "registry", Reference: testRegistry.BaseURL.String()}, + Artifacts: []resolution.Artifact{{ + Target: testTarget(config.PlatformTuple()), + Format: "tgz", + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: packageURL.String()}, + Hash: "sha256:" + hash, + Size: &size, + }}, + } + entry, err := lockInfoFromResolvedRelease("test-driver-1", release) + require.NoError(t, err) + lockPath := filepath.Join(t.TempDir(), "dbc.lock") + require.NoError(t, writeLockFileAtomic(lockPath, LockFile{Version: lockFileVersion, Drivers: []lockInfo{entry}})) + loaded, err := loadLockFile(lockPath) + require.NoError(t, err) + selected, err := selectLockedArtifact(loaded.lockinfo["test-driver-1"], config.PlatformTuple(), false) + require.NoError(t, err) + assert.Equal(t, "tgz", selected.Format) + item, err := installItemFromLockedArtifact("test-driver-1", loaded.lockinfo["test-driver-1"], selected) + require.NoError(t, err) + selectedArtifact, err := item.selectedArtifact() + require.NoError(t, err) + assert.Equal(t, "tgz", selectedArtifact.Format) + + model := syncModel{baseModel: baseModel{downloadPkg: func(dbc.PkgInfo) (*os.File, error) { + return os.Open(archivePath) + }}, LockFilePath: filepath.Join(t.TempDir(), "dbc.lock"), + cfg: config.Config{Level: config.ConfigEnv, Location: t.TempDir()}} + prepared, err := model.prepareInstallItems(context.Background(), []installItem{item}) + require.NoError(t, err) + defer closePreparedItems(prepared.items) + require.Len(t, prepared.items, 1) + require.NotNil(t, prepared.items[0].Validation) + assert.Equal(t, "tgz", prepared.lock.Drivers[0].Artifacts[0].Format, + "candidate lock must preserve the source-declared format") + assert.Equal(t, entry.Artifacts[0], prepared.lock.Drivers[0].Artifacts[0]) +} + +func TestSyncOpensRelativePathArtifactFromProjectDirectory(t *testing.T) { + projectDir := t.TempDir() + packageDir := filepath.Join(projectDir, "packages") + require.NoError(t, os.MkdirAll(packageDir, 0o700)) + archivePath := filepath.Join(packageDir, "test-driver-1.tar.gz") + archiveBytes, err := os.ReadFile(filepath.Join("testdata", "test-driver-1.tar.gz")) + require.NoError(t, err) + require.NoError(t, os.WriteFile(archivePath, archiveBytes, 0o600)) + digest := sha256.Sum256(archiveBytes) + size := int64(len(archiveBytes)) + declaredPath := "./packages/test-driver-1.tar.gz" + release := resolution.ResolvedRelease{ + DriverID: "test-driver-1", Version: "1.0.0", + Source: resolution.SourceSpec{Type: "path", Reference: declaredPath}, + Artifacts: []resolution.Artifact{{ + Target: testTarget(config.PlatformTuple()), Format: "tar.gz", + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: declaredPath}, + Hash: "sha256:" + hex.EncodeToString(digest[:]), Size: &size, + }}, + } + entry, err := lockInfoFromResolvedRelease(release.DriverID, release) + require.NoError(t, err) + item, err := installItemFromLockedArtifact(release.DriverID, entry, entry.Artifacts[0]) + require.NoError(t, err) + model := syncModel{LockFilePath: filepath.Join(projectDir, "dbc.lock"), + cfg: config.Config{Level: config.ConfigEnv, Location: filepath.Join(projectDir, "install")}} + prepared, err := model.prepareInstallItems(context.Background(), []installItem{item}) + require.NoError(t, err) + defer closePreparedItems(prepared.items) + require.Len(t, prepared.lock.Drivers, 1) + assert.Equal(t, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: declaredPath}, prepared.lock.Drivers[0].Artifacts[0].Location, + "opening a project-relative artifact must not rewrite its lock identity") + assert.Equal(t, entry.Artifacts[0].Hash, prepared.lock.Drivers[0].Artifacts[0].Hash) + assert.Equal(t, entry.Artifacts[0].Size, prepared.lock.Drivers[0].Artifacts[0].Size) +} + +func TestSelectedArtifactMetadataCopiesHostRequirements(t *testing.T) { + requirements := resolution.HostRequirements{ + OSMin: "3.2", + GLibCMin: "2.17", + Libs: []string{"libssl.so.3"}, + Bins: []resolution.NamedRequirement{{Name: "git", Min: "2.40"}}, + } + release := resolution.ResolvedRelease{ + DriverID: "example", Version: "1.2.3", Source: resolution.SourceSpec{Type: "path", Reference: "./example.tgz"}, + Artifacts: []resolution.Artifact{{Target: testTarget(config.PlatformTuple()), Format: "tgz", HostRequirements: requirements}}, + } + item, err := newInstallItem(release, 0, config.PlatformTuple(), nil) + require.NoError(t, err) + requirements.Libs[0] = "changed" + requirements.Bins[0].Name = "changed" + selected, err := item.selectedArtifact() + require.NoError(t, err) + assert.Equal(t, "tgz", selected.Format) + assert.Equal(t, []string{"libssl.so.3"}, selected.HostRequirements.Libs) + assert.Equal(t, []resolution.NamedRequirement{{Name: "git", Min: "2.40"}}, selected.HostRequirements.Bins) +} + +func TestResolvedReleaseHostRequirementsSurviveLockWriteAndReload(t *testing.T) { + release := testResolvedRelease() + release.Artifacts[0].Format = "tgz" + entry, err := lockInfoFromResolvedRelease("example", release) + require.NoError(t, err) + path := filepath.Join(t.TempDir(), "dbc.lock") + require.NoError(t, writeLockFileAtomic(path, LockFile{Version: lockFileVersion, Drivers: []lockInfo{entry}})) + loaded, err := loadLockFile(path) + require.NoError(t, err) + artifact, err := selectLockedArtifact(loaded.lockinfo["example"], "linux_amd64_gnu_v1", false) + require.NoError(t, err) + assert.Equal(t, "tgz", artifact.Format) + assert.Equal(t, lockArtifactFromResolved(release.Artifacts[0]).HostRequirements, artifact.HostRequirements) + assert.Equal(t, release.Artifacts[0].HostRequirements, loaded.lockinfo["example"].resolvedRelease().Artifacts[0].HostRequirements) +} + +func TestInstallableArtifactFormatValidation(t *testing.T) { + for _, format := range []string{"", "tar.gz", "tgz"} { + t.Run("accept "+format, func(t *testing.T) { + assert.NoError(t, validateInstallableArtifactFormat(format)) + }) + } + for _, format := range []string{"zip", "tar.xz", "tar"} { + t.Run("reject "+format, func(t *testing.T) { + err := validateInstallableArtifactFormat(format) + require.Error(t, err) + assert.Contains(t, err.Error(), format) + assert.Contains(t, err.Error(), "supported formats: tar.gz, tgz") + }) + } +} + +func TestUnsupportedHostRequirementsFailClosedBeforePreparationOrEnsure(t *testing.T) { + tests := []struct { + name string + requirements resolution.HostRequirements + want string + }{ + {name: "os minimum", requirements: resolution.HostRequirements{OSMin: "3.2.0"}, want: `os_min="3.2.0"`}, + {name: "glibc minimum", requirements: resolution.HostRequirements{GLibCMin: "2.17"}, want: `glibc_min="2.17"`}, + {name: "libraries", requirements: resolution.HostRequirements{Libs: []string{"libz.so", "liba.so"}}, want: `libs=["liba.so", "libz.so"]`}, + {name: "binaries", requirements: resolution.HostRequirements{Bins: []resolution.NamedRequirement{{Name: "zstd", Min: "1.5"}, {Name: "git"}}}, want: `bins=["git", "zstd" (min "1.5")]`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + t.Setenv("ADBC_DRIVER_PATH", root) + libraryPath := filepath.Join(root, "sentinel.so") + const sentinel = "keep-existing-runtime-bytes" + require.NoError(t, os.WriteFile(libraryPath, []byte(sentinel), 0o600)) + installed := config.DriverInfo{ID: "example", Name: "Existing Example", Version: semver.MustParse("1.2.3"), Source: "dbc"} + installed.Driver.Shared.Set(config.PlatformTuple(), libraryPath) + cfg := config.Config{Level: config.ConfigEnv, Location: root} + require.NoError(t, config.CreateManifest(cfg, installed)) + loaded, err := config.GetDriver(cfg, "example") + require.NoError(t, err) + + item := mustTestInstallItem(t, resolution.ResolvedRelease{ + DriverID: "example", Version: "1.2.3", Source: resolution.SourceSpec{Type: "registry", Reference: "https://registry.example.test"}, + Artifacts: []resolution.Artifact{{Target: testTarget(config.PlatformTuple()), Format: "tgz", + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://assets.example.test/example.tgz"}, HostRequirements: tt.requirements}}, + }, config.PlatformTuple(), nil) + downloadCalls, ensureCalls := 0, 0 + worker := newSyncWorker() + worker.hooks.ensurePackage = func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + ensureCalls++ + return config.EnsurePackageResult{}, nil + } + model := syncModel{ + baseModel: baseModel{downloadPkg: func(dbc.PkgInfo) (*os.File, error) { + downloadCalls++ + return nil, errors.New("unexpected download") + }}, + LockFilePath: filepath.Join(root, "dbc.lock"), + cfg: config.Config{Level: config.ConfigEnv, Location: root, Exists: true, Drivers: map[string]config.DriverInfo{"example": loaded}}, + worker: worker, + } + prepared, err := model.prepareInstallItems(context.Background(), []installItem{item}) + require.ErrorContains(t, err, "unsupported host requirements for example") + assert.Contains(t, err.Error(), tt.want) + assert.Empty(t, prepared.lock.Drivers) + assert.Zero(t, downloadCalls) + assert.Zero(t, ensureCalls) + + after, err := config.GetDriver(cfg, "example") + require.NoError(t, err) + assert.Equal(t, loaded.Name, after.Name) + assert.Equal(t, loaded.Version.String(), after.Version.String()) + assert.Equal(t, libraryPath, after.Driver.Shared.Get(config.PlatformTuple())) + bytes, err := os.ReadFile(libraryPath) + require.NoError(t, err) + assert.Equal(t, sentinel, string(bytes)) + _, err = os.Stat(model.LockFilePath) + assert.ErrorIs(t, err, os.ErrNotExist) + }) + } +} + +func TestRegistryAndPathSourcesDoNotFabricateEvidence(t *testing.T) { + for _, source := range []resolution.SourceSpec{ + {Type: "registry", Reference: "https://registry.example.test"}, + {Type: "path", Reference: "./driver"}, + } { + t.Run(source.Type, func(t *testing.T) { + release := testResolvedRelease() + release.Source = source + release.Evidence = nil + entry, err := lockInfoFromResolvedRelease("example", release) + require.NoError(t, err) + assert.Empty(t, entry.Evidence) + assert.Empty(t, entry.resolvedRelease().Evidence) + }) + } +} + +func testResolvedRelease() resolution.ResolvedRelease { + size := int64(10) + return resolution.ResolvedRelease{ + DriverID: "example", + Version: "1.2.3", + Source: resolution.SourceSpec{Type: "packslip", Reference: "github.com/example/driver"}, + Evidence: []resolution.Evidence{{ + Kind: resolution.EvidenceKindReleaseMetadata, + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/bundle"}, + Hash: "sha256:" + strings.Repeat("e", 64), + }}, + Artifacts: []resolution.Artifact{{ + Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu", Variant: "v1"}, Format: "tar.gz", + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/linux.tar.gz"}, Hash: "sha256:" + strings.Repeat("a", 64), Size: &size, + HostRequirements: resolution.HostRequirements{ + GLibCMin: "2.17", Libs: []string{"libc.so.6"}, + Bins: []resolution.NamedRequirement{{Name: "git", Min: "2.40"}}, + }, + }}, + } +} diff --git a/cmd/dbc/main.go b/cmd/dbc/main.go index d9409162..0456d716 100644 --- a/cmd/dbc/main.go +++ b/cmd/dbc/main.go @@ -18,7 +18,9 @@ import ( "context" "errors" "fmt" + "io" "net/http" + "net/url" "os" "slices" "strings" @@ -32,6 +34,7 @@ import ( "github.com/columnar-tech/dbc/cmd/dbc/completions" "github.com/columnar-tech/dbc/config" "github.com/columnar-tech/dbc/internal" + "github.com/columnar-tech/dbc/internal/packslip" "github.com/mattn/go-isatty" ) @@ -76,6 +79,19 @@ type NeedsRenderer interface { NeedsRenderer() } +func filterProgramMessage(model tea.Model, msg tea.Msg) tea.Msg { + if filtered, ok := model.(interface{ FilterProgramMessage(tea.Msg) tea.Msg }); ok { + return filtered.FilterProgramMessage(msg) + } + return msg +} + +func notifyProgramExited(model tea.Model) { + if lifecycle, ok := model.(interface{ ProgramExited() }); ok { + lifecycle.ProgramExited() + } +} + var ( dbcClient *dbc.Client dbcClientErr error @@ -167,7 +183,6 @@ var getDriverRegistry = func() ([]dbc.Driver, error) { return dbcClient.Search(context.Background(), "") } - func findDriver(name string, drivers []dbc.Driver) (dbc.Driver, error) { idx := slices.IndexFunc(drivers, func(d dbc.Driver) bool { return d.Path == name @@ -190,6 +205,13 @@ func downloadPkg(p dbc.PkgInfo) (*os.File, error) { }) } +func downloadPackage(ctx context.Context, pkg dbc.PkgInfo) (io.ReadCloser, error) { + if err := initDBCClient(); err != nil { + return nil, fmt.Errorf("failed to initialize authenticated download client: %w", err) + } + return dbcClient.Download(ctx, pkg) +} + func getConfig(c config.ConfigLevel) config.Config { switch c { case config.ConfigSystem, config.ConfigUser: @@ -204,8 +226,11 @@ func getConfig(c config.ConfigLevel) config.Config { } type baseModel struct { - getDriverRegistry func() ([]dbc.Driver, error) - downloadPkg func(p dbc.PkgInfo) (*os.File, error) + getDriverRegistry func() ([]dbc.Driver, error) + downloadPkg func(p dbc.PkgInfo) (*os.File, error) + downloadArtifact func(context.Context, dbc.PkgInfo) (io.ReadCloser, error) + fetchPackslipArtifact func(context.Context, *url.URL) (io.ReadCloser, error) + newPackslipResolver func() (packslip.Resolver, error) status int err error @@ -467,12 +492,12 @@ func main() { // Work around https://github.com/columnar-tech/dbc/issues/351 usedRenderer := false if !isatty.IsTerminal(os.Stdout.Fd()) || !needsRenderer { - prog = tea.NewProgram(m, tea.WithoutRenderer(), tea.WithInput(nil)) + prog = tea.NewProgram(m, tea.WithoutRenderer(), tea.WithInput(nil), tea.WithFilter(filterProgramMessage)) } else if args.Quiet { // Quiet still prints stderr as GNU standard is to suppress "usual" output - prog = tea.NewProgram(m, tea.WithoutRenderer(), tea.WithInput(nil), tea.WithOutput(os.Stderr)) + prog = tea.NewProgram(m, tea.WithoutRenderer(), tea.WithInput(nil), tea.WithOutput(os.Stderr), tea.WithFilter(filterProgramMessage)) } else { - prog = tea.NewProgram(m) + prog = tea.NewProgram(m, tea.WithFilter(filterProgramMessage)) usedRenderer = true } @@ -484,8 +509,11 @@ func main() { } } + programModel := m var runErr error - if m, runErr = prog.Run(); runErr != nil { + m, runErr = prog.Run() + notifyProgramExited(programModel) + if runErr != nil { fmt.Fprintln(os.Stderr, "Error running program:", runErr) os.Exit(1) } diff --git a/cmd/dbc/main_test.go b/cmd/dbc/main_test.go index cc61c86a..eb17a2a2 100644 --- a/cmd/dbc/main_test.go +++ b/cmd/dbc/main_test.go @@ -132,18 +132,19 @@ func TestCmdStatus(t *testing.T) { func TestInstallHelpMentionsVersionConstraints(t *testing.T) { out := renderSubcommandHelp(t, "install", "-h") - require.Contains(t, out, "Driver to install, optionally with a version constraint") + require.Contains(t, out, "Driver to install, a version constraint") require.Contains(t, out, `dbc install "mysql=0.1.0"`) require.Contains(t, out, `dbc install "mysql>=1,<2"`) + require.Contains(t, out, "github.com/owner/repo=1.2.3") require.Contains(t, out, "https://docs.columnar.tech/dbc/guides/installing/#version-constraints") } func TestSubcommandSuggestions(t *testing.T) { tests := []struct { - name string - invalidCmd string - wantSuggestion string - hasSuggestion bool + name string + invalidCmd string + wantSuggestion string + hasSuggestion bool }{ { name: "unknown command has no suggestion", diff --git a/cmd/dbc/package_executor.go b/cmd/dbc/package_executor.go new file mode 100644 index 00000000..6cdf6338 --- /dev/null +++ b/cmd/dbc/package_executor.go @@ -0,0 +1,545 @@ +// 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 main + +import ( + "context" + "errors" + "fmt" + "io" + "net/url" + "os" + "sort" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc" + "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/resolution" + "github.com/columnar-tech/dbc/internal/sourceresolution" +) + +// packageExecutor owns the package-level proof and installation lifecycle for +// one selected artifact. Sync continues to own planning, project locking, and +// candidate-lock persistence, while this executor handles the source-neutral +// transition from a resolved artifact to a verified runtime registration. +type packageExecutor struct { + cfg config.Config + baseDir string + noVerify bool + downloadArtifact func(context.Context, dbc.PkgInfo) (io.ReadCloser, error) + downloadPkg func(dbc.PkgInfo) (*os.File, error) + fetchPackslip func(context.Context, *url.URL) (io.ReadCloser, error) + ensurePackage func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) +} + +func newPackageExecutor(cfg config.Config, baseDir string, noVerify bool, + downloadArtifact func(context.Context, dbc.PkgInfo) (io.ReadCloser, error), + downloadPkg func(dbc.PkgInfo) (*os.File, error), + fetchPackslip func(context.Context, *url.URL) (io.ReadCloser, error), + ensurePackage func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error), +) *packageExecutor { + if ensurePackage == nil { + ensurePackage = config.EnsurePackage + } + if fetchPackslip == nil { + fetchPackslip = fetchPackslipArtifact + } + return &packageExecutor{ + cfg: cfg, baseDir: baseDir, noVerify: noVerify, + downloadArtifact: downloadArtifact, downloadPkg: downloadPkg, + fetchPackslip: fetchPackslip, + ensurePackage: ensurePackage, + } +} + +func (s syncModel) newPackageExecutor() (*packageExecutor, error) { + baseDir := "" + if s.Path != "" || s.LockFilePath != "" { + var err error + baseDir, err = s.projectBaseDir() + if err != nil { + return nil, err + } + } + var ensurePackage func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) + if s.worker != nil { + ensurePackage = s.worker.hooks.ensurePackage + } + return newPackageExecutor(s.cfg, baseDir, s.NoVerify, s.downloadArtifact, s.downloadPkg, s.fetchPackslipArtifact, ensurePackage), nil +} + +func validateInstallableArtifactFormat(format string) error { + switch format { + case "", "tar.gz", "tgz": + return nil + default: + return fmt.Errorf("unsupported package format %q (supported formats: tar.gz, tgz)", format) + } +} + +func validateHostRequirements(driverID string, requirements resolution.HostRequirements) error { + if requirements.OSMin == "" && requirements.GLibCMin == "" && len(requirements.Libs) == 0 && len(requirements.Bins) == 0 { + return nil + } + var declared []string + if requirements.OSMin != "" { + declared = append(declared, fmt.Sprintf("os_min=%q", requirements.OSMin)) + } + if requirements.GLibCMin != "" { + declared = append(declared, fmt.Sprintf("glibc_min=%q", requirements.GLibCMin)) + } + if len(requirements.Libs) != 0 { + libs := append([]string(nil), requirements.Libs...) + sort.Strings(libs) + quoted := make([]string, len(libs)) + for i, lib := range libs { + quoted[i] = fmt.Sprintf("%q", lib) + } + declared = append(declared, "libs=["+strings.Join(quoted, ", ")+"]") + } + if len(requirements.Bins) != 0 { + bins := append([]resolution.NamedRequirement(nil), requirements.Bins...) + sort.Slice(bins, func(i, j int) bool { + if bins[i].Name != bins[j].Name { + return bins[i].Name < bins[j].Name + } + return bins[i].Min < bins[j].Min + }) + formatted := make([]string, len(bins)) + for i, bin := range bins { + formatted[i] = fmt.Sprintf("%q", bin.Name) + if bin.Min != "" { + formatted[i] += fmt.Sprintf(" (min %q)", bin.Min) + } + } + declared = append(declared, "bins=["+strings.Join(formatted, ", ")+"]") + } + return fmt.Errorf("unsupported host requirements for %s: %s", driverID, strings.Join(declared, "; ")) +} + +func expectedSyncPackageMetadata(item installItem) (config.ExpectedPackageMetadata, bool, error) { + selected, err := item.selectedArtifact() + if err != nil { + return config.ExpectedPackageMetadata{}, false, err + } + if item.Release.DriverID == "" || item.Release.Version == "" || item.Platform == "" || + item.Release.Source.Type == "" || item.Release.Source.Reference == "" { + return config.ExpectedPackageMetadata{}, false, errors.New("resolved package metadata is incomplete") + } + hasHash := selected.Hash != "" + hasSize := selected.Size != nil + if item.Release.Source.Type == "packslip" && !hasHash { + return config.ExpectedPackageMetadata{}, false, errors.New("Packslip artifact requires an authenticated archive hash") + } + if !hasHash && hasSize { + return config.ExpectedPackageMetadata{}, false, errors.New("package metadata cannot include size without archive hash") + } + expected := config.ExpectedPackageMetadata{ + ID: item.Release.DriverID, Version: item.Release.Version, Platform: item.Platform, + SourceType: item.Release.Source.Type, SourceIdentity: item.Release.Source.Reference, + } + if hasHash { + expected.ArchiveHash = selected.Hash + if hasSize { + expected.ArchiveSize = *selected.Size + expected.ArchiveSizePresent = true + } + } + return expected, hasHash, nil +} + +func (e *packageExecutor) openResolvedArtifact(ctx context.Context, item installItem) (*sourceresolution.OpenedArtifact, error) { + selected, err := item.selectedArtifact() + if err != nil { + return nil, err + } + if selected.Location.Kind == resolution.ArtifactLocationPath { + if item.Release.Source.Type != "path" { + return nil, fmt.Errorf("source %q cannot open a path artifact", item.Release.Source.Type) + } + return sourceresolution.OpenArtifact(ctx, nil, selected.Location, e.baseDir) + } + if selected.Location.Kind != resolution.ArtifactLocationURL { + return nil, fmt.Errorf("unsupported artifact location kind %q", selected.Location.Kind) + } + switch item.Release.Source.Type { + case "packslip": + if e.fetchPackslip == nil { + return nil, errors.New("no credential-free Packslip artifact fetcher is configured") + } + return sourceresolution.OpenArtifact(ctx, e.fetchPackslip, selected.Location, e.baseDir) + case "registry": + fetch := func(ctx context.Context, artifactURL *url.URL) (io.ReadCloser, error) { + return e.openRegistryArtifact(ctx, item, selected, artifactURL) + } + return sourceresolution.OpenArtifact(ctx, fetch, selected.Location, e.baseDir) + default: + return nil, fmt.Errorf("source %q cannot open a URL artifact", item.Release.Source.Type) + } +} + +func (e *packageExecutor) openRegistryArtifact(ctx context.Context, item installItem, selected *resolution.Artifact, artifactURL *url.URL) (io.ReadCloser, error) { + version, err := semver.NewVersion(item.Release.Version) + if err != nil { + return nil, fmt.Errorf("invalid resolved package version %q: %w", item.Release.Version, err) + } + registryURL, err := url.Parse(item.Release.Source.Reference) + if err != nil || registryURL.Host == "" { + return nil, fmt.Errorf("invalid resolved registry identity %q", item.Release.Source.Reference) + } + pkg := dbc.PkgInfo{ + Driver: dbc.Driver{Path: item.Release.DriverID, Title: item.Release.DriverID, Registry: &dbc.Registry{BaseURL: registryURL}}, + Version: version, + PlatformTuple: item.Platform, + Path: artifactURL, + ArtifactHash: selected.Hash, + ArtifactSize: cloneInt64(selected.Size), + } + if e.downloadArtifact != nil { + return e.downloadArtifact(ctx, pkg) + } + if e.downloadPkg == nil { + return nil, errors.New("no artifact downloader is configured") + } + // Existing test adapters and custom models expose the legacy file-based + // hook. Production sync uses downloadArtifact, which calls Client.Download. + return e.downloadPkg(pkg) +} + +func (e *packageExecutor) prepareItem(ctx context.Context, item *installItem) error { + if err := ctx.Err(); err != nil { + return err + } + selected, err := item.selectedArtifact() + if err != nil { + return err + } + if err := validateInstallableArtifactFormat(selected.Format); err != nil { + return fmt.Errorf("driver %s: %w", item.Release.DriverID, err) + } + if err := validateHostRequirements(item.Release.DriverID, selected.HostRequirements); err != nil { + return err + } + var sameVersionInstalled *config.DriverInfo + if e.cfg.Exists { + if installed, ok := e.cfg.Drivers[item.Release.DriverID]; ok { + if installed.Version != nil && item.Release.Version == installed.Version.String() { + installedCopy := installed + sameVersionInstalled = &installedCopy + expected, _, err := expectedSyncPackageMetadata(*item) + if err != nil { + return err + } + item.Expected = expected + matches, err := e.itemCurrentMatches(item, &installedCopy) + if err != nil { + return err + } + if matches { + libraryHash, hashErr := checksum(installed.Driver.Shared.Get(item.Platform)) + if hashErr != nil { + return fmt.Errorf("failed to checksum installed driver: %w", hashErr) + } + markAlreadyInstalled(item, installed, libraryHash) + } + } + } + } + + needsArchive := item.AlreadyInstalled == nil || !canReuseLockedEntry(*item) + if needsArchive { + if err := e.downloadAndPrepareItem(ctx, item); err != nil { + return err + } + if sameVersionInstalled != nil { + matches, err := e.itemCurrentMatches(item, sameVersionInstalled) + if err != nil { + return err + } + if matches { + libraryHash, hashErr := checksum(sameVersionInstalled.Driver.Shared.Get(item.Platform)) + if hashErr != nil { + return fmt.Errorf("failed to checksum installed driver: %w", hashErr) + } + markAlreadyInstalled(item, *sameVersionInstalled, libraryHash) + } + } + if item.AlreadyInstalled == nil { + item.InstalledLibraryHash = item.ValidatedLibraryHash + if item.Validation != nil && item.Validation.VerifiedLibraryHash == "" && + item.LockEntry != nil && item.LockEntry.Legacy != nil && samePlatformTarget(item.LockEntry.Legacy.Platform, item.Platform) { + item.InstalledLibraryHash = item.LockEntry.Legacy.LibraryHash + } + } + } else { + // The exact locked artifact is already proven by its managed receipt. + expected, _, err := expectedSyncPackageMetadata(*item) + if err != nil { + return err + } + item.Expected = expected + } + return nil +} + +func (e *packageExecutor) downloadAndPrepareItem(ctx context.Context, item *installItem) error { + if err := ctx.Err(); err != nil { + return err + } + if _, err := item.selectedArtifact(); err != nil { + return err + } + expected, _, err := expectedSyncPackageMetadata(*item) + if err != nil { + return err + } + if item.Archive == nil { + archive, err := e.openResolvedArtifact(ctx, *item) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("failed to open driver artifact: %w", err) + } + item.Archive = archive + } + archive := item.Archive.File + var verify func(string, config.Manifest) error + // Packslip authenticates the archive digest during resolution; replay + // verifies the same locked digest. Columnar library signatures are a + // separate registry/local policy, independent of the package format. + // TODO: When another signing policy is supported, share policy selection + // with Client.Install and keep signer selection out of archive parsing. + if !e.noVerify && item.Release.Source.Type != "packslip" { + verify = func(stagingDir string, manifest config.Manifest) error { + return dbc.VerifyPackageSignature(stagingDir, manifest) + } + } + validation, err := config.PreparePackage(e.cfg, item.Release.DriverID, archive, expected, config.InstallOptions{Verify: verify}) + if err != nil { + if isPackageVerificationFailure(err) { + return fmt.Errorf("failed to verify signature: %w", packageVerificationError(err)) + } + return fmt.Errorf("failed to prepare driver package: %w", err) + } + prepared := validation.Prepared + preparedAccepted := false + defer func() { + if !preparedAccepted && prepared != nil { + _ = prepared.Close() + } + }() + selected, err := item.selectedArtifact() + if err != nil { + return err + } + archiveHashWasMissing := selected.Hash == "" + if selected.Hash == "" { + selected.Hash = validation.ArchiveHash + } + expected, _, err = expectedSyncPackageMetadata(*item) + if err != nil { + return err + } + if archiveHashWasMissing { + // A legacy registry release has no source digest to constrain this + // download. Finalize its identity with the measured hash for the lock, + // while keeping the measured size in the receipt rather than the lock. + expected.ArchiveHash = validation.ArchiveHash + expected.ArchiveSize = validation.ArchiveSize + expected.ArchiveSizePresent = true + } + item.Expected = expected + item.ValidatedLibraryHash = strings.TrimPrefix(validation.VerifiedLibraryHash, "sha256:") + if item.ValidatedLibraryHash == "" { + candidateLibrary := validation.Registration.Driver.Shared.Get(item.Platform) + if item.LockEntry != nil && item.LockEntry.Legacy != nil && samePlatformTarget(item.LockEntry.Legacy.Platform, item.Platform) { + if err := verifyLegacyExternalLibrary(candidateLibrary, item.LockEntry.Legacy.LibraryHash); err != nil { + return fmt.Errorf("candidate package external library does not match the legacy lock proof: %w", err) + } + } + } + if item.Archive != nil { + if err := item.Archive.Close(); err != nil { + return fmt.Errorf("could not close downloaded package archive: %w", err) + } + item.Archive = nil + } + item.Validation = &validation + preparedAccepted = true + return nil +} + +func (e *packageExecutor) itemCurrentMatches(item *installItem, current *config.DriverInfo) (bool, error) { + if current == nil || current.Version == nil || item.Expected.ID == "" || item.Expected.Version == "" || + current.ID != item.Expected.ID || current.Version.String() != item.Expected.Version { + return false, nil + } + receipt, managed, present, valid, err := config.InspectDriverInstallReceipt(e.cfg, *current) + if err != nil { + return false, fmt.Errorf("failed to resolve installed driver receipt location: %w", err) + } + libraryPath := current.Driver.Shared.Get(item.Platform) + if managed && present { + // Receipts establish content integrity, not whether signature verification + // was performed. This also reuses installations made with --no-verify. + if !valid || !config.InstallReceiptMatchesExpectedPackage(receipt, item.Expected) || + !config.VerifyInstallReceiptLibraryIntegrity(libraryPath, receipt) || + !config.InstallReceiptMatchesRuntimeRegistration(receipt, *current, item.Platform) { + return false, nil + } + if item.Validation != nil { + if item.Validation.VerifiedLibraryHash == "" || item.Validation.VerifiedLibraryHash != receipt.InstalledLibraryHash || + !config.PackageValidationMatchesRuntimeRegistration(*current, *item.Validation, item.Platform) { + return false, nil + } + } + return true, nil + } + if present || item.Validation == nil || item.LockEntry == nil || item.LockEntry.Legacy == nil || + !samePlatformTarget(item.LockEntry.Legacy.Platform, item.Platform) || + !config.PackageValidationMatchesRuntimeRegistration(*current, *item.Validation, item.Platform) { + return false, nil + } + proofHash := item.LockEntry.Legacy.LibraryHash + if err := validateLegacyLibraryHash(proofHash); err != nil { + return false, nil + } + currentHash, err := checksum(libraryPath) + if err != nil || currentHash != proofHash { + return false, nil + } + if item.Validation.VerifiedLibraryHash != "" { + return item.ValidatedLibraryHash == proofHash, nil + } + if err := verifyLegacyExternalLibrary(item.Validation.Registration.Driver.Shared.Get(item.Platform), proofHash); err != nil { + return false, nil + } + return true, nil +} + +func (e *packageExecutor) ensurePreparedPackage(ctx context.Context, item *installItem) (config.EnsurePackageResult, error) { + if item != nil && item.Validation != nil && item.Validation.Prepared != nil { + defer func() { _ = item.Validation.Prepared.Close() }() + } + if err := ctx.Err(); err != nil { + return config.EnsurePackageResult{}, err + } + if _, err := item.selectedArtifact(); err != nil { + return config.EnsurePackageResult{}, err + } + callbacks := config.EnsurePackageCallbacks{ + CurrentMatches: func(current *config.DriverInfo) (bool, error) { + return e.itemCurrentMatches(item, current) + }, + Prepare: func(ctx context.Context) (*config.PreparedPackage, error) { + if item.Validation == nil || item.Validation.Prepared == nil { + if err := e.downloadAndPrepareItem(ctx, item); err != nil { + return nil, err + } + } + if item.Validation == nil || item.Validation.Prepared == nil { + return nil, errors.New("package preparation completed without a prepared payload") + } + return item.Validation.Prepared, nil + }, + ValidateResult: func(result config.EnsurePackageResult) error { + return e.validateEnsureResult(item, result) + }, + } + result, err := e.ensurePackage(ctx, e.cfg, item.Release.DriverID, item.Expected, callbacks) + if err != nil && isPackageVerificationFailure(err) { + return result, fmt.Errorf("failed to verify signature: %w", packageVerificationError(err)) + } + return result, err +} + +func (e *packageExecutor) validateEnsureResult(item *installItem, result config.EnsurePackageResult) error { + if result.Installed == nil { + return errors.New("package ensure returned no installed driver registration") + } + if result.Skipped { + matches, err := e.itemCurrentMatches(item, result.Installed) + if err != nil { + return err + } + if !matches { + return errors.New("installed driver no longer matches the selected package proof") + } + return nil + } + if item.Validation == nil || result.Manifest == nil { + return errors.New("installed package is missing validated candidate evidence") + } + if !config.PackageValidationMatchesRuntimeRegistration(*result.Installed, *item.Validation, item.Platform) || + !config.PackageValidationMatchesRuntimeRegistration(result.Manifest.DriverInfo, *item.Validation, item.Platform) { + return errors.New("installed registration does not match the validated package") + } + if result.Installed.Version == nil || result.Installed.Version.String() != item.Expected.Version { + return errors.New("installed driver version does not match the selected package") + } + if item.Validation.VerifiedLibraryHash == "" { + path := result.Installed.Driver.Shared.Get(item.Platform) + if item.LockEntry != nil && item.LockEntry.Legacy != nil && samePlatformTarget(item.LockEntry.Legacy.Platform, item.Platform) { + if err := verifyLegacyExternalLibrary(path, item.LockEntry.Legacy.LibraryHash); err != nil { + return fmt.Errorf("installed package external library does not match the legacy lock proof: %w", err) + } + } + return nil + } + receipt, managed, present, valid, err := config.InspectDriverInstallReceipt(e.cfg, *result.Installed) + if err != nil { + return fmt.Errorf("failed to resolve installed driver receipt location: %w", err) + } + libraryPath := result.Installed.Driver.Shared.Get(item.Platform) + if !managed || !present || !valid || !config.InstallReceiptMatchesExpectedPackage(receipt, item.Expected) || + receipt.InstalledLibraryHash != item.Validation.VerifiedLibraryHash || + !config.InstallReceiptMatchesRuntimeRegistration(receipt, *result.Installed, item.Platform) || + !config.VerifyInstallReceiptLibraryIntegrity(libraryPath, receipt) { + return errors.New("installed package receipt does not match the validated package") + } + return nil +} + +func markAlreadyInstalled(item *installItem, installed config.DriverInfo, libraryHash string) { + item.InstalledLibraryHash = strings.TrimPrefix(libraryHash, "sha256:") + installedCopy := installed + item.AlreadyInstalled = &installedCopy +} + +func verifyLegacyExternalLibrary(path, expectedHash string) error { + if err := validateLegacyLibraryHash(expectedHash); err != nil { + return fmt.Errorf("invalid legacy library checksum: %w", err) + } + if path == "" { + return errors.New("candidate runtime registration has no shared library path") + } + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("could not inspect candidate external library %s: %w", path, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("candidate external library %s is not a regular file", path) + } + actualHash, err := checksum(path) + if err != nil { + return err + } + if actualHash != expectedHash { + return fmt.Errorf("candidate external library checksum mismatch: got %s, expected %s", actualHash, expectedHash) + } + return nil +} diff --git a/cmd/dbc/packslip_fetch.go b/cmd/dbc/packslip_fetch.go new file mode 100644 index 00000000..299e757d --- /dev/null +++ b/cmd/dbc/packslip_fetch.go @@ -0,0 +1,114 @@ +// 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 main + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +const ( + packslipArtifactRedirectLimit = 10 + packslipErrorBodyLimit = 4096 +) + +var packslipArtifactClient = newPackslipArtifactHTTPClient() + +func newPackslipArtifactHTTPClient() *http.Client { + transport := &http.Transport{Proxy: http.ProxyFromEnvironment, ForceAttemptHTTP2: true} + return &http.Client{ + Transport: transport, + CheckRedirect: func(request *http.Request, via []*http.Request) error { + if len(via) >= packslipArtifactRedirectLimit { + return fmt.Errorf("Packslip artifact redirect limit (%d) exceeded", packslipArtifactRedirectLimit) + } + if err := validatePackslipArtifactURL(request.URL); err != nil { + return fmt.Errorf("reject Packslip artifact redirect: %w", err) + } + return nil + }, + } +} + +func fetchPackslipArtifact(ctx context.Context, artifactURL *url.URL) (io.ReadCloser, error) { + return fetchPackslipArtifactWithClient(ctx, packslipArtifactClient, artifactURL) +} + +// fetchPackslipArtifactWithClient keeps tests transport-local while ensuring +// that the security policy is applied even when the supplied client has a +// different redirect callback or a cookie jar. +func fetchPackslipArtifactWithClient(ctx context.Context, client *http.Client, artifactURL *url.URL) (io.ReadCloser, error) { + if ctx == nil { + return nil, errors.New("Packslip artifact fetch requires a context") + } + if err := validatePackslipArtifactURL(artifactURL); err != nil { + return nil, err + } + if client == nil { + client = packslipArtifactClient + } + copy := *client + copy.Jar = nil + previousRedirectCheck := client.CheckRedirect + copy.CheckRedirect = func(request *http.Request, via []*http.Request) error { + if len(via) >= packslipArtifactRedirectLimit { + return fmt.Errorf("Packslip artifact redirect limit (%d) exceeded", packslipArtifactRedirectLimit) + } + if err := validatePackslipArtifactURL(request.URL); err != nil { + return fmt.Errorf("reject Packslip artifact redirect: %w", err) + } + if previousRedirectCheck != nil { + if err := previousRedirectCheck(request, via); err != nil { + return err + } + if err := validatePackslipArtifactURL(request.URL); err != nil { + return fmt.Errorf("reject Packslip artifact redirect after client policy: %w", err) + } + } + return nil + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, artifactURL.String(), nil) + if err != nil { + return nil, fmt.Errorf("create Packslip artifact request: %w", err) + } + request.Header.Set("User-Agent", "dbc") + response, err := copy.Do(request) + if err != nil { + return nil, fmt.Errorf("fetch Packslip artifact: %w", err) + } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + body, _ := io.ReadAll(io.LimitReader(response.Body, packslipErrorBodyLimit)) + _ = response.Body.Close() + detail := strings.TrimSpace(string(body)) + if detail == "" { + return nil, fmt.Errorf("fetch Packslip artifact: unexpected HTTP status %s", response.Status) + } + return nil, fmt.Errorf("fetch Packslip artifact: unexpected HTTP status %s: %s", response.Status, detail) + } + return response.Body, nil +} + +func validatePackslipArtifactURL(artifactURL *url.URL) error { + if artifactURL == nil || !strings.EqualFold(artifactURL.Scheme, "https") || + artifactURL.Hostname() == "" || artifactURL.User != nil || artifactURL.Fragment != "" || strings.Contains(artifactURL.String(), "#") { + return errors.New("Packslip artifact URL must be HTTPS with a host and without userinfo or fragment") + } + return nil +} diff --git a/cmd/dbc/packslip_fetch_test.go b/cmd/dbc/packslip_fetch_test.go new file mode 100644 index 00000000..e586f4bf --- /dev/null +++ b/cmd/dbc/packslip_fetch_test.go @@ -0,0 +1,205 @@ +// 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 main + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/columnar-tech/dbc" + "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/resolution" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackslipArtifactFetchPreservesSignedURLWithoutCredentials(t *testing.T) { + const rawQuery = "X-Amz-Credential=release%2Ftoken&X-Amz-Signature=abc%2B123&response-content-type=application%2Fgzip" + var requestQuery string + var authorization string + var midPresent, uidPresent bool + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestQuery = r.URL.RawQuery + authorization = r.Header.Get("Authorization") + query := r.URL.Query() + _, midPresent = query["mid"] + _, uidPresent = query["uid"] + _, _ = io.WriteString(w, "signed package bytes") + })) + defer server.Close() + artifactURL, err := url.Parse(server.URL + "/driver.tgz?" + rawQuery) + require.NoError(t, err) + target, err := resolution.TargetFromPlatformTuple(config.PlatformTuple()) + require.NoError(t, err) + item, err := newInstallItem(resolution.ResolvedRelease{ + DriverID: "example", Version: "1.2.3", + Source: resolution.SourceSpec{Type: "packslip", Reference: "github.com/example/driver"}, + Artifacts: []resolution.Artifact{{ + Target: target, Format: "tgz", + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: artifactURL.String()}, + }}, + }, 0, config.PlatformTuple(), nil) + require.NoError(t, err) + registryCalls := 0 + executor := newPackageExecutor(config.Config{}, t.TempDir(), true, + func(context.Context, dbc.PkgInfo) (io.ReadCloser, error) { + registryCalls++ + return nil, errors.New("Packslip fetch must not use the registry transport") + }, nil, nil, nil) + executor.fetchPackslip = func(ctx context.Context, url *url.URL) (io.ReadCloser, error) { + return fetchPackslipArtifactWithClient(ctx, server.Client(), url) + } + opened, err := executor.openResolvedArtifact(context.Background(), item) + require.NoError(t, err) + defer opened.Close() + contents, err := io.ReadAll(opened.File) + require.NoError(t, err) + assert.Equal(t, "signed package bytes", string(contents)) + assert.Equal(t, rawQuery, requestQuery, "signed query must be sent exactly as resolved") + assert.Empty(t, authorization) + assert.False(t, midPresent) + assert.False(t, uidPresent) + assert.Zero(t, registryCalls) +} + +func TestPackageExecutorRoutesRegistryURLThroughExistingDownloader(t *testing.T) { + target, err := resolution.TargetFromPlatformTuple(config.PlatformTuple()) + require.NoError(t, err) + item, err := newInstallItem(resolution.ResolvedRelease{ + DriverID: "example", Version: "1.2.3", + Source: resolution.SourceSpec{Type: "registry", Reference: "https://registry.example.test"}, + Artifacts: []resolution.Artifact{{ + Target: target, Format: "tar.gz", + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://assets.example.test/driver.tar.gz?sig=unchanged"}, + }}, + }, 0, config.PlatformTuple(), nil) + require.NoError(t, err) + registryCalls, packslipCalls := 0, 0 + executor := newPackageExecutor(config.Config{}, t.TempDir(), true, + func(_ context.Context, pkg dbc.PkgInfo) (io.ReadCloser, error) { + registryCalls++ + assert.Equal(t, "sig=unchanged", pkg.Path.RawQuery) + return io.NopCloser(strings.NewReader("registry package bytes")), nil + }, nil, nil, nil) + executor.fetchPackslip = func(context.Context, *url.URL) (io.ReadCloser, error) { + packslipCalls++ + return nil, errors.New("registry fetch must not use Packslip transport") + } + opened, err := executor.openResolvedArtifact(context.Background(), item) + require.NoError(t, err) + defer opened.Close() + contents, err := io.ReadAll(opened.File) + require.NoError(t, err) + assert.Equal(t, "registry package bytes", string(contents)) + assert.Equal(t, 1, registryCalls) + assert.Zero(t, packslipCalls) +} + +func TestPackageExecutorFailsClosedForUnknownURLSource(t *testing.T) { + target, err := resolution.TargetFromPlatformTuple(config.PlatformTuple()) + require.NoError(t, err) + item, err := newInstallItem(resolution.ResolvedRelease{ + DriverID: "example", Version: "1.2.3", + Source: resolution.SourceSpec{Type: "unknown", Reference: "opaque"}, + Artifacts: []resolution.Artifact{{ + Target: target, Format: "tgz", + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://assets.example.test/driver.tgz"}, + }}, + }, 0, config.PlatformTuple(), nil) + require.NoError(t, err) + registryCalls, packslipCalls := 0, 0 + executor := newPackageExecutor(config.Config{}, t.TempDir(), true, + func(context.Context, dbc.PkgInfo) (io.ReadCloser, error) { + registryCalls++ + return nil, nil + }, nil, nil, nil) + executor.fetchPackslip = func(context.Context, *url.URL) (io.ReadCloser, error) { + packslipCalls++ + return nil, nil + } + _, err = executor.openResolvedArtifact(context.Background(), item) + require.ErrorContains(t, err, `source "unknown" cannot open a URL artifact`) + assert.Zero(t, registryCalls) + assert.Zero(t, packslipCalls) +} + +func TestPackslipArtifactFetchBoundsErrorBody(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = io.WriteString(w, "prefix"+strings.Repeat("x", packslipErrorBodyLimit)+"body-tail-sentinel") + })) + defer server.Close() + artifactURL, err := url.Parse(server.URL + "/driver.tgz") + require.NoError(t, err) + _, err = fetchPackslipArtifactWithClient(context.Background(), server.Client(), artifactURL) + require.Error(t, err) + assert.ErrorContains(t, err, "502 Bad Gateway") + assert.NotContains(t, err.Error(), "body-tail-sentinel") + assert.Less(t, len(err.Error()), packslipErrorBodyLimit+256) +} + +func TestPackslipArtifactFetchRejectsHTTPSDowngradeRedirect(t *testing.T) { + var downgradeHits int + httpTarget := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + downgradeHits++ + })) + defer httpTarget.Close() + tlsSource := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, httpTarget.URL+"/archive", http.StatusFound) + })) + defer tlsSource.Close() + artifactURL, err := url.Parse(tlsSource.URL + "/signed") + require.NoError(t, err) + _, err = fetchPackslipArtifactWithClient(context.Background(), tlsSource.Client(), artifactURL) + require.Error(t, err) + assert.ErrorContains(t, err, "reject Packslip artifact redirect") + assert.ErrorContains(t, err, "must be HTTPS") + assert.Zero(t, downgradeHits) +} + +func TestPackslipArtifactFetchRejectsRedirectCallbackDowngrade(t *testing.T) { + var downgradeHits int + httpTarget := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + downgradeHits++ + })) + defer httpTarget.Close() + tlsSource := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "https://assets.example.test/redirect", http.StatusFound) + })) + defer tlsSource.Close() + artifactURL, err := url.Parse(tlsSource.URL + "/signed") + require.NoError(t, err) + client := tlsSource.Client() + client.CheckRedirect = func(request *http.Request, _ []*http.Request) error { + targetURL, parseErr := url.Parse(httpTarget.URL + "/archive") + if parseErr != nil { + return parseErr + } + request.URL = targetURL + return nil + } + + _, err = fetchPackslipArtifactWithClient(context.Background(), client, artifactURL) + require.Error(t, err) + assert.ErrorContains(t, err, "reject Packslip artifact redirect after client policy") + assert.ErrorContains(t, err, "must be HTTPS") + assert.Zero(t, downgradeHits) +} diff --git a/cmd/dbc/registry_test.go b/cmd/dbc/registry_test.go index d4233dee..737c9aeb 100644 --- a/cmd/dbc/registry_test.go +++ b/cmd/dbc/registry_test.go @@ -26,6 +26,7 @@ import ( "time" tea "charm.land/bubbletea/v2" + "github.com/Masterminds/semver/v3" "github.com/columnar-tech/dbc/config" "github.com/stretchr/testify/suite" "golang.org/x/sys/windows/registry" @@ -51,12 +52,14 @@ func (s *RegistryTestSuite) run(m tea.Model) string { var err error m, err = p.Run() s.Require().NoError(err) - s.Equal(0, m.(HasStatus).Status(), "exited with a non-zero status") var extra string if fo, ok := m.(HasFinalOutput); ok { extra = fo.FinalOutput() } + status := m.(HasStatus) + s.Equal(0, status.Status(), "exited with a non-zero status: error=%v, final output=%q, captured output=%q", + status.Err(), extra, out.String()) return out.String() + extra } @@ -94,8 +97,25 @@ func (s *RegistryTestSuite) TearDownTest() { os.RemoveAll(s.cfgUserPath) } +func (s *RegistryTestSuite) assertRegisteredPackagePath() { + k, err := registry.OpenKey(registry.CURRENT_USER, "SOFTWARE\\ADBC\\Drivers\\test-driver-1", registry.READ) + s.Require().NoError(err) + defer k.Close() + + path, _, err := k.GetStringValue("driver") + s.Require().NoError(err) + s.FileExists(path) + + receipt, managed, present, valid := config.InspectInstallReceipt(s.cfgUserPath, "test-driver-1", path) + s.Require().True(managed, "registry driver path should point into a dbc-managed package generation: %s", path) + s.Require().True(present, "managed package generation should contain an install receipt: %s", path) + s.Require().True(valid, "managed package receipt should match the registered library path: %s", path) + s.Equal("test-driver-1", receipt.DriverID) + s.Equal("1.1.0", receipt.DriverVersion) +} + func (s *RegistryTestSuite) TestInstallDriver() { - m := InstallCmd{Driver: "test-driver-1"}. + m := InstallCmd{Driver: "test-driver-1", Level: config.ConfigUser}. GetModelCustom(testBaseModel()) out := s.run(m) s.Equal("\nInstalled test-driver-1 1.1.0 to "+s.cfgUserPath, out) @@ -109,14 +129,61 @@ func (s *RegistryTestSuite) TestInstallDriver() { s.Require().NoError(err) s.Equal("1.1.0", val) - val, _, err = k.GetStringValue("driver") + s.assertRegisteredPackagePath() + + installed, err := config.GetDriver(config.Config{Level: config.ConfigUser, Location: s.cfgUserPath}, "test-driver-1") s.Require().NoError(err) - s.Equal(filepath.Join(s.cfgUserPath, "test-driver-1.1", "test-driver-1-not-valid.so"), val) + s.Require().NotNil(installed.AdbcInfo.Version) + s.Equal("1.1.0", installed.AdbcInfo.Version.String()) +} + +func (s *RegistryTestSuite) TestRegistryPersistsAndClearsADBCMetadata() { + cfg := config.Config{Level: config.ConfigUser, Location: s.cfgUserPath} + driver := config.DriverInfo{ + ID: "test-registry-metadata", Name: "Test Registry Metadata", Publisher: "Example", + License: "Apache-2.0", Source: "dbc", Version: semver.MustParse("1.2.3"), + } + driver.AdbcInfo.Version = semver.MustParse("0.9.1") + driver.AdbcInfo.Features.Supported = []string{"feature-b", "feature-a"} + driver.AdbcInfo.Features.Unsupported = []string{"feature-c"} + driver.Driver.Entrypoint = "AdbcDriverInit" + driver.Driver.Shared.Set(config.PlatformTuple(), filepath.Join(s.cfgUserPath, "driver.dll")) + s.Require().NoError(config.CreateManifest(cfg, driver)) + + loaded, err := config.GetDriver(cfg, driver.ID) + s.Require().NoError(err) + s.Require().NotNil(loaded.AdbcInfo.Version) + s.Equal("0.9.1", loaded.AdbcInfo.Version.String()) + s.Equal([]string{"feature-b", "feature-a"}, loaded.AdbcInfo.Features.Supported) + s.Equal([]string{"feature-c"}, loaded.AdbcInfo.Features.Unsupported) + + // Replacing a registration without optional ADBC metadata must remove old + // registry values instead of leaving stale values that change its identity. + driver.AdbcInfo.Version = nil + driver.AdbcInfo.Features.Supported = nil + driver.AdbcInfo.Features.Unsupported = nil + s.Require().NoError(config.CreateManifest(cfg, driver)) + loaded, err = config.GetDriver(cfg, driver.ID) + s.Require().NoError(err) + s.Nil(loaded.AdbcInfo.Version) + s.Empty(loaded.AdbcInfo.Features.Supported) + s.Empty(loaded.AdbcInfo.Features.Unsupported) + + k, err := registry.OpenKey(registry.CURRENT_USER, "SOFTWARE\\ADBC\\Drivers\\"+driver.ID, registry.ALL_ACCESS) + s.Require().NoError(err) + defer k.Close() + s.Require().NoError(k.SetStringValue("adbc_version", "not-a-version")) + _, err = config.GetDriver(cfg, driver.ID) + s.ErrorContains(err, "invalid ADBC version in registry") + s.Require().NoError(k.SetStringValue("adbc_version", "0.9.1")) + s.Require().NoError(k.SetStringValue("adbc_supported_features", "wrong-registry-type")) + _, err = config.GetDriver(cfg, driver.ID) + s.Error(err, "a present feature value with the wrong registry type must be rejected") } func (s *RegistryTestSuite) TestPartialReinstallDriver() { // First install the driver normally. - m := InstallCmd{Driver: "test-driver-1"}. + m := InstallCmd{Driver: "test-driver-1", Level: config.ConfigUser}. GetModelCustom(testBaseModel()) out := s.run(m) s.Equal("\nInstalled test-driver-1 1.1.0 to "+s.cfgUserPath, out) @@ -124,10 +191,11 @@ func (s *RegistryTestSuite) TestPartialReinstallDriver() { s.clearRegistry() // Now reinstall the driver, which should succeed even though the registry key is missing. - m = InstallCmd{Driver: "test-driver-1"}. + m = InstallCmd{Driver: "test-driver-1", Level: config.ConfigUser}. GetModelCustom(testBaseModel()) out = s.run(m) s.Equal("\nInstalled test-driver-1 1.1.0 to "+s.cfgUserPath, out) + s.assertRegisteredPackagePath() } func TestRegistryKeyHandling(t *testing.T) { diff --git a/cmd/dbc/registry_wiring_test.go b/cmd/dbc/registry_wiring_test.go index 7ed35f65..a6897cff 100644 --- a/cmd/dbc/registry_wiring_test.go +++ b/cmd/dbc/registry_wiring_test.go @@ -393,86 +393,6 @@ func TestAuthHTTPClientDoesNotRequireRegistryConfig(t *testing.T) { require.NotNil(t, c) } -// TestGetDriverListHonorsProjectRegistries proves GetDriverList — a helper -// used by library consumers that parse dbc.toml directly — honors the -// project's [[registries]] section when resolving driver packages, AND -// does not leak that configuration across calls in the same process. -func TestGetDriverListHonorsProjectRegistries(t *testing.T) { - indexFor := func(drvName string) string { - return `drivers: - - name: ` + drvName + ` - description: test - license: MIT - path: ` + drvName + ` - pkginfo: - - version: v1.0.0 - packages: - - platform: linux_amd64 - url: ` + drvName + `/1.0.0/x.tar.gz - - platform: linux_arm64 - url: ` + drvName + `/1.0.0/x.tar.gz - - platform: macos_amd64 - url: ` + drvName + `/1.0.0/x.tar.gz - - platform: macos_arm64 - url: ` + drvName + `/1.0.0/x.tar.gz - - platform: windows_amd64 - url: ` + drvName + `/1.0.0/x.tar.gz - - platform: windows_arm64 - url: ` + drvName + `/1.0.0/x.tar.gz -` - } - - makeServer := func(drvName string, hits *int32) *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasSuffix(r.URL.Path, "/index.yaml") { - atomic.AddInt32(hits, 1) - w.Header().Set("Content-Type", "application/yaml") - w.Write([]byte(indexFor(drvName))) - return - } - http.NotFound(w, r) - })) - } - - t.Setenv("DBC_BASE_URL", "") - - savedGlobal := globalRegistryConfig - t.Cleanup(func() { globalRegistryConfig = savedGlobal }) - globalRegistryConfig = nil - - var hitsA, hitsB int32 - serverA := makeServer("driver-a", &hitsA) - defer serverA.Close() - serverB := makeServer("driver-b", &hitsB) - defer serverB.Close() - - writeList := func(t *testing.T, registryURL, drvName string) string { - t.Helper() - p := t.TempDir() + "/dbc.toml" - content := "replace_defaults = true\n\n" + - "[[registries]]\nurl = '" + registryURL + "'\n\n" + - "[drivers]\n[drivers." + drvName + "]\nversion = '>=1.0.0'\n" - require.NoError(t, os.WriteFile(p, []byte(content), 0o644)) - return p - } - - // Call 1: dbc.toml pointing at server A. - pkgs, err := GetDriverList(writeList(t, serverA.URL, "driver-a")) - require.NoError(t, err) - require.Len(t, pkgs, 1) - assert.Equal(t, "driver-a", pkgs[0].Driver.Path) - assert.GreaterOrEqual(t, atomic.LoadInt32(&hitsA), int32(1)) - - // Call 2: a DIFFERENT dbc.toml pointing at server B. If GetDriverList - // leaked registry state from call 1, server B wouldn't be hit. - pkgs, err = GetDriverList(writeList(t, serverB.URL, "driver-b")) - require.NoError(t, err) - require.Len(t, pkgs, 1) - assert.Equal(t, "driver-b", pkgs[0].Driver.Path) - assert.GreaterOrEqual(t, atomic.LoadInt32(&hitsB), int32(1), - "GetDriverList must not leak registry state from a previous call") -} - // TestStartupEndToEndGlobalReplaceDefaultsWithProjectEntries runs the full // CLI startup sequence (loadStartupRegistryConfig + project-command dispatch // via applyProjectRegistries) against a temp global config.toml declaring @@ -739,6 +659,161 @@ func TestAddAbortsOnConcurrentRegistryConfigChange(t *testing.T) { assert.NotContains(t, s, "test-driver-1", "aborted add must not write the driver entry") } +func TestAddAbortsWhenTargetSourceChangesDuringRegistryLookup(t *testing.T) { + t.Setenv("DBC_BASE_URL", "") + + dir := t.TempDir() + tomlPath := filepath.Join(dir, "dbc.toml") + initialContent := "[drivers.test-driver-1]\nversion = '>=1.0.0'\n" + require.NoError(t, os.WriteFile(tomlPath, []byte(initialContent), 0o644)) + + lookupStarted := make(chan struct{}) + unblock := make(chan struct{}) + slowRegistry := func() ([]dbc.Driver, error) { + close(lookupStarted) + <-unblock + return getTestDriverRegistry() + } + + done := make(chan tea.Msg, 1) + go func() { + m := AddCmd{Path: tomlPath, Driver: []string{"test-driver-1>=1.1.0"}}.GetModelCustom( + baseModel{getDriverRegistry: slowRegistry, downloadPkg: downloadTestPkg}, + ) + done <- runTeaCmdToCompletion(t, m.(interface { + Init() tea.Cmd + Update(tea.Msg) (tea.Model, tea.Cmd) + })) + }() + + <-lookupStarted + concurrentContent := "[drivers.test-driver-1]\n" + + "version = '>=1.0.0'\n" + + "[drivers.test-driver-1.source]\n" + + "type = 'registry'\n" + + "url = 'https://registry.example.test/custom'\n" + + "\n[drivers.test-driver-2]\n" + require.NoError(t, os.WriteFile(tomlPath, []byte(concurrentContent), 0o644)) + + close(unblock) + msgOut := <-done + err, ok := msgOut.(error) + require.True(t, ok, "add must reject source drift for the entry it resolved") + assert.ErrorContains(t, err, "driver \"test-driver-1\" source changed while resolving drivers") + + data, readErr := os.ReadFile(tomlPath) + require.NoError(t, readErr) + assert.Equal(t, concurrentContent, string(data), "source drift must leave concurrent contents unchanged") +} + +func TestAddAbortsWhenTargetEntryPresenceChangesDuringRegistryLookup(t *testing.T) { + t.Setenv("DBC_BASE_URL", "") + tests := []struct { + name string + initialContent string + concurrentContent string + }{ + { + name: "concurrent remove with nil source", + initialContent: "[drivers.test-driver-1]\nversion = '>=1.0.0'\n", + concurrentContent: "[drivers]\n", + }, + { + name: "concurrent add with nil source", + initialContent: "[drivers]\n", + concurrentContent: "[drivers.test-driver-1]\nversion = '>=1.0.0'\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + tomlPath := filepath.Join(dir, "dbc.toml") + require.NoError(t, os.WriteFile(tomlPath, []byte(tt.initialContent), 0o644)) + + lookupStarted := make(chan struct{}) + unblock := make(chan struct{}) + slowRegistry := func() ([]dbc.Driver, error) { + close(lookupStarted) + <-unblock + return getTestDriverRegistry() + } + + done := make(chan tea.Msg, 1) + go func() { + m := AddCmd{Path: tomlPath, Driver: []string{"test-driver-1>=1.1.0"}}.GetModelCustom( + baseModel{getDriverRegistry: slowRegistry, downloadPkg: downloadTestPkg}, + ) + done <- runTeaCmdToCompletion(t, m.(interface { + Init() tea.Cmd + Update(tea.Msg) (tea.Model, tea.Cmd) + })) + }() + + <-lookupStarted + require.NoError(t, os.WriteFile(tomlPath, []byte(tt.concurrentContent), 0o644)) + close(unblock) + + msgOut := <-done + err, ok := msgOut.(error) + require.True(t, ok, "add must reject target entry creation/removal during lookup") + assert.ErrorContains(t, err, "entry presence changed") + + data, readErr := os.ReadFile(tomlPath) + require.NoError(t, readErr) + assert.Equal(t, tt.concurrentContent, string(data), "target presence drift must preserve concurrent contents") + }) + } +} + +func TestAddAbortsOnConcurrentInvalidDriverSource(t *testing.T) { + t.Setenv("DBC_BASE_URL", "") + + dir := t.TempDir() + tomlPath := dir + "/dbc.toml" + require.NoError(t, os.WriteFile(tomlPath, []byte("[drivers]\n"), 0o644)) + + lookupStarted := make(chan struct{}) + unblock := make(chan struct{}) + slowRegistry := func() ([]dbc.Driver, error) { + close(lookupStarted) + <-unblock + return getTestDriverRegistry() + } + + done := make(chan tea.Msg, 1) + go func() { + m := AddCmd{Path: tomlPath, Driver: []string{"test-driver-1"}}.GetModelCustom( + baseModel{getDriverRegistry: slowRegistry, downloadPkg: downloadTestPkg}, + ) + done <- runTeaCmdToCompletion(t, m.(interface { + Init() tea.Cmd + Update(tea.Msg) (tea.Model, tea.Cmd) + })) + }() + + <-lookupStarted + + concurrentContent := "[drivers]\n" + + "[drivers.invalid]\n" + + "[drivers.invalid.source]\n" + + "type = 'path'\n" + + "path = '../packages/driver.tar.gz'\n" + + "project = 'github.com/owner/project'\n" + require.NoError(t, os.WriteFile(tomlPath, []byte(concurrentContent), 0o644)) + + close(unblock) + msgOut := <-done + err, ok := msgOut.(error) + require.True(t, ok, "AddCmd must reject an invalid source from the locked re-read") + assert.Contains(t, err.Error(), "error re-reading driver list under lock") + assert.Contains(t, err.Error(), `driver "invalid" source: path source contains fields for another source type`) + + data, readErr := os.ReadFile(tomlPath) + require.NoError(t, readErr) + assert.Equal(t, concurrentContent, string(data), "invalid concurrent content must remain unchanged") +} + // TestAddInitialReadIsAtomicAgainstTornState drives an actual torn-write // scenario: hold the project lock, truncate dbc.toml to invalid TOML, // start dbc add, then restore valid TOML and release. With the initial @@ -1633,4 +1708,4 @@ func TestInstallCmdIgnoresProjectRegistries(t *testing.T) { require.NotNil(t, capturedPkg.Driver.Registry) assert.Equal(t, globalServer.URL, capturedPkg.Driver.Registry.BaseURL.String(), "install must resolve the driver from the global registry, not the project one") -} \ No newline at end of file +} diff --git a/cmd/dbc/remove.go b/cmd/dbc/remove.go index dfa306c4..63d869f2 100644 --- a/cmd/dbc/remove.go +++ b/cmd/dbc/remove.go @@ -95,6 +95,9 @@ func (m removeModel) Init() tea.Cmd { if err := toml.NewDecoder(f).Decode(&m.list); err != nil { return err } + if err := m.list.validateSources(); err != nil { + return err + } m.Driver = strings.TrimSpace(m.Driver) if m.list.Drivers == nil { diff --git a/cmd/dbc/remove_test.go b/cmd/dbc/remove_test.go index f6d8df31..e31eb9ca 100644 --- a/cmd/dbc/remove_test.go +++ b/cmd/dbc/remove_test.go @@ -16,11 +16,51 @@ package main import ( "encoding/json" + "errors" + "os" "path/filepath" + "github.com/columnar-tech/dbc" "github.com/columnar-tech/dbc/internal/jsonschema" ) +func (suite *SubcommandTestSuite) TestRemoveSourceConfiguredDriversWithoutRegistryLookup() { + tests := []struct { + id string + source string + }{ + { + id: "packslip-driver", + source: "version = '1.2.3'\n" + + "[drivers.packslip-driver.source]\ntype = 'packslip'\n" + + "project = 'github.com/example/packslip-driver'\n", + }, + { + id: "path-driver", + source: "[drivers.path-driver.source]\ntype = 'path'\npath = './driver.tgz'\n", + }, + } + registryCalls := 0 + base := testBaseModel() + base.getDriverRegistry = func() ([]dbc.Driver, error) { + registryCalls++ + return nil, errors.New("remove must not query registries") + } + for _, tt := range tests { + suite.Run(tt.id, func() { + path := filepath.Join(suite.tempdir, tt.id+".toml") + contents := "[drivers." + tt.id + "]\n" + tt.source + suite.Require().NoError(os.WriteFile(path, []byte(contents), 0o644)) + out := suite.runCmd(RemoveCmd{Driver: tt.id, Path: path}.GetModelCustom(base)) + suite.Contains(out, "removed '"+tt.id+"' from driver list") + updated, err := os.ReadFile(path) + suite.Require().NoError(err) + suite.NotContains(string(updated), tt.id) + }) + } + suite.Equal(0, registryCalls) +} + func (suite *SubcommandTestSuite) TestRemoveOutput() { m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() suite.runCmd(m) diff --git a/cmd/dbc/search_test.go b/cmd/dbc/search_test.go index 21d7f001..a895e5b8 100644 --- a/cmd/dbc/search_test.go +++ b/cmd/dbc/search_test.go @@ -70,7 +70,7 @@ func (suite *SubcommandTestSuite) TestSearchCmdVerbose() { "Available Versions:\n ╰── 1.0.0\n"+ "• test-driver-no-sig\n Title: Test Driver No Signature\n "+ "Description: Driver manifest missing Files.signature entry\n License: Apache-2.0\n "+ - "Available Versions:\n ╰── 1.0.0\n"+ + "Available Versions:\n ╰── 1.1.0\n"+ "• test-driver-invalid-manifest\n Title: Test Driver Invalid Manifest\n "+ "Description: This is test driver with an invalid manifest. See https://github.com/columnar-tech/dbc/issues/37.\n License: Apache-2.0\n "+ "Available Versions:\n ╰── 1.0.0\n"+ @@ -104,7 +104,7 @@ func (suite *SubcommandTestSuite) TestSearchCmdVerboseWithInstalled() { " Description: Driver manifest missing Files.signature entry\n"+ " License: Apache-2.0\n"+ " Available Versions:\n"+ - " ╰── 1.0.0\n"+ + " ╰── 1.1.0\n"+ "• test-driver-invalid-manifest\n"+ " Title: Test Driver Invalid Manifest\n"+ " Description: This is test driver with an invalid manifest. See https://github.com/columnar-tech/dbc/issues/37.\n"+ @@ -179,7 +179,7 @@ func (suite *SubcommandTestSuite) TestSearchCmdVerboseWithPre() { "Available Versions:\n ╰── 1.0.0\n"+ "• test-driver-no-sig\n Title: Test Driver No Signature\n "+ "Description: Driver manifest missing Files.signature entry\n License: Apache-2.0\n "+ - "Available Versions:\n ╰── 1.0.0\n"+ + "Available Versions:\n ╰── 1.1.0\n"+ "• test-driver-invalid-manifest\n Title: Test Driver Invalid Manifest\n "+ "Description: This is test driver with an invalid manifest. See https://github.com/columnar-tech/dbc/issues/37.\n License: Apache-2.0\n "+ "Available Versions:\n ╰── 1.0.0\n"+ @@ -406,7 +406,7 @@ func (suite *SubcommandTestSuite) TestSearch_JSON() { var result struct { Drivers []jsonschema.SearchDriverBasic `json:"drivers"` - Warning string `json:"warning,omitempty"` + Warning string `json:"warning,omitempty"` } suite.Require().NoError(json.Unmarshal(env.Payload, &result)) suite.NotEmpty(result.Drivers) @@ -425,7 +425,7 @@ func (suite *SubcommandTestSuite) TestSearch_JSON_Verbose() { var result struct { Drivers []jsonschema.SearchDriverVerbose `json:"drivers"` - Warning string `json:"warning,omitempty"` + Warning string `json:"warning,omitempty"` } suite.Require().NoError(json.Unmarshal(env.Payload, &result)) suite.NotEmpty(result.Drivers) diff --git a/cmd/dbc/subcommand_test.go b/cmd/dbc/subcommand_test.go index d5e00d93..3b954cda 100644 --- a/cmd/dbc/subcommand_test.go +++ b/cmd/dbc/subcommand_test.go @@ -162,13 +162,15 @@ func (suite *SubcommandTestSuite) runCmdErr(m tea.Model) string { m = jw.WithJSONWriter(&out) } prog = tea.NewProgram(m, tea.WithInput(nil), tea.WithOutput(&out), - tea.WithoutRenderer(), tea.WithContext(ctx)) + tea.WithoutRenderer(), tea.WithContext(ctx), tea.WithFilter(filterProgramMessage)) defer func() { prog = nil }() var err error + programModel := m m, err = prog.Run() + notifyProgramExited(programModel) prog.Wait() suite.Require().NoError(err) suite.Equal(1, m.(HasStatus).Status(), "The subcommand did not exit with a status of 1 as expected.") @@ -200,13 +202,15 @@ func (suite *SubcommandTestSuite) runCmd(m tea.Model) string { m = jw.WithJSONWriter(&out) } prog = tea.NewProgram(m, tea.WithInput(nil), tea.WithOutput(&out), - tea.WithoutRenderer(), tea.WithContext(ctx)) + tea.WithoutRenderer(), tea.WithContext(ctx), tea.WithFilter(filterProgramMessage)) defer func() { prog = nil }() var err error + programModel := m m, err = prog.Run() + notifyProgramExited(programModel) prog.Wait() suite.Require().NoError(err) suite.Equal(0, m.(HasStatus).Status(), "The command exited with a non-zero status.") diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index ab8337bc..d89978b7 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -15,24 +15,16 @@ package main import ( - "errors" "fmt" "io" - "io/fs" "os" - "path" - "path/filepath" - "strings" - "time" "charm.land/bubbles/v2/progress" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" "github.com/columnar-tech/dbc" "github.com/columnar-tech/dbc/config" "github.com/columnar-tech/dbc/internal/jsonschema" - "github.com/pelletier/go-toml/v2" ) type SyncCmd struct { @@ -51,6 +43,7 @@ func (c SyncCmd) GetModelCustom(baseModel baseModel) tea.Model { NoVerify: c.NoVerify, jsonOutput: c.Json || c.JsonStreamProgress, jsonStreamProgress: c.JsonStreamProgress, + worker: newSyncWorker(), } } @@ -62,6 +55,7 @@ func (c SyncCmd) GetModel() tea.Model { jsonOutput: c.Json || c.JsonStreamProgress, jsonStreamProgress: c.JsonStreamProgress, baseModel: defaultBaseModel(), + worker: newSyncWorker(), } } @@ -69,6 +63,38 @@ func (syncModel) NeedsRenderer() {} func (s syncModel) IsJSONMode() bool { return s.jsonOutput } +// FilterProgramMessage keeps Bubble Tea quit and interrupt messages from +// stopping sync while its worker owns the project lock or prepared archives. +// Other commands do not implement this optional interface and retain their +// existing quit behavior. +func (s syncModel) FilterProgramMessage(msg tea.Msg) tea.Msg { + if s.terminal || s.worker == nil { + return msg + } + cancel := false + switch msg.(type) { + case tea.QuitMsg, tea.InterruptMsg: + cancel = true + case tea.KeyPressMsg: + key := msg.(tea.KeyPressMsg).String() + cancel = key == "ctrl+c" || key == "ctrl+d" || key == "esc" + } + if cancel { + s.worker.cancel() + return nil + } + return msg +} + +// ProgramExited is called by the command runner after Bubble Tea exits through +// a path not handled by FilterProgramMessage. It abandons UI events and joins +// the worker after cancellation and cleanup. +func (s syncModel) ProgramExited() { + if s.worker != nil { + s.worker.abandonProgram() + } +} + func (s syncModel) WithJSONWriter(w io.Writer) tea.Model { s.jsonOut = w return s @@ -98,9 +124,14 @@ func (s syncModel) FinalOutput() string { Installed: installed, Skipped: skipped, Errors: []jsonschema.SyncError{}, + Migration: s.migration, }) } +func syncMigrationMessage(migration jsonschema.SyncMigration) string { + return fmt.Sprintf("Migrated dbc.lock from v%d to v%d. v2 records package archive hashes; verified v1 installed-library checksums are retained as legacy proofs where applicable.", migration.FromVersion, migration.ToVersion) +} + type syncModel struct { baseModel @@ -109,8 +140,9 @@ type syncModel struct { NoVerify bool LockFilePath string // information to write the new lockfile - locked LockFile - cfg config.Config + locked LockFile + writeCandidateLock func(string, LockFile) error + cfg config.Config jsonOutput bool jsonStreamProgress bool @@ -135,461 +167,10 @@ type syncModel struct { skippedDrivers []jsonschema.SyncedDriver // newlyInstalled tracks freshly installed drivers for JSON output newlyInstalled []jsonschema.SyncedDriver + // migration is set only after the worker has persisted a v2 candidate lock. + migration *jsonschema.SyncMigration - jsonOut io.Writer -} - -type driversListMsg struct { - path string - list DriversList -} - -func (s syncModel) Init() tea.Cmd { - return func() tea.Msg { - p, err := filepath.Abs(s.Path) - if err != nil { - return err - } - - if filepath.Ext(p) == "" { - p = filepath.Join(p, "dbc.toml") - } - - lockPath := filepath.Join(filepath.Dir(p), ".dbc.project.lock") - lock, err := acquireLock(lockPath, 10*time.Second) - if err != nil { - return err - } - defer lock.Release() - - drivers, err := loadDriverList(p) - if err != nil { - return err - } - return driversListMsg{ - path: p, - list: drivers, - } - } -} - -func loadDriverList(path string) (DriversList, error) { - list, err := openAndDecodeDriverList(path) - if err != nil { - return DriversList{}, err - } - if len(list.Drivers) == 0 { - return DriversList{}, fmt.Errorf("no drivers found in driver list `%s`", path) - } - return list, nil -} - -type installItem struct { - Driver dbc.Driver - Package dbc.PkgInfo - Checksum string -} - -func (s syncModel) createInstallList(list DriversList) ([]installItem, error) { - // Load the lock file if it exists - lf, err := loadLockFile(s.LockFilePath) - if err != nil && !errors.Is(err, fs.ErrNotExist) { - return nil, err - } - - // construct our list of driver+version to install - var items []installItem - for name, spec := range list.Drivers { - var info lockInfo - if lf.lockinfo != nil { - info = lf.lockinfo[name] - } - - // locate the driver info in the CDN driver registry index - drv, err := findDriver(name, s.driverIndex) - if err != nil { - return nil, wrapWithRegistryContext(err, s.registryErrors) - } - - var pkg dbc.PkgInfo - // if the lockfile specified a version and either the driver list doesn't - // specify a version constraint or the version in the locked file is valid - // for that constraint, then we want to install the version in the lockfile - if info.Version != nil && (spec.Version == nil || spec.Version.Check(info.Version)) { - // install the locked version and verify checksum - pkg, err = drv.GetPackage(info.Version, config.PlatformTuple(), spec.Prerelease == "allow") - } else { - // no locked version or driver list version doesn't match locked file - if spec.Version != nil { - if spec.Prerelease == "allow" { - spec.Version.IncludePrerelease = true - } - pkg, err = drv.GetWithConstraint(spec.Version, config.PlatformTuple()) - } else { - pkg, err = drv.GetPackage(nil, config.PlatformTuple(), spec.Prerelease == "allow") - } - } - - if err != nil { - return nil, err - } - - items = append(items, installItem{ - Driver: drv, - Package: pkg, - Checksum: info.Checksum, - }) - } - return items, nil -} - -type installedDrvMsg struct { - removed *config.DriverInfo - info config.DriverInfo - postInstall []string -} - -type alreadyInstalledDrvMsg struct { - info config.DriverInfo - item installItem -} - -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 { - if item.Package.Version.Equal(drv.Version) { - chksum, err := checksum(drv.Driver.Shared.Get(config.PlatformTuple())) - if err != nil { - return fmt.Errorf("failed to compute checksum: %w", err) - } - - if item.Checksum != "" { - if chksum != item.Checksum { - return fmt.Errorf("checksum mismatch for driver %s: %s != %s", - item.Driver.Path, chksum, item.Checksum) - } - } else { - item.Checksum = chksum - } - - 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 - } - } - } - - // avoid deadlock by doing this in a goroutine rather than during processing the tea.Msg - go func() { - output, err := s.downloadPkg(item.Package) - if err != nil { - prog.Send(fmt.Errorf("failed to download driver: %w", err)) - 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)) - return - } - - output.Seek(0, io.SeekStart) - manifest, err := config.InflateTarball(output, finalDir) - 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)) - return - } - - prog.Send(installedDrvMsg{ - removed: removedDriver, - info: manifest.DriverInfo, - postInstall: manifest.PostInstall.Messages, - }) - }() - return nil - } -} - -func (s syncModel) writeLockFile() error { - f, err := os.Create(s.LockFilePath) - if err != nil { - return fmt.Errorf("failed to create lock file %s: %w", s.LockFilePath, err) - } - defer f.Close() - - s.locked.Version = lockFileVersion - return toml.NewEncoder(f).Encode(s.locked) -} - -func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - s.width, s.height = msg.Width, msg.Height - case spinner.TickMsg: - var cmd tea.Cmd - s.spinner, cmd = s.spinner.Update(msg) - return s, cmd - case progress.FrameMsg: - var cmd tea.Cmd - s.progress, cmd = s.progress.Update(msg) - return s, cmd - case driversListMsg: - s.Path = msg.path - s.LockFilePath = strings.TrimSuffix(s.Path, filepath.Ext(s.Path)) + ".lock" - s.list = msg.list - if err := applyProjectRegistries(s.list); err != nil { - return s, errCmd("%v", err) - } - return s, func() tea.Msg { - drivers, err := s.getDriverRegistry() - // Return both drivers and error - we'll decide how to handle based on whether - // all requested drivers can be found - return driversWithRegistryError{ - drivers: drivers, - err: err, - } - } - case driversWithRegistryError: - s.registryErrors = msg.err - // If we have no drivers and there's an error, fail immediately - if len(msg.drivers) == 0 && msg.err != nil { - return s, errCmd("error getting driver list: %w", msg.err) - } - s.driverIndex = msg.drivers - return s, func() tea.Msg { - items, err := s.createInstallList(s.list) - if err != nil { - return err - } - return items - } - case []dbc.Driver: - // For backwards compatibility, still handle plain driver list - s.driverIndex = msg - return s, func() tea.Msg { - items, err := s.createInstallList(s.list) - if err != nil { - return err - } - return items - } - case []installItem: - s.spinner = spinner.New() - s.progress = progress.New( - progress.WithDefaultBlend(), - progress.WithWidth(40), - progress.WithoutPercentage(), - ) - s.installItems = msg - - if s.jsonStreamProgress { - for _, item := range msg { - s.emitJSON("sync.progress", jsonschema.SyncProgressEvent{ - Phase: "resolving", - Driver: item.Driver.Path, - }) - } - } - - return s, tea.Batch(s.installDriver(s.cfg, s.installItems[s.index]), s.spinner.Tick) - case alreadyInstalledDrvMsg: - s.locked.Drivers = append(s.locked.Drivers, lockInfo{ - Name: msg.info.ID, - Version: msg.info.Version, - Platform: config.PlatformTuple(), - Checksum: msg.item.Checksum, - }) - s.skippedDrivers = append(s.skippedDrivers, jsonschema.SyncedDriver{ - Name: msg.info.ID, - Version: msg.info.Version.String(), - }) - - if s.jsonStreamProgress { - s.emitJSON("sync.progress", jsonschema.SyncProgressEvent{ - Phase: "skipped", - Driver: msg.info.ID, - Version: msg.info.Version.String(), - }) - } - - if s.index >= len(s.installItems)-1 { - s.done = true - if s.jsonOutput { - return s, tea.Sequence( - func() tea.Msg { return s.writeLockFile() }, - tea.Quit) - } - return s, tea.Sequence( - tea.Printf("%s %s-%s already installed", checkMark, msg.info.ID, msg.info.Version), - func() tea.Msg { return s.writeLockFile() }, - tea.Quit) - } - - s.index++ - progressCmd := s.progress.SetPercent(float64(s.index) / float64(len(s.installItems))) - if s.jsonOutput { - return s, tea.Batch( - progressCmd, - s.installDriver(s.cfg, s.installItems[s.index]), - ) - } - return s, tea.Batch( - progressCmd, - tea.Printf("%s %s-%s already installed", checkMark, msg.info.ID, msg.info.Version), - s.installDriver(s.cfg, s.installItems[s.index]), - ) - case installedDrvMsg: - chksum, err := checksum(msg.info.Driver.Shared.Get(config.PlatformTuple())) - if err != nil { - s.status = 1 - if s.jsonOutput { - return s, tea.Sequence(tea.Println(marshalEnvelope("error", jsonschema.ErrorResponse{ - Code: "checksum_failed", - Message: err.Error(), - })), tea.Quit) - } - return s, tea.Sequence(tea.Println("Error: ", err), tea.Quit) - } - s.locked.Drivers = append(s.locked.Drivers, lockInfo{ - Name: msg.info.ID, - Version: msg.info.Version, - Platform: config.PlatformTuple(), - Checksum: chksum, - }) - s.newlyInstalled = append(s.newlyInstalled, jsonschema.SyncedDriver{ - Name: msg.info.ID, - Version: msg.info.Version.String(), - }) - - if s.jsonStreamProgress { - s.emitJSON("sync.progress", jsonschema.SyncProgressEvent{ - Phase: "installed", - Driver: msg.info.ID, - Version: msg.info.Version.String(), - }) - } - - 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 { - printCmd = tea.Sequence( - printCmd, - tea.Printf("%s post-install: %s", checkMark, m), - ) - } - } - } - - if s.index >= len(s.installItems)-1 { - s.done = true - if s.jsonOutput { - return s, tea.Sequence( - func() tea.Msg { return s.writeLockFile() }, - tea.Quit) - } - return s, tea.Sequence( - printCmd, - func() tea.Msg { return s.writeLockFile() }, - tea.Quit) - } - - s.index++ - progressCmd := s.progress.SetPercent(float64(s.index) / float64(len(s.installItems))) - if s.jsonOutput { - return s, tea.Batch( - progressCmd, - s.installDriver(s.cfg, s.installItems[s.index]), - ) - } - return s, tea.Batch( - progressCmd, - printCmd, - s.installDriver(s.cfg, s.installItems[s.index]), - ) - case error: - s.status = 1 - s.err = msg - if s.jsonOutput { - return s, tea.Sequence(tea.Println(marshalEnvelope("error", jsonschema.ErrorResponse{ - Code: "sync_failed", - Message: msg.Error(), - })), tea.Quit) - } - } - - bm, cmd := s.baseModel.Update(msg) - s.baseModel = bm.(baseModel) - - return s, cmd -} - -func (s syncModel) View() tea.View { - if s.status != 0 { - return tea.NewView("") - } - - n := len(s.installItems) - if n == 0 { - return tea.NewView("Determining drivers to install...") - } - w := lipgloss.Width(fmt.Sprintf("%d", n)) - - if s.done { - return tea.NewView("Done!\n") - } - - driverCount := fmt.Sprintf(" %*d/%*d", w, s.index, w, n) - - spin := s.spinner.View() + " " - prog := s.progress.View() - cellsAvail := max(0, s.width-lipgloss.Width(spin+prog+driverCount)) - - driverName := s.installItems[s.index].Driver.Path - info := lipgloss.NewStyle().MaxWidth(cellsAvail).Render("Installing " + driverName) - - cellsRemaining := max(0, s.width-lipgloss.Width(spin+info+prog+driverCount)) - gap := strings.Repeat(" ", max(0, cellsRemaining)) - - return tea.NewView(spin + info + gap + prog + driverCount) + jsonOut io.Writer + worker *syncWorker + terminal bool } diff --git a/cmd/dbc/sync_cancellation_test.go b/cmd/dbc/sync_cancellation_test.go new file mode 100644 index 00000000..863dfc9a --- /dev/null +++ b/cmd/dbc/sync_cancellation_test.go @@ -0,0 +1,505 @@ +// 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 main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/columnar-tech/dbc" + "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/fslock" + "github.com/columnar-tech/dbc/internal/jsonschema" +) + +type syncProgramRun struct { + program *tea.Program + done chan struct{} + model tea.Model + err error + output bytes.Buffer +} + +func startSyncProgram(model syncModel) *syncProgramRun { + return startSyncProgramWithContext(model, nil) +} + +func startSyncProgramWithContext(model syncModel, ctx context.Context) *syncProgramRun { + run := &syncProgramRun{done: make(chan struct{})} + model.jsonOut = &run.output + options := []tea.ProgramOption{tea.WithInput(nil), tea.WithOutput(io.Discard), tea.WithoutRenderer(), tea.WithFilter(filterProgramMessage)} + if ctx != nil { + options = append(options, tea.WithContext(ctx)) + } + run.program = tea.NewProgram(model, options...) + go func() { + run.model, run.err = run.program.Run() + notifyProgramExited(model) + run.program.Wait() + close(run.done) + }() + return run +} + +func waitSyncTestSignal(t *testing.T, signal <-chan struct{}) { + t.Helper() + select { + case <-signal: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for sync worker test signal") + } +} + +func waitSyncProgram(t *testing.T, run *syncProgramRun) syncModel { + t.Helper() + select { + case <-run.done: + case <-time.After(5 * time.Second): + run.program.Send(tea.InterruptMsg{}) + t.Fatal("timed out waiting for sync program to finish") + } + if run.err != nil { + t.Fatalf("sync program returned an error: %v", run.err) + } + model, ok := run.model.(syncModel) + if !ok { + t.Fatalf("unexpected sync model result %T", run.model) + } + return model +} + +func (suite *SubcommandTestSuite) TestSyncCancellationWaitsForWorkerCleanup() { + for _, stage := range []string{"registry", "download", "prepare", "candidate-save", "install"} { + suite.Run(stage, func() { + tmp := suite.T().TempDir() + path := filepath.Join(tmp, "dbc.toml") + lockPath := filepath.Join(tmp, "dbc.lock") + projectLockPath := filepath.Join(tmp, ".dbc.project.lock") + suite.Require().NoError(os.WriteFile(path, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0600)) + suite.Require().NoError(writeLockFileAtomic(lockPath, LockFile{Version: lockFileVersion})) + oldLock, err := os.ReadFile(lockPath) + suite.Require().NoError(err) + + entered := make(chan struct{}) + release := make(chan struct{}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + defer unblock() + var downloadedArchive *os.File + var preparedPayload *config.PreparedPackage + model := SyncCmd{Path: path, NoVerify: true, Json: stage != "prepare", JsonStreamProgress: stage == "prepare"}.GetModelCustom(baseModel{ + getDriverRegistry: func() ([]dbc.Driver, error) { + if stage == "registry" { + close(entered) + <-release + } + return getTestDriverRegistry() + }, + downloadPkg: func(pkg dbc.PkgInfo) (*os.File, error) { + downloadedArchive, err = downloadTestPkg(pkg) + if stage == "download" { + close(entered) + <-release + } + return downloadedArchive, err + }, + }).(syncModel) + model.worker.hooks.duringPrepare = func(_ context.Context, _ int, item installItem) error { + if item.Validation != nil { + preparedPayload = item.Validation.Prepared + } + return nil + } + block := func(context.Context) error { + close(entered) + <-release + return nil + } + switch stage { + case "prepare": + model.worker.hooks.duringPrepare = func(ctx context.Context, _ int, item installItem) error { + if item.Validation != nil { + preparedPayload = item.Validation.Prepared + } + return block(ctx) + } + case "candidate-save": + model.worker.hooks.beforeCandidateSave = block + case "install": + model.worker.hooks.ensurePackage = func(ctx context.Context, cfg config.Config, driver string, expected config.ExpectedPackageMetadata, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + preparedPayload, err = callbacks.Prepare(ctx) + if err != nil { + return config.EnsurePackageResult{}, err + } + close(entered) + <-release + return config.EnsurePackageResult{}, ctx.Err() + } + } + + run := startSyncProgram(model) + waitSyncTestSignal(suite.T(), entered) + run.program.Send(tea.InterruptMsg{}) + waitSyncTestSignal(suite.T(), model.worker.ctx.Done()) + select { + case <-run.done: + suite.FailNow("program exited before the worker completed") + default: + } + select { + case <-model.worker.done: + suite.FailNow("worker exited while its test hook was blocked") + default: + } + var statErr error + switch stage { + case "registry": + suite.Nil(downloadedArchive) + case "download": + _, statErr = downloadedArchive.Stat() + suite.NoError(statErr, "source response remains open while download-stage cancellation is blocked") + case "prepare", "candidate-save", "install": + suite.NotNil(preparedPayload, "package staging completed before the cancellation hook") + } + lockCtx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + _, lockErr := fslock.AcquireContext(lockCtx, projectLockPath) + cancel() + suite.ErrorIs(lockErr, context.DeadlineExceeded, "project lock must stay held until the worker exits") + + unblock() + result := waitSyncProgram(suite.T(), run) + suite.Equal(1, result.Status()) + suite.ErrorIs(result.Err(), context.Canceled) + suite.Empty(result.FinalOutput()) + var lines []string + for _, line := range strings.Split(strings.TrimSpace(run.output.String()), "\n") { + if line != "" { + lines = append(lines, line) + } + } + errorEnvelopes := 0 + for _, line := range lines { + var envelope jsonschema.Envelope + suite.NoError(json.Unmarshal([]byte(line), &envelope), "each JSON progress line must be valid") + if envelope.Kind != "error" { + suite.NotEqual("sync.status", envelope.Kind, "cancel must not produce successful final output") + continue + } + errorEnvelopes++ + var response jsonschema.ErrorResponse + suite.NoError(json.Unmarshal(envelope.Payload, &response)) + suite.Equal("sync_failed", response.Code) + suite.Equal(context.Canceled.Error(), response.Message) + } + suite.Equal(1, errorEnvelopes, "cancel should emit one terminal JSON error envelope") + waitSyncTestSignal(suite.T(), model.worker.done) + if stage == "download" { + assertFileClosed(suite.T(), downloadedArchive) + } + if preparedPayload != nil { + suite.NoError(preparedPayload.Close(), "prepared payload should already have been closed by worker cleanup") + } + assertNoPreparedWorkspaces(suite.T(), suite.Dir()) + projectLock, lockErr := fslock.Acquire(projectLockPath, time.Second) + suite.NoError(lockErr) + if lockErr == nil { + suite.NoError(projectLock.Release()) + } + newLock, err := os.ReadFile(lockPath) + suite.Require().NoError(err) + if stage == "install" { + suite.NotEqual(oldLock, newLock, "candidate lock remains after execution begins") + } else { + suite.Equal(oldLock, newLock, "cancellation before candidate save preserves old lock") + } + _, err = config.GetDriver(config.Config{Level: config.ConfigEnv, Location: suite.Dir()}, "test-driver-1") + suite.Error(err, "test install hook must prevent runtime installation") + }) + } +} + +func (suite *SubcommandTestSuite) TestSyncProgramContextCancellationJoinsWorker() { + tmp := suite.T().TempDir() + path := filepath.Join(tmp, "dbc.toml") + projectLockPath := filepath.Join(tmp, ".dbc.project.lock") + suite.Require().NoError(os.WriteFile(path, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0600)) + entered := make(chan struct{}) + release := make(chan struct{}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + defer unblock() + var archive *os.File + model := SyncCmd{Path: path, NoVerify: true, Json: true}.GetModelCustom(baseModel{ + getDriverRegistry: getTestDriverRegistry, + downloadPkg: func(pkg dbc.PkgInfo) (*os.File, error) { + var err error + archive, err = downloadTestPkg(pkg) + close(entered) + <-release // Deliberately contextless, matching the downloader contract. + return archive, err + }, + }).(syncModel) + programCtx, cancelProgram := context.WithCancel(context.Background()) + run := startSyncProgramWithContext(model, programCtx) + waitSyncTestSignal(suite.T(), entered) + cancelProgram() + waitSyncTestSignal(suite.T(), model.worker.ctx.Done()) + select { + case <-run.done: + suite.FailNow("runner must join the worker before returning") + default: + } + suite.NotNil(archive) + _, err := archive.Stat() + suite.NoError(err, "archive must remain owned until the blocked downloader returns") + lockCtx, cancelLock := context.WithTimeout(context.Background(), 100*time.Millisecond) + _, lockErr := fslock.AcquireContext(lockCtx, projectLockPath) + cancelLock() + suite.ErrorIs(lockErr, context.DeadlineExceeded, "project lock must remain held during cancellation cleanup") + + unblock() + waitSyncTestSignal(suite.T(), run.done) + waitSyncTestSignal(suite.T(), model.worker.done) + suite.ErrorIs(run.err, context.Canceled) + assertFileClosed(suite.T(), archive) + projectLock, err := fslock.Acquire(projectLockPath, time.Second) + suite.NoError(err) + if err == nil { + suite.NoError(projectLock.Release()) + } +} + +func (suite *SubcommandTestSuite) TestSyncCancellationWhileWaitingForProjectLock() { + tmp := suite.T().TempDir() + path := filepath.Join(tmp, "dbc.toml") + projectLockPath := filepath.Join(tmp, ".dbc.project.lock") + suite.Require().NoError(os.WriteFile(path, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0600)) + holder, err := fslock.Acquire(projectLockPath, time.Second) + suite.Require().NoError(err) + var holderReleaseOnce sync.Once + releaseHolder := func() { holderReleaseOnce.Do(func() { _ = holder.Release() }) } + defer releaseHolder() + registryCalls, downloadCalls := atomic.Int32{}, atomic.Int32{} + model := SyncCmd{Path: path, NoVerify: true, Json: true}.GetModelCustom(baseModel{ + getDriverRegistry: func() ([]dbc.Driver, error) { + registryCalls.Add(1) + return getTestDriverRegistry() + }, + downloadPkg: func(pkg dbc.PkgInfo) (*os.File, error) { + downloadCalls.Add(1) + return downloadTestPkg(pkg) + }, + }).(syncModel) + run := startSyncProgram(model) + deadline := time.After(2 * time.Second) + for !model.worker.hasStarted() { + select { + case <-deadline: + suite.FailNow("sync worker did not start") + default: + time.Sleep(time.Millisecond) + } + } + time.Sleep(100 * time.Millisecond) + run.program.Send(tea.InterruptMsg{}) + result := waitSyncProgram(suite.T(), run) + suite.Equal(1, result.Status()) + suite.ErrorIs(result.Err(), context.Canceled) + suite.Zero(registryCalls.Load(), "registry discovery must wait for project lock acquisition") + suite.Zero(downloadCalls.Load()) + waitSyncTestSignal(suite.T(), model.worker.done) + releaseHolder() + projectLock, err := fslock.Acquire(projectLockPath, time.Second) + suite.NoError(err, "canceled lock waiter must not retain the project lock") + if err == nil { + suite.NoError(projectLock.Release()) + } +} + +func (suite *SubcommandTestSuite) TestSyncSerializesSameProjectUntilWorkerFinishes() { + tmp := suite.T().TempDir() + path := filepath.Join(tmp, "dbc.toml") + suite.Require().NoError(os.WriteFile(path, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0600)) + entered := make(chan struct{}) + release := make(chan struct{}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + defer unblock() + var registryCalls atomic.Int32 + first := SyncCmd{Path: path, NoVerify: true, Json: true}.GetModelCustom(baseModel{ + getDriverRegistry: func() ([]dbc.Driver, error) { + registryCalls.Add(1) + return getTestDriverRegistry() + }, + downloadPkg: downloadTestPkg, + }).(syncModel) + first.worker.hooks.beforeCandidateSave = func(context.Context) error { + close(entered) + <-release + return nil + } + firstRun := startSyncProgram(first) + waitSyncTestSignal(suite.T(), entered) + + secondDownloads, secondInstalls := atomic.Int32{}, atomic.Int32{} + second := SyncCmd{Path: path, NoVerify: true, Json: true}.GetModelCustom(baseModel{ + getDriverRegistry: func() ([]dbc.Driver, error) { + registryCalls.Add(1) + return getTestDriverRegistry() + }, + downloadPkg: func(pkg dbc.PkgInfo) (*os.File, error) { + secondDownloads.Add(1) + return downloadTestPkg(pkg) + }, + }).(syncModel) + second.worker.hooks.ensurePackage = func(ctx context.Context, cfg config.Config, driver string, expected config.ExpectedPackageMetadata, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + result, err := config.EnsurePackage(ctx, cfg, driver, expected, callbacks) + if result.Manifest != nil { + secondInstalls.Add(1) + } + return result, err + } + secondRun := startSyncProgram(second) + deadline := time.After(2 * time.Second) + for !second.worker.hasStarted() { + select { + case <-deadline: + suite.FailNow("second sync did not start") + default: + time.Sleep(time.Millisecond) + } + } + time.Sleep(150 * time.Millisecond) + suite.Equal(int32(1), registryCalls.Load(), "second sync must wait before registry discovery") + select { + case <-secondRun.done: + suite.FailNow("second sync exited before acquiring the project lock") + default: + } + + unblock() + firstResult := waitSyncProgram(suite.T(), firstRun) + secondResult := waitSyncProgram(suite.T(), secondRun) + suite.Equal(0, firstResult.Status()) + suite.Equal(0, secondResult.Status()) + suite.Equal(int32(1), registryCalls.Load(), "candidate lock should let the second sync avoid discovery") + suite.Len(secondResult.skippedDrivers, 1, "runtime state must be reloaded after the second sync acquires the lock") + suite.Empty(secondResult.newlyInstalled) + suite.Zero(secondDownloads.Load(), "the second sync should reuse the installed exact artifact") + suite.Zero(secondInstalls.Load(), "the second sync should skip the already-installed driver") +} + +func (suite *SubcommandTestSuite) copyArchiveForSyncTest(source string) string { + suite.T().Helper() + input, err := os.Open(source) + suite.Require().NoError(err) + defer input.Close() + output, err := os.CreateTemp(suite.tempdir, "sync-archive-*.tar.gz") + suite.Require().NoError(err) + _, err = io.Copy(output, input) + suite.Require().NoError(err) + suite.Require().NoError(output.Close()) + return output.Name() +} + +type syncInjectedMessageModel struct { + model syncModel + message tea.Msg + result *syncModel +} + +func (m syncInjectedMessageModel) Init() tea.Cmd { + return func() tea.Msg { return m.message } +} + +func (m syncInjectedMessageModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + updated, cmd := m.model.Update(msg) + if next, ok := updated.(syncModel); ok { + m.model = next + if m.result != nil { + *m.result = next + } + return m, cmd + } + return updated, cmd +} + +func (m syncInjectedMessageModel) View() tea.View { + return m.model.View() +} + +func (m syncInjectedMessageModel) WithJSONWriter(w io.Writer) tea.Model { + m.model.jsonOut = w + return m +} + +func (m syncInjectedMessageModel) Status() int { return m.model.Status() } +func (m syncInjectedMessageModel) Err() error { return m.model.Err() } +func (m syncInjectedMessageModel) FinalOutput() string { + return m.model.FinalOutput() +} +func (m syncInjectedMessageModel) IsJSONMode() bool { return m.model.IsJSONMode() } + +func (suite *SubcommandTestSuite) TestSyncTerminalErrorsUseSingleOutputContract() { + for _, mode := range []string{"plain", "json", "json-stream"} { + suite.Run(mode, func() { + wantErr := errors.New("injected sync failure") + const code = "sync_failed" + message := syncWorkerResultMsg{err: wantErr, code: code} + jsonOutput := mode != "plain" + var result syncModel + model := syncInjectedMessageModel{ + model: syncModel{ + jsonOutput: jsonOutput, + jsonStreamProgress: mode == "json-stream", + }, + message: message, + result: &result, + } + output := suite.runCmdErr(model) + suite.Equal(1, result.Status()) + suite.EqualError(result.Err(), wantErr.Error()) + suite.Empty(result.FinalOutput(), "failure must not produce a success sync.status envelope") + + if !jsonOutput { + suite.Equal("\nError: "+wantErr.Error(), output) + suite.Equal(1, strings.Count(output, wantErr.Error()), "terminal error should be reported once") + return + } + + suite.NotContains(output, "Error:", "JSON output must not contain plaintext error formatting") + lines := strings.Split(strings.TrimSpace(output), "\n") + suite.Len(lines, 1, "failure should produce exactly one terminal JSON envelope") + var envelope jsonschema.Envelope + suite.NoError(json.Unmarshal([]byte(lines[0]), &envelope)) + suite.Equal("error", envelope.Kind) + var response jsonschema.ErrorResponse + suite.NoError(json.Unmarshal(envelope.Payload, &response)) + suite.Equal(code, response.Code) + suite.Equal(wantErr.Error(), response.Message) + }) + } +} diff --git a/cmd/dbc/sync_execution.go b/cmd/dbc/sync_execution.go new file mode 100644 index 00000000..096eb26a --- /dev/null +++ b/cmd/dbc/sync_execution.go @@ -0,0 +1,437 @@ +// 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 main + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "time" + + "charm.land/bubbletea/v2" + "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/fslock" + "github.com/columnar-tech/dbc/internal/jsonschema" + "github.com/columnar-tech/dbc/internal/resolution" + "github.com/columnar-tech/dbc/internal/sourceidentity" + "github.com/columnar-tech/dbc/internal/sourceresolution" +) + +type installedDrvMsg struct { + removed *config.DriverInfo + info config.DriverInfo + postInstall []string + item installItem +} + +type alreadyInstalledDrvMsg struct { + info config.DriverInfo + item installItem +} + +func (s syncModel) writeLockFile() error { + s.locked.Version = lockFileVersion + return s.persistCandidateLock(s.locked) +} + +func (s syncModel) persistCandidateLock(lock LockFile) error { + lock.Version = lockFileVersion + if s.writeCandidateLock != nil { + return s.writeCandidateLock(s.LockFilePath, lock) + } + return writeLockFileAtomic(s.LockFilePath, lock) +} + +func canReuseLockedEntry(item installItem) bool { + selected, err := item.selectedArtifact() + if err != nil || item.LockEntry == nil || item.LockEntry.Version == nil { + return false + } + version, err := semver.NewVersion(item.Release.Version) + if err != nil || !sourceresolution.SameReleaseVersion(sourceidentity.Kind(item.LockEntry.Source.Type), item.LockEntry.Version, version) { + return false + } + source, err := lockSourceForResolvedRelease(item.Release) + if err != nil || !sameLockSourceIdentity(item.LockEntry.Source, source) { + return false + } + locked, err := selectLockedArtifact(*item.LockEntry, item.Platform, false) + if err != nil { + return false + } + return locked.Target == selected.Target && locked.Location == selected.Location && + locked.Hash == selected.Hash && sameLockSize(locked.Size, selected.Size) && + locked.Format == selected.Format && + reflect.DeepEqual(canonicalHostRequirements(locked.HostRequirements), canonicalHostRequirements(lockHostRequirementsFromResolution(selected.HostRequirements))) +} + +func lockSourceForResolvedRelease(release resolution.ResolvedRelease) (lockSource, error) { + source := lockSource{Type: release.Source.Type} + switch release.Source.Type { + case "registry": + source.URL = release.Source.Reference + case "packslip": + source.Project = release.Source.Reference + case "path": + source.Path = release.Source.Reference + default: + return lockSource{}, fmt.Errorf("unsupported resolved source type %q", release.Source.Type) + } + if _, err := lockSourceIdentity(source); err != nil { + return lockSource{}, err + } + return source, nil +} + +func (s syncModel) prepareInstallItems(ctx context.Context, items []installItem) (preparedSyncMsg, error) { + prepared := preparedSyncMsg{items: items, lock: LockFile{Version: lockFileVersion}} + if err := ctx.Err(); err != nil { + return prepared, err + } + if len(prepared.items) == 0 { + return prepared, nil + } + executor, err := s.newPackageExecutor() + if err != nil { + return prepared, err + } + for i := range prepared.items { + if err := ctx.Err(); err != nil { + return prepared, err + } + item := &prepared.items[i] + if err := executor.prepareItem(ctx, item); err != nil { + return prepared, err + } + entry, err := lockEntryForItem(*item) + if err != nil { + return prepared, fmt.Errorf("failed to update lock entry: %w", err) + } + prepared.lock.Drivers = append(prepared.lock.Drivers, entry) + if s.worker != nil && s.worker.hooks.duringPrepare != nil { + if err := s.worker.hooks.duringPrepare(ctx, i, *item); err != nil { + return prepared, err + } + } + } + return prepared, nil +} + +type syncChecksumError struct{ err error } + +func (e syncChecksumError) Error() string { return e.err.Error() } +func (e syncChecksumError) Unwrap() error { return e.err } + +func acquireSyncProjectLock(ctx context.Context, lockPath string) (fslock.Lock, error) { + lock, err := fslock.AcquireContext(ctx, lockPath) + if err == nil { + return lock, nil + } + if errors.Is(ctx.Err(), context.Canceled) { + return fslock.Lock{}, ctx.Err() + } + if errors.Is(err, os.ErrPermission) { + return fslock.Lock{}, fmt.Errorf( + "cannot write to %s: permission denied.\nThis command requires elevated privileges; try %s.", + filepath.Dir(lockPath), elevationHint()) + } + if errors.Is(err, fslock.ErrLockContended) || errors.Is(ctx.Err(), context.DeadlineExceeded) { + return fslock.Lock{}, fmt.Errorf("another dbc operation is in progress: %w: %v", fslock.ErrLockContended, err) + } + return fslock.Lock{}, fmt.Errorf("could not acquire lock in %s: %w", filepath.Dir(lockPath), err) +} + +func (s syncModel) runSyncWorker(worker *syncWorker) (result syncWorkerResultMsg) { + result = syncWorkerResultMsg{code: "sync_failed"} + fail := func(code string, err error) syncWorkerResultMsg { + result.code = code + result.err = err + return result + } + ctx := worker.ctx + path, err := filepath.Abs(s.Path) + if err != nil { + return fail("sync_failed", err) + } + if filepath.Ext(path) == "" { + path = filepath.Join(path, "dbc.toml") + } + lockPath := filepath.Join(filepath.Dir(path), ".dbc.project.lock") + lockCtx, cancelLockWait := context.WithTimeout(ctx, 10*time.Second) + projectLock, err := acquireSyncProjectLock(lockCtx, lockPath) + cancelLockWait() + if err != nil { + if ctx.Err() != nil { + return fail("sync_failed", ctx.Err()) + } + return fail("sync_failed", err) + } + defer projectLock.Release() + if err := ctx.Err(); err != nil { + return fail("sync_failed", err) + } + s.cfg = reloadConfigTarget(s.cfg) + + list, err := loadDriverList(path) + if err != nil { + return fail("sync_failed", err) + } + s.Path = path + s.LockFilePath = strings.TrimSuffix(path, filepath.Ext(path)) + ".lock" + s.list = list + if err := applyProjectRegistries(s.list); err != nil { + return fail("sync_failed", err) + } + if err := ctx.Err(); err != nil { + return fail("sync_failed", err) + } + + planned, needsRegistry, inputLockVersion, err := s.planSyncItemsWithVersion(s.list) + if err != nil { + return fail("sync_failed", fmt.Errorf("failed to inspect lock file: %w", err)) + } + // Keep the version from the snapshot that actually drove planning in the + // worker. The model is copied through Bubble Tea messages, while this value + // must remain tied to the candidate being persisted. + worker.inputLockVersion = inputLockVersion + if needsRegistry { + // Registry clients are currently contextless. The project lock remains + // held until this call returns, after which cancellation is observed. + drivers, registryErr := s.getDriverRegistry() + if err := ctx.Err(); err != nil { + return fail("sync_failed", err) + } + s.registryErrors = registryErr + if len(drivers) == 0 && registryErr != nil { + return fail("sync_failed", fmt.Errorf("error getting driver list: %w", registryErr)) + } + s.driverIndex = drivers + } + + items, err := s.createInstallListContext(ctx, planned) + if err != nil { + return fail("sync_failed", err) + } + if err := ctx.Err(); err != nil { + return fail("sync_failed", err) + } + if !worker.send(ctx, syncItemsMsg{items: items}) { + return fail("sync_failed", ctx.Err()) + } + for _, item := range items { + if !worker.send(ctx, syncResolvingMsg{driver: item.Release.DriverID}) { + return fail("sync_failed", ctx.Err()) + } + } + + prepared, err := s.prepareInstallItems(ctx, items) + defer func() { + if cleanupErr := closePreparedItems(prepared.items); cleanupErr != nil { + result.err = errors.Join(result.err, fmt.Errorf("failed to clean up prepared package workspaces: %w", cleanupErr)) + if result.code == "" { + result.code = "sync_failed" + } + } + }() + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return fail("sync_failed", err) + } + return fail("sync_failed", err) + } + if err := ctx.Err(); err != nil { + return fail("sync_failed", err) + } + if worker.hooks.beforeCandidateSave != nil { + if err := worker.hooks.beforeCandidateSave(ctx); err != nil { + return fail("sync_failed", err) + } + } + if err := ctx.Err(); err != nil { + return fail("sync_failed", err) + } + if err := s.persistCandidateLock(prepared.lock); err != nil { + return fail("sync_failed", fmt.Errorf("failed to write candidate lock file: %w", err)) + } + if worker.inputLockVersion == lockFileVersionV1 { + if !worker.send(ctx, syncLockMigratedMsg{ + fromVersion: lockFileVersionV1, + toVersion: lockFileVersion, + }) { + return fail("sync_failed", ctx.Err()) + } + } + result.lock = prepared.lock + + executor, err := s.newPackageExecutor() + if err != nil { + return fail("sync_failed", err) + } + for i := range prepared.items { + if err := ctx.Err(); err != nil { + return fail("sync_failed", err) + } + item := &prepared.items[i] + ensured, err := executor.ensurePreparedPackage(ctx, item) + if err != nil { + var checksumErr syncChecksumError + if errors.As(err, &checksumErr) { + return fail("checksum_failed", checksumErr) + } + return fail("sync_failed", err) + } + if ensured.Skipped { + if ensured.Installed == nil { + return fail("sync_failed", errors.New("package ensure skipped without an installed registration")) + } + progress := alreadyInstalledDrvMsg{info: *ensured.Installed, item: *item} + progress.item.Archive = nil + if !worker.send(ctx, progress) { + return fail("sync_failed", ctx.Err()) + } + continue + } + if ensured.Manifest == nil || ensured.Installed == nil { + return fail("sync_failed", errors.New("package ensure completed without installation details")) + } + installed := installedDrvMsg{ + removed: ensured.Previous, + info: *ensured.Installed, + postInstall: ensured.Manifest.PostInstall.Messages, + item: *item, + } + installed.item.Archive = nil + if !worker.send(ctx, installed) { + return fail("sync_failed", ctx.Err()) + } + } + if err := ctx.Err(); err != nil { + return fail("sync_failed", err) + } + result.err = nil + result.code = "" + return result +} + +func closePreparedItems(items []installItem) error { + var closeErr error + for i := range items { + if items[i].Archive != nil { + if err := items[i].Archive.Close(); err != nil { + closeErr = errors.Join(closeErr, fmt.Errorf("could not close downloaded artifact: %w", err)) + } + items[i].Archive = nil + } + if items[i].Validation != nil && items[i].Validation.Prepared != nil { + if err := items[i].Validation.Prepared.Close(); err != nil { + closeErr = errors.Join(closeErr, fmt.Errorf("could not close prepared package: %w", err)) + } + items[i].Validation.Prepared = nil + } + } + return closeErr +} + +func syncProgressPercent(index, total int) float64 { + if total <= 0 { + return 0 + } + completed := min(max(index+1, 0), total) + return float64(completed) / float64(total) +} + +func (s syncModel) fail(code string, err error) (syncModel, tea.Cmd) { + s.status = 1 + s.err = err + s.terminal = true + if s.jsonOutput { + s.emitJSON("error", jsonschema.ErrorResponse{ + Code: code, + Message: err.Error(), + }) + return s, tea.Quit + } + return s, tea.Quit +} + +func lockEntryForItem(item installItem) (lockInfo, error) { + if _, err := item.selectedArtifact(); err != nil { + return lockInfo{}, err + } + legacyProofMatches := true + if item.LockEntry != nil && item.LockEntry.Legacy != nil && samePlatformTarget(item.LockEntry.Legacy.Platform, item.Platform) { + if err := verifyLegacyLibraryProof(*item.LockEntry, item.Platform, item.InstalledLibraryHash); err != nil { + if !hasValidatedReplacementEvidence(item) { + return lockInfo{}, err + } + // A mismatched legacy proof cannot be carried into a candidate that + // will replace the old library. The validated archive becomes the new + // v2 artifact evidence instead. + legacyProofMatches = false + } + } + if canReuseLockedEntry(item) && legacyProofMatches { + return *item.LockEntry, nil + } + candidate, err := lockInfoFromResolvedRelease(item.Release.DriverID, item.Release) + if err != nil { + return lockInfo{}, err + } + if item.LockEntry == nil || item.LockEntry.Version == nil || + !sourceresolution.SameReleaseVersion(sourceidentity.Kind(candidate.Source.Type), item.LockEntry.Version, candidate.Version) { + return candidate, nil + } + if len(item.LockEntry.Artifacts) == 0 { + if item.LockEntry.Legacy != nil { + if !legacyProofMatches { + return candidate, nil + } + var verified *VerifiedLegacyLibrary + if samePlatformTarget(item.LockEntry.Legacy.Platform, item.Platform) { + verified = &VerifiedLegacyLibrary{Platform: item.Platform, LibraryHash: item.InstalledLibraryHash} + } + return migrateV1Entry(*item.LockEntry, item.Release, item.Platform, verified) + } + return candidate, nil + } + existing := *item.LockEntry + if !legacyProofMatches { + existing.Legacy = nil + } + return refreshLockEntry(existing, candidate) +} + +func hasValidatedReplacementEvidence(item installItem) bool { + selected, err := item.selectedArtifact() + if err != nil || item.AlreadyInstalled != nil || item.Validation == nil || item.Validation.Prepared == nil || + !item.Validation.Prepared.MatchesExpected(item.Expected) || + selected.Hash == "" || item.Validation.ArchiveSize <= 0 || item.ValidatedLibraryHash == "" { + return false + } + if validateLegacyLibraryHash(item.ValidatedLibraryHash) != nil || + item.Expected.ID != item.Release.DriverID || item.Expected.Version != item.Release.Version || + item.Expected.Platform == "" || item.Expected.Platform != item.Platform || + item.Expected.SourceType != item.Release.Source.Type || item.Expected.SourceIdentity != item.Release.Source.Reference || + item.Expected.ArchiveHash != selected.Hash || + (item.Expected.ArchiveSizePresent && item.Expected.ArchiveSize != item.Validation.ArchiveSize) { + return false + } + return true +} diff --git a/cmd/dbc/sync_execution_test.go b/cmd/dbc/sync_execution_test.go new file mode 100644 index 00000000..a7ab37f0 --- /dev/null +++ b/cmd/dbc/sync_execution_test.go @@ -0,0 +1,746 @@ +// 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 main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc" + "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/resolution" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func (suite *SubcommandTestSuite) TestSyncLegacyLibraryProofUsesValidatedArchiveBeforeInstall() { + path := filepath.Join(suite.tempdir, "dbc.toml") + lockPath := filepath.Join(suite.tempdir, "dbc.lock") + suite.Require().NoError(os.WriteFile(path, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0644)) + archivePath := filepath.Join("testdata", "test-driver-1.tar.gz") + archive, err := os.Open(archivePath) + suite.Require().NoError(err) + expected := config.ExpectedPackageMetadata{ + ID: "test-driver-1", Version: "1.0.0", Platform: config.PlatformTuple(), + SourceType: "registry", SourceIdentity: testRegistry.BaseURL.String(), + } + validation, err := config.PreparePackage(config.Config{Level: suite.configLevel, Location: suite.Dir()}, "test-driver-1", archive, expected, config.InstallOptions{}) + suite.Require().NoError(err) + suite.Require().NotNil(validation.Prepared) + suite.NoError(validation.Prepared.Close()) + suite.NoError(archive.Close()) + libraryHash := strings.TrimPrefix(validation.VerifiedLibraryHash, "sha256:") + legacyLock := fmt.Sprintf("version = 1\n\n[[drivers]]\nname = %q\nversion = %q\nplatform = %q\nchecksum = %q\n", "test-driver-1", "1.0.0", config.PlatformTuple(), libraryHash) + suite.Require().NoError(os.WriteFile(lockPath, []byte(legacyLock), 0644)) + + model := SyncCmd{Path: path, NoVerify: true}.GetModelCustom(testBaseModel()) + suite.runCmd(model) + updated, err := loadLockFile(lockPath) + suite.Require().NoError(err) + entry := updated.lockinfo["test-driver-1"] + suite.Require().NotNil(entry.Legacy) + suite.Equal(libraryHash, entry.Legacy.LibraryHash) +} + +func TestPackageExecutorKeepsSourceArtifactMetadataInLock(t *testing.T) { + for _, test := range []struct { + name string + sourceType string + sourceReference string + }{ + {name: "registry metadata", sourceType: "registry", sourceReference: "https://registry.example.test"}, + {name: "Packslip metadata", sourceType: "packslip", sourceReference: "github.com/example/driver"}, + } { + t.Run(test.name, func(t *testing.T) { + archivePath := filepath.Join(t.TempDir(), "driver.tar.gz") + archive, archiveHash := makeSyncLegacyPackageArchive(t, archivePath, "1.2.3") + size := int64(len(archive)) + target := testTarget(config.PlatformTuple()) + release := resolution.ResolvedRelease{ + DriverID: "test-driver-1", Version: "1.2.3", + Source: resolution.SourceSpec{Type: test.sourceType, Reference: test.sourceReference}, + Artifacts: []resolution.Artifact{{ + Target: target, Format: "tar.gz", + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://assets.example.test/driver.tar.gz"}, + Hash: archiveHash, Size: &size, + }}, + } + before, err := lockInfoFromResolvedRelease(release.DriverID, release) + require.NoError(t, err) + item, err := newInstallItem(release, 0, config.PlatformTuple(), nil) + require.NoError(t, err) + executor := newPackageExecutor(config.Config{Level: config.ConfigEnv, Location: filepath.Join(t.TempDir(), "install")}, t.TempDir(), test.sourceType == "registry", + func(context.Context, dbc.PkgInfo) (io.ReadCloser, error) { return os.Open(archivePath) }, nil, nil, nil) + executor.fetchPackslip = func(context.Context, *url.URL) (io.ReadCloser, error) { return os.Open(archivePath) } + require.NoError(t, executor.prepareItem(context.Background(), &item)) + require.NotNil(t, item.Validation) + require.NoError(t, item.Validation.Prepared.Close()) + after, err := lockEntryForItem(item) + require.NoError(t, err) + require.Len(t, before.Artifacts, 1) + require.Len(t, after.Artifacts, 1) + assert.Equal(t, before.Artifacts[0].Hash, after.Artifacts[0].Hash) + assert.Equal(t, before.Artifacts[0].Size, after.Artifacts[0].Size) + if test.sourceType == "packslip" { + item.Release.Artifacts[0].Hash = "" + item.Release.Artifacts[0].Size = nil + executor.noVerify = true + err := executor.downloadAndPrepareItem(context.Background(), &item) + require.ErrorContains(t, err, "requires an authenticated archive hash") + } + }) + } +} + +func (suite *SubcommandTestSuite) TestSyncLegacyProofDoesNotSkipDifferentCandidateLibrary() { + root := suite.T().TempDir() + suite.T().Setenv("ADBC_DRIVER_PATH", root) + driverListPath := filepath.Join(root, "dbc.toml") + lockPath := filepath.Join(root, "dbc.lock") + suite.Require().NoError(os.WriteFile(driverListPath, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0o644)) + + legacyDirectory := filepath.Join(root, fmt.Sprintf("test-driver-1_%s_v1.1.0", config.PlatformTuple())) + suite.Require().NoError(os.Mkdir(legacyDirectory, 0o755)) + legacyLibrary := filepath.Join(legacyDirectory, "driver.so") + suite.Require().NoError(os.WriteFile(legacyLibrary, []byte("old legacy library"), 0o644)) + oldLibraryHash, err := checksum(legacyLibrary) + suite.Require().NoError(err) + legacyInfo := config.DriverInfo{ + ID: "test-driver-1", Name: "Legacy Test Driver", Version: semver.MustParse("1.1.0"), Source: "dbc", + } + legacyInfo.Driver.Shared.Set(config.PlatformTuple(), legacyLibrary) + suite.Require().NoError(config.CreateManifest(config.Config{Level: config.ConfigEnv, Location: root}, legacyInfo)) + legacyLock := fmt.Sprintf("version = 1\n\n[[drivers]]\nname = %q\nversion = %q\nplatform = %q\nchecksum = %q\n", "test-driver-1", "1.1.0", config.PlatformTuple(), oldLibraryHash) + suite.Require().NoError(os.WriteFile(lockPath, []byte(legacyLock), 0o644)) + + downloadCalls := 0 + model := SyncCmd{Path: driverListPath, NoVerify: true}.GetModelCustom(baseModel{ + getDriverRegistry: getTestDriverRegistry, + downloadPkg: func(pkg dbc.PkgInfo) (*os.File, error) { + downloadCalls++ + return os.Open(filepath.Join("testdata", "test-driver-1.1.tar.gz")) + }, + }) + suite.runCmd(model) + suite.Equal(1, downloadCalls) + + installed, err := config.GetDriver(config.Config{Level: config.ConfigEnv, Location: root}, "test-driver-1") + suite.Require().NoError(err) + newLibrary := installed.Driver.Shared.Get(config.PlatformTuple()) + suite.NotEqual(legacyLibrary, newLibrary) + newLibraryHash, err := checksum(newLibrary) + suite.Require().NoError(err) + suite.NotEqual(oldLibraryHash, newLibraryHash) + updated, err := loadLockFile(lockPath) + suite.Require().NoError(err) + suite.Nil(updated.lockinfo["test-driver-1"].Legacy, "a mismatched v1 proof must not survive replacement by a different candidate library") +} + +func (suite *SubcommandTestSuite) TestSyncLegacyManifestOnlyProofCompatibility() { + type fixture struct { + root, listPath, lockPath, archivePath, proofHash string + oldLock []byte + } + setup := func(t *testing.T, currentPath, candidatePath string, proofData, candidateData []byte) fixture { + t.Helper() + root := t.TempDir() + t.Setenv("ADBC_DRIVER_PATH", root) + driverListPath := filepath.Join(root, "dbc.toml") + lockPath := filepath.Join(root, "dbc.lock") + if err := os.WriteFile(driverListPath, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0o644); err != nil { + t.Fatal(err) + } + writeExternal := func(path string, data []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + } + writeExternal(currentPath, proofData) + if err := os.WriteFile(filepath.Join(filepath.Dir(currentPath), "LICENSE"), []byte("external sibling"), 0o644); err != nil { + t.Fatal(err) + } + if candidatePath != currentPath { + writeExternal(candidatePath, candidateData) + if err := os.WriteFile(filepath.Join(filepath.Dir(candidatePath), "LICENSE"), []byte("candidate sibling"), 0o644); err != nil { + t.Fatal(err) + } + } + current := config.DriverInfo{ + ID: "test-driver-1", Name: "Legacy Shared Driver", Version: semver.MustParse("1.1.0"), Source: "dbc", + } + current.Driver.Entrypoint = "DriverInit" + current.Driver.Shared.Set(config.PlatformTuple(), currentPath) + if err := config.CreateManifest(config.Config{Level: config.ConfigEnv, Location: root}, current); err != nil { + t.Fatal(err) + } + proofFile := filepath.Join(t.TempDir(), "proof-library") + if err := os.WriteFile(proofFile, proofData, 0o600); err != nil { + t.Fatal(err) + } + proofHash, err := checksum(proofFile) + if err != nil { + t.Fatal(err) + } + legacyLock := fmt.Sprintf("version = 1\n\n[[drivers]]\nname = %q\nversion = %q\nplatform = %q\nchecksum = %q\n", "test-driver-1", "1.1.0", config.PlatformTuple(), proofHash) + if err := os.WriteFile(lockPath, []byte(legacyLock), 0o644); err != nil { + t.Fatal(err) + } + archivePath := filepath.Join(t.TempDir(), "manifest-only.tar.gz") + archiveBytes := makeSyncManifestOnlyArchive(t, "1.1.0", candidatePath, "DriverInit") + if err := os.WriteFile(archivePath, archiveBytes, 0o600); err != nil { + t.Fatal(err) + } + return fixture{ + root: root, listPath: driverListPath, lockPath: lockPath, + archivePath: archivePath, proofHash: proofHash, oldLock: []byte(legacyLock), + } + } + + run := func(t *testing.T, driverListPath, archivePath string, count *syncArchiveRunCounts, registry func() ([]dbc.Driver, error)) (string, error) { + t.Helper() + model := SyncCmd{Path: driverListPath, NoVerify: true}.GetModelCustom(baseModel{ + getDriverRegistry: func() ([]dbc.Driver, error) { + count.registry++ + return registry() + }, + downloadPkg: func(dbc.PkgInfo) (*os.File, error) { + count.download++ + return os.Open(archivePath) + }, + }).(syncModel) + model.worker.hooks.ensurePackage = func(ctx context.Context, cfg config.Config, driver string, expected config.ExpectedPackageMetadata, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + result, err := config.EnsurePackage(ctx, cfg, driver, expected, callbacks) + if result.Manifest != nil { + count.install++ + } + return result, err + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + var output bytes.Buffer + program := tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(&output), + tea.WithContext(ctx), tea.WithFilter(filterProgramMessage)) + prog = program + defer func() { prog = nil }() + programModel := tea.Model(model) + finalModel, runErr := program.Run() + notifyProgramExited(programModel) + program.Wait() + if runErr != nil { + return output.String(), runErr + } + status := finalModel.(HasStatus) + var final string + if finalOutput, ok := finalModel.(HasFinalOutput); ok { + final = finalOutput.FinalOutput() + } + if err := status.Err(); err != nil { + jsonMode := false + if mode, ok := finalModel.(interface{ IsJSONMode() bool }); ok { + jsonMode = mode.IsJSONMode() + } + if !jsonMode { + final += "\n" + formatErr(err) + } + } + combined := output.String() + final + if status.Status() != 0 { + return combined, status.Err() + } + return combined, nil + } + + suite.Run("matching migration and locked replay", func() { + t := suite.T() + path := filepath.Join(t.TempDir(), "external", "library.so") + data := []byte("legacy external library") + fixture := setup(t, path, path, data, data) + counts := &syncArchiveRunCounts{} + out, err := run(t, fixture.listPath, fixture.archivePath, counts, getTestDriverRegistry) + suite.NoError(err) + suite.Contains(out, "Migrated dbc.lock from v1 to v2. v2 records package archive hashes; verified v1 installed-library checksums are retained as legacy proofs where applicable.") + suite.Equal(syncArchiveRunCounts{registry: 1, download: 1, install: 0}, *counts) + updated, err := loadLockFile(fixture.lockPath) + suite.Require().NoError(err) + suite.Require().NotNil(updated.lockinfo["test-driver-1"].Legacy) + suite.Equal(fixture.proofHash, updated.lockinfo["test-driver-1"].Legacy.LibraryHash) + installed, err := config.GetDriver(config.Config{Level: config.ConfigEnv, Location: fixture.root}, "test-driver-1") + suite.Require().NoError(err) + suite.Equal(path, installed.Driver.Shared.Get(config.PlatformTuple())) + suite.Equal("DriverInit", installed.Driver.Entrypoint) + installedHash, err := checksum(path) + suite.Require().NoError(err) + suite.Equal(fixture.proofHash, installedHash) + lockAfterMigration, err := os.ReadFile(fixture.lockPath) + suite.Require().NoError(err) + suite.NotEqual(fixture.oldLock, lockAfterMigration) + + replayCounts := &syncArchiveRunCounts{} + _, err = run(t, fixture.listPath, fixture.archivePath, replayCounts, func() ([]dbc.Driver, error) { + return nil, errors.New("locked v2 replay must not discover registry") + }) + suite.NoError(err) + suite.Equal(syncArchiveRunCounts{download: 1}, *replayCounts, "manifest-only locked replay validates its exact archive without registry discovery") + lockAfterReplay, err := os.ReadFile(fixture.lockPath) + suite.Require().NoError(err) + suite.Equal(lockAfterMigration, lockAfterReplay) + }) + + suite.Run("mismatched candidate proof fails before mutation", func() { + t := suite.T() + root := t.TempDir() + currentPath := filepath.Join(root, "external-current", "library.so") + candidatePath := filepath.Join(root, "external-candidate", "library.so") + proofData := []byte("preserved legacy library") + candidateData := []byte("not the legacy library") + fixture := setup(t, currentPath, candidatePath, proofData, candidateData) + beforeManifest, err := os.ReadFile(filepath.Join(fixture.root, "test-driver-1.toml")) + suite.Require().NoError(err) + counts := &syncArchiveRunCounts{} + out, err := run(t, fixture.listPath, fixture.archivePath, counts, getTestDriverRegistry) + suite.Error(err) + suite.Contains(out, "candidate package external library does not match the legacy lock proof") + suite.Equal(1, counts.registry) + suite.Equal(1, counts.download) + suite.Equal(0, counts.install) + lockAfter, err := os.ReadFile(fixture.lockPath) + suite.Require().NoError(err) + suite.Equal(fixture.oldLock, lockAfter) + manifestAfter, err := os.ReadFile(filepath.Join(fixture.root, "test-driver-1.toml")) + suite.Require().NoError(err) + suite.Equal(beforeManifest, manifestAfter) + currentAfter, err := os.ReadFile(currentPath) + suite.Require().NoError(err) + suite.Equal(proofData, currentAfter) + candidateAfter, err := os.ReadFile(candidatePath) + suite.Require().NoError(err) + suite.Equal(candidateData, candidateAfter) + }) + +} + +type syncArchiveRunCounts struct { + registry int + download int + install int +} + +func makeSyncManifestOnlyArchive(t *testing.T, version, sharedPath, entrypoint string) []byte { + t.Helper() + manifest := fmt.Sprintf(`manifest_version = 1 +name = "Legacy Shared Driver" +version = %q + +[Driver] +entrypoint = %q +shared = %q +`, version, entrypoint, sharedPath) + var output bytes.Buffer + gzipWriter := gzip.NewWriter(&output) + tarWriter := tar.NewWriter(gzipWriter) + if err := tarWriter.WriteHeader(&tar.Header{Name: "MANIFEST", Mode: 0o644, Size: int64(len(manifest)), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := tarWriter.Write([]byte(manifest)); err != nil { + t.Fatal(err) + } + if err := tarWriter.Close(); err != nil { + t.Fatal(err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatal(err) + } + return output.Bytes() +} + +func (suite *SubcommandTestSuite) TestSyncPartialRegistryDownloadsEachArchiveOnce() { + path := filepath.Join(suite.tempdir, "dbc.toml") + suite.Require().NoError(os.WriteFile(path, []byte("[drivers]\n[drivers.test-driver-1]\n[drivers.test-driver-no-sig]\n"), 0644)) + downloaded := map[string]int{} + model := SyncCmd{Path: path, Level: suite.configLevel, NoVerify: true}.GetModelCustom(baseModel{ + getDriverRegistry: getTestDriverRegistry, + downloadPkg: func(pkg dbc.PkgInfo) (*os.File, error) { + downloaded[pkg.Driver.Path]++ + return downloadTestPkg(pkg) + }, + }) + suite.runCmd(model) + suite.Equal(map[string]int{"test-driver-1": 1, "test-driver-no-sig": 1}, downloaded) + + locked, err := loadLockFile(strings.TrimSuffix(path, ".toml") + ".lock") + suite.Require().NoError(err) + lockedArtifact, err := selectLockedArtifact(locked.lockinfo["test-driver-1"], config.PlatformTuple(), false) + suite.Require().NoError(err) + suite.NotEmpty(lockedArtifact.Hash, "legacy registry downloads finalize the archive hash") + suite.Nil(lockedArtifact.Size, "host-measured registry size must not be added to the lock") + installed, err := config.GetDriver(config.Config{Level: suite.configLevel, Location: suite.Dir()}, "test-driver-1") + suite.Require().NoError(err) + receipt, managed, present, valid, err := config.InspectDriverInstallReceipt(config.Config{Level: suite.configLevel, Location: suite.Dir()}, installed) + suite.Require().NoError(err) + suite.True(managed && present && valid) + suite.Positive(receipt.ArchiveSize, "the receipt still retains the measured archive size") + +} + +func (suite *SubcommandTestSuite) TestSyncExactLockedArtifactConvergesWithoutRegistryDiscovery() { + path := filepath.Join(suite.tempdir, "dbc.toml") + suite.Require().NoError(os.WriteFile(path, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0644)) + suite.runCmd(SyncCmd{Path: path, NoVerify: true}.GetModelCustom(testBaseModel())) + registryCalls, downloadCalls := 0, 0 + model := SyncCmd{Path: path, NoVerify: true}.GetModelCustom(baseModel{ + getDriverRegistry: func() ([]dbc.Driver, error) { + registryCalls++ + return nil, fmt.Errorf("registry discovery should not run") + }, + downloadPkg: func(dbc.PkgInfo) (*os.File, error) { + downloadCalls++ + return nil, fmt.Errorf("download should not run") + }, + }) + suite.runCmd(model) + suite.Equal(0, registryCalls) + suite.Equal(0, downloadCalls) +} + +func writeSyncReceipt(t *testing.T, path string, receipt config.InstallReceipt) { + t.Helper() + data, err := json.Marshal(receipt) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } +} + +func (suite *SubcommandTestSuite) TestSyncManifestOnlyExternalLibraryWithoutProofRepairsFromExactLock() { + root := suite.T().TempDir() + suite.T().Setenv("ADBC_DRIVER_PATH", root) + driverListPath := filepath.Join(root, "dbc.toml") + suite.Require().NoError(os.WriteFile(driverListPath, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0o644)) + suite.runCmd(SyncCmd{Path: driverListPath, NoVerify: true}.GetModelCustom(testBaseModel())) + + externalDir := filepath.Join(root, "external") + suite.Require().NoError(os.Mkdir(externalDir, 0o755)) + externalLibrary := filepath.Join(externalDir, "driver.so") + suite.Require().NoError(os.WriteFile(externalLibrary, []byte("external library"), 0o644)) + externalInfo := config.DriverInfo{ + ID: "test-driver-1", Name: "External Test Driver", Version: semver.MustParse("1.1.0"), Source: "dbc", + } + externalInfo.Driver.Shared.Set(config.PlatformTuple(), externalLibrary) + suite.Require().NoError(config.CreateManifest(config.Config{Level: config.ConfigEnv, Location: root}, externalInfo)) + + registryCalls, downloadCalls := 0, 0 + model := SyncCmd{Path: driverListPath, NoVerify: true}.GetModelCustom(baseModel{ + getDriverRegistry: func() ([]dbc.Driver, error) { + registryCalls++ + return nil, errors.New("locked replay must not discover registries") + }, + downloadPkg: func(pkg dbc.PkgInfo) (*os.File, error) { + downloadCalls++ + return downloadTestPkg(pkg) + }, + }) + suite.runCmd(model) + suite.Equal(0, registryCalls) + suite.Equal(1, downloadCalls) + got, err := os.ReadFile(externalLibrary) + suite.Require().NoError(err) + suite.Equal("external library", string(got)) + installed, err := config.GetDriver(config.Config{Level: config.ConfigEnv, Location: root}, "test-driver-1") + suite.Require().NoError(err) + managedLibrary := installed.Driver.Shared.Get(config.PlatformTuple()) + suite.NotEqual(externalLibrary, managedLibrary) + receipt, managed, present, valid, err := config.InspectDriverInstallReceipt(config.Config{Level: config.ConfigEnv, Location: root}, installed) + suite.Require().NoError(err) + suite.True(managed) + suite.True(present) + suite.True(valid) + suite.True(config.VerifyInstallReceiptLibraryIntegrity(managedLibrary, receipt)) +} + +func (suite *SubcommandTestSuite) TestSyncManifestOnlyInstallUsesPreparedSnapshotAfterArchiveMutation() { + root := suite.T().TempDir() + suite.T().Setenv("ADBC_DRIVER_PATH", root) + driverListPath := filepath.Join(root, "dbc.toml") + lockPath := filepath.Join(root, "dbc.lock") + externalLibrary := filepath.Join(root, "external", "driver.so") + suite.Require().NoError(os.MkdirAll(filepath.Dir(externalLibrary), 0o755)) + suite.Require().NoError(os.WriteFile(externalLibrary, []byte("external runtime library"), 0o644)) + suite.Require().NoError(os.WriteFile(driverListPath, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0o644)) + archiveBytes := makeSyncManifestOnlyArchive(suite.T(), "1.1.0", externalLibrary, "DriverInit") + makeArchive := func() (*os.File, error) { + archive, err := os.CreateTemp(root, "manifest-only-*.tar.gz") + if err != nil { + return nil, err + } + _, err = archive.Write(archiveBytes) + if err != nil { + _ = archive.Close() + return nil, err + } + _, err = archive.Seek(0, io.SeekStart) + if err != nil { + _ = archive.Close() + return nil, err + } + return archive, nil + } + var preparedArchivePath string + var preparedPackage dbc.PkgInfo + first := SyncCmd{Path: driverListPath, NoVerify: true}.GetModelCustom(baseModel{ + getDriverRegistry: getTestDriverRegistry, + downloadPkg: func(pkg dbc.PkgInfo) (*os.File, error) { + preparedPackage = pkg + archive, err := makeArchive() + if err != nil { + return nil, err + } + preparedArchivePath = archive.Name() + return archive, nil + }, + }).(syncModel) + first.worker.hooks.beforeCandidateSave = func(context.Context) error { + return os.WriteFile(preparedArchivePath, []byte("corrupted after validation"), 0o600) + } + suite.runCmd(first) + suite.Equal("test-driver-1", preparedPackage.Driver.Path) + _, err := os.ReadFile(lockPath) + suite.Require().NoError(err) + locked, err := loadLockFile(lockPath) + suite.Require().NoError(err) + artifact, err := selectLockedArtifact(locked.lockinfo["test-driver-1"], config.PlatformTuple(), false) + suite.Require().NoError(err) + suite.NotEmpty(artifact.Hash) + suite.Nil(artifact.Size, "registry downloads do not add measured size to the lock") + installed, err := config.GetDriver(config.Config{Level: config.ConfigEnv, Location: root}, "test-driver-1") + suite.Require().NoError(err) + suite.Equal(externalLibrary, installed.Driver.Shared.Get(config.PlatformTuple())) + suite.Equal("DriverInit", installed.Driver.Entrypoint) +} + +func (suite *SubcommandTestSuite) TestSyncPathReplayUsesReceiptBeforeOpeningChangedArchive() { + root := suite.T().TempDir() + suite.T().Setenv("ADBC_DRIVER_PATH", root) + projectPath := filepath.Join(root, "dbc.toml") + lockPath := filepath.Join(root, "dbc.lock") + archivePath := filepath.Join(root, "packages", "driver.tar.gz") + declaredPath := "./packages/driver.tar.gz" + suite.Require().NoError(os.MkdirAll(filepath.Dir(archivePath), 0o700)) + makeSyncLegacyPackageArchive(suite.T(), archivePath, "1.2.3") + suite.Require().NoError(os.WriteFile(projectPath, []byte("[drivers.test-driver-1.source]\ntype = 'path'\npath = '"+declaredPath+"'\n"), 0o600)) + + registryCalls := 0 + newModel := func() syncModel { + return SyncCmd{Path: projectPath, NoVerify: true}.GetModelCustom(baseModel{ + getDriverRegistry: func() ([]dbc.Driver, error) { + registryCalls++ + return nil, errors.New("path lock replay must not discover registries") + }, + }).(syncModel) + } + suite.runCmd(newModel()) + suite.Zero(registryCalls) + lockBeforeReplay, err := os.ReadFile(lockPath) + suite.Require().NoError(err) + locked, err := loadLockFile(lockPath) + suite.Require().NoError(err) + entry := locked.lockinfo["test-driver-1"] + suite.Equal("path", entry.Source.Type) + suite.Equal(declaredPath, entry.Source.Path) + suite.Equal("1.2.3", entry.Version.String()) + suite.Require().Len(entry.Artifacts, 1) + suite.Equal(declaredPath, entry.Artifacts[0].Location.Value) + + cfg := config.Config{Level: config.ConfigEnv, Location: root} + installed, err := config.GetDriver(cfg, "test-driver-1") + suite.Require().NoError(err) + libraryPath := installed.Driver.Shared.Get(config.PlatformTuple()) + registrationPath := filepath.Join(root, "test-driver-1.toml") + receiptPath := filepath.Join(filepath.Dir(libraryPath), "dbc-install-receipt.json") + registrationBefore, err := os.ReadFile(registrationPath) + suite.Require().NoError(err) + receiptBefore, err := os.ReadFile(receiptPath) + suite.Require().NoError(err) + libraryBefore, err := os.ReadFile(libraryPath) + suite.Require().NoError(err) + + // A healthy receipt and matching runtime registration allow exact lock replay + // to skip without opening the path artifact, even if it has since changed. + suite.Require().NoError(os.WriteFile(archivePath, []byte("changed archive bytes"), 0o600)) + second := newModel() + second.worker.hooks.duringPrepare = func(_ context.Context, _ int, item installItem) error { + if item.AlreadyInstalled == nil { + return errors.New("valid path receipt should mark the locked package as already installed") + } + if item.Archive != nil { + return errors.New("path artifact was opened despite a valid locked receipt") + } + return nil + } + ensureCalls := 0 + second.worker.hooks.ensurePackage = func(ctx context.Context, cfg config.Config, driver string, expected config.ExpectedPackageMetadata, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + ensureCalls++ + result, err := config.EnsurePackage(ctx, cfg, driver, expected, callbacks) + if err == nil && !result.Skipped { + return result, errors.New("healthy path receipt should skip package installation") + } + return result, err + } + suite.runCmd(second) + suite.Equal(1, ensureCalls) + suite.Zero(registryCalls) + lockAfterReplay, err := os.ReadFile(lockPath) + suite.Require().NoError(err) + suite.Equal(lockBeforeReplay, lockAfterReplay) + registrationAfter, err := os.ReadFile(registrationPath) + suite.Require().NoError(err) + receiptAfter, err := os.ReadFile(receiptPath) + suite.Require().NoError(err) + libraryAfter, err := os.ReadFile(libraryPath) + suite.Require().NoError(err) + suite.Equal(registrationBefore, registrationAfter) + suite.Equal(receiptBefore, receiptAfter) + suite.Equal(libraryBefore, libraryAfter) + + // If repair is needed, the changed path must be reopened and the locked hash + // mismatch must fail before either runtime state or lock state is published. + var receipt config.InstallReceipt + suite.Require().NoError(json.Unmarshal(receiptAfter, &receipt)) + receipt.SourceIdentity = "./packages/changed-driver.tar.gz" + writeSyncReceipt(suite.T(), receiptPath, receipt) + registrationBefore, err = os.ReadFile(registrationPath) + suite.Require().NoError(err) + receiptBefore, err = os.ReadFile(receiptPath) + suite.Require().NoError(err) + libraryBefore, err = os.ReadFile(libraryPath) + suite.Require().NoError(err) + + third := newModel() + ensureCalls = 0 + third.worker.hooks.ensurePackage = func(ctx context.Context, cfg config.Config, driver string, expected config.ExpectedPackageMetadata, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + ensureCalls++ + return config.EnsurePackage(ctx, cfg, driver, expected, callbacks) + } + suite.Contains(suite.runCmdErr(third), "package archive hash mismatch") + suite.Equal(0, ensureCalls, "archive validation must fail before installation") + suite.Zero(registryCalls) + lockAfterFailure, err := os.ReadFile(lockPath) + suite.Require().NoError(err) + suite.Equal(lockBeforeReplay, lockAfterFailure) + registrationAfter, err = os.ReadFile(registrationPath) + suite.Require().NoError(err) + receiptAfterFailure, err := os.ReadFile(receiptPath) + suite.Require().NoError(err) + libraryAfter, err = os.ReadFile(libraryPath) + suite.Require().NoError(err) + suite.Equal(registrationBefore, registrationAfter) + suite.Equal(receiptBefore, receiptAfterFailure) + suite.Equal(libraryBefore, libraryAfter) +} + +func (suite *SubcommandTestSuite) TestSyncPostDownloadReceiptMatchControlsSkip() { + for _, test := range []struct { + name string + mutateReceipt bool + wantInstall int + }{ + {name: "matching receipt skips after validation", wantInstall: 0}, + {name: "mismatching receipt installs repair", mutateReceipt: true, wantInstall: 1}, + } { + suite.Run(test.name, func() { + t := suite.T() + root := t.TempDir() + t.Setenv("ADBC_DRIVER_PATH", root) + driverListPath := filepath.Join(root, "dbc.toml") + lockPath := filepath.Join(root, "dbc.lock") + suite.Require().NoError(os.WriteFile(driverListPath, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0o644)) + suite.runCmd(SyncCmd{Path: driverListPath, NoVerify: true}.GetModelCustom(testBaseModel())) + + cfg := config.Config{Level: config.ConfigEnv, Location: root} + before, err := config.GetDriver(cfg, "test-driver-1") + suite.Require().NoError(err) + beforeLibrary := before.Driver.Shared.Get(config.PlatformTuple()) + if test.mutateReceipt { + receiptPath := filepath.Join(filepath.Dir(beforeLibrary), "dbc-install-receipt.json") + data, err := os.ReadFile(receiptPath) + suite.Require().NoError(err) + var receipt config.InstallReceipt + suite.Require().NoError(json.Unmarshal(data, &receipt)) + receipt.SourceIdentity = "https://different.example" + writeSyncReceipt(t, receiptPath, receipt) + } + suite.Require().NoError(os.Remove(lockPath), "force fresh registry resolution and candidate validation") + + registryCalls, downloadCalls, installCalls := 0, 0, 0 + model := SyncCmd{Path: driverListPath, NoVerify: true}.GetModelCustom(baseModel{ + getDriverRegistry: func() ([]dbc.Driver, error) { + registryCalls++ + return getTestDriverRegistry() + }, + downloadPkg: func(pkg dbc.PkgInfo) (*os.File, error) { + downloadCalls++ + return downloadTestPkg(pkg) + }, + }).(syncModel) + model.worker.hooks.ensurePackage = func(ctx context.Context, cfg config.Config, driver string, expected config.ExpectedPackageMetadata, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + result, err := config.EnsurePackage(ctx, cfg, driver, expected, callbacks) + if result.Manifest != nil { + installCalls++ + } + return result, err + } + suite.runCmd(model) + suite.Equal(1, registryCalls) + suite.Equal(1, downloadCalls) + suite.Equal(test.wantInstall, installCalls) + + after, err := config.GetDriver(cfg, "test-driver-1") + suite.Require().NoError(err) + afterLibrary := after.Driver.Shared.Get(config.PlatformTuple()) + if test.wantInstall == 0 { + suite.Equal(before.FilePath, after.FilePath) + suite.Equal(beforeLibrary, afterLibrary, "a matching receipt must skip without replacing the registered generation") + } else { + suite.NotEqual(beforeLibrary, afterLibrary, "a mismatching receipt must trigger package replacement") + } + receipt, managed, present, valid, err := config.InspectDriverInstallReceipt(cfg, after) + suite.Require().NoError(err) + suite.True(managed) + suite.True(present) + suite.True(valid) + suite.True(config.VerifyInstallReceiptLibraryIntegrity(afterLibrary, receipt)) + suite.Equal(testRegistry.BaseURL.String(), receipt.SourceIdentity) + }) + } +} diff --git a/cmd/dbc/sync_lifecycle_test.go b/cmd/dbc/sync_lifecycle_test.go new file mode 100644 index 00000000..af8af226 --- /dev/null +++ b/cmd/dbc/sync_lifecycle_test.go @@ -0,0 +1,384 @@ +// 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 main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/columnar-tech/dbc" + "github.com/columnar-tech/dbc/internal/fslock" + "github.com/columnar-tech/dbc/internal/jsonschema" +) + +func TestAcquireSyncProjectLockDeadlineIsContentionButCancelIsNot(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), ".dbc.project.lock") + held, err := fslock.Acquire(lockPath, time.Second) + if err != nil { + t.Fatalf("acquire holder lock: %v", err) + } + defer held.Release() + + deadlineCtx, cancelDeadline := context.WithTimeout(context.Background(), 100*time.Millisecond) + _, err = acquireSyncProjectLock(deadlineCtx, lockPath) + cancelDeadline() + if !errors.Is(err, fslock.ErrLockContended) { + t.Fatalf("deadline while waiting for held project lock should be contention, got: %v", err) + } + if !strings.Contains(err.Error(), "another dbc operation is in progress") { + t.Fatalf("expected contention-specific message, got: %v", err) + } + + cancelCtx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = acquireSyncProjectLock(cancelCtx, lockPath) + if !errors.Is(err, context.Canceled) { + t.Fatalf("explicit cancellation should remain context.Canceled, got: %v", err) + } + if errors.Is(err, fslock.ErrLockContended) { + t.Fatalf("explicit cancellation must not be classified as contention: %v", err) + } +} + +func (suite *SubcommandTestSuite) TestSync() { + m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() + suite.runCmd(m) + + m = AddCmd{Path: filepath.Join(suite.tempdir, "dbc.toml"), Driver: []string{"test-driver-1"}}.GetModel() + suite.runCmd(m) + + m = SyncCmd{ + Path: filepath.Join(suite.tempdir, "dbc.toml"), + }.GetModelCustom( + testBaseModel()) + 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")) + + m = SyncCmd{ + Path: filepath.Join(suite.tempdir, "dbc.toml"), + }.GetModelCustom( + testBaseModel()) + suite.validateOutput("✓ test-driver-1-1.1.0 already installed\r\n\rDone!\r\n", "", suite.runCmd(m)) +} + +func (suite *SubcommandTestSuite) TestSyncWithVersion() { + tests := []struct { + driver string + expectedVersion string + }{ + {"test-driver-1=1.0.0", "1.0.0"}, + {"test-driver-1<=1.0.0", "1.0.0"}, + {"test-driver-1<1.1.0", "1.0.0"}, + {"test-driver-1~1.0", "1.0.0"}, + {"test-driver-1^1.0", "1.1.0"}, + } + + for _, tt := range tests { + suite.Run(tt.driver, func() { + m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() + suite.runCmd(m) + + m = AddCmd{Path: filepath.Join(suite.tempdir, "dbc.toml"), Driver: []string{tt.driver}}.GetModel() + suite.runCmd(m) + + m = SyncCmd{ + Path: filepath.Join(suite.tempdir, "dbc.toml"), + }.GetModelCustom( + testBaseModel()) + suite.validateOutput("✓ test-driver-1-"+tt.expectedVersion+"\r\n\rDone!\r\n", "", suite.runCmd(m)) + suite.FileExists(filepath.Join(suite.tempdir, "test-driver-1.toml")) + suite.FileExists(filepath.Join(suite.tempdir, "dbc.lock")) + + for _, f := range suite.getFilesInTempDir() { + os.Remove(filepath.Join(suite.tempdir, f)) + } + }) + } +} + +func (suite *SubcommandTestSuite) TestSyncVirtualEnv() { + suite.T().Setenv("ADBC_DRIVER_PATH", "") + + m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() + suite.runCmd(m) + + m = AddCmd{Path: filepath.Join(suite.tempdir, "dbc.toml"), Driver: []string{"test-driver-1"}}.GetModel() + suite.runCmd(m) + + suite.T().Setenv("VIRTUAL_ENV", suite.tempdir) + + m = SyncCmd{ + Path: filepath.Join(suite.tempdir, "dbc.toml"), + }.GetModelCustom( + testBaseModel()) + suite.validateOutput("✓ test-driver-1-1.1.0\r\n\rDone!\r\n", "", suite.runCmd(m)) + suite.FileExists(filepath.Join(suite.tempdir, "etc", "adbc", "drivers", "test-driver-1.toml")) + + m = SyncCmd{ + Path: filepath.Join(suite.tempdir, "dbc.toml"), + }.GetModelCustom( + testBaseModel()) + suite.validateOutput("✓ test-driver-1-1.1.0 already installed\r\n\rDone!\r\n", "", suite.runCmd(m)) +} + +func (suite *SubcommandTestSuite) TestSyncCondaPrefix() { + suite.T().Setenv("ADBC_DRIVER_PATH", "") + + m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() + suite.runCmd(m) + + m = AddCmd{Path: filepath.Join(suite.tempdir, "dbc.toml"), Driver: []string{"test-driver-1"}}.GetModel() + suite.runCmd(m) + + suite.T().Setenv("CONDA_PREFIX", suite.tempdir) + + m = SyncCmd{ + Path: filepath.Join(suite.tempdir, "dbc.toml"), + }.GetModelCustom( + testBaseModel()) + suite.validateOutput("✓ test-driver-1-1.1.0\r\n\rDone!\r\n", "", suite.runCmd(m)) + suite.FileExists(filepath.Join(suite.tempdir, "etc", "adbc", "drivers", "test-driver-1.toml")) + + m = SyncCmd{ + Path: filepath.Join(suite.tempdir, "dbc.toml"), + }.GetModelCustom( + testBaseModel()) + suite.validateOutput("✓ test-driver-1-1.1.0 already installed\r\n\rDone!\r\n", "", suite.runCmd(m)) +} + +func (suite *SubcommandTestSuite) TestSyncInstallFailSig() { + m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() + suite.runCmd(m) + + m = AddCmd{Path: filepath.Join(suite.tempdir, "dbc.toml"), Driver: []string{"test-driver-no-sig"}}.GetModel() + suite.runCmd(m) + + m = SyncCmd{ + 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()) +} + +func (suite *SubcommandTestSuite) TestSyncInstallNoVerify() { + m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() + suite.runCmd(m) + + m = AddCmd{Path: filepath.Join(suite.tempdir, "dbc.toml"), Driver: []string{"test-driver-no-sig"}}.GetModel() + suite.runCmd(m) + + m = SyncCmd{ + Path: filepath.Join(suite.tempdir, "dbc.toml"), + NoVerify: true, + }.GetModelCustom( + testBaseModel()) + suite.validateOutput("✓ test-driver-no-sig-1.1.0\r\n\rDone!\r\n", "", suite.runCmd(m)) +} + +func (suite *SubcommandTestSuite) TestSyncPartialRegistryFailure() { + // Initialize driver list + m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() + suite.runCmd(m) + + m = AddCmd{Path: filepath.Join(suite.tempdir, "dbc.toml"), Driver: []string{"test-driver-1"}}.GetModel() + suite.runCmd(m) + + // Test that sync command handles partial registry failure gracefully + // (one registry succeeds, another fails - returns both drivers and error) + partialFailingRegistry := func() ([]dbc.Driver, error) { + // Get drivers from the test registry (simulating one successful registry) + drivers, _ := getTestDriverRegistry() + // But also return an error (simulating another registry that failed) + return drivers, fmt.Errorf("registry https://backup-registry.example.com: failed to fetch driver registry: network timeout") + } + + // Should succeed if the requested driver is found in the available drivers + m = SyncCmd{ + Path: filepath.Join(suite.tempdir, "dbc.toml"), + }.GetModelCustom( + baseModel{getDriverRegistry: partialFailingRegistry, downloadPkg: downloadTestPkg}) + + // Should install successfully without printing the registry error + 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")) +} + +func (suite *SubcommandTestSuite) TestSyncPartialRegistryFailureDriverNotFound() { + // Initialize driver list with a driver that doesn't exist + m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() + suite.runCmd(m) + + // Manually create a driver list with a nonexistent driver + err := os.WriteFile(filepath.Join(suite.tempdir, "dbc.toml"), []byte(`# dbc driver list +[drivers] +[drivers.nonexistent-driver] +`), 0644) + suite.Require().NoError(err) + + // Test that sync command shows registry errors when the requested driver is not found + partialFailingRegistry := func() ([]dbc.Driver, error) { + // Get drivers from the test registry (simulating one successful registry) + drivers, _ := getTestDriverRegistry() + // But also return an error (simulating another registry that failed) + return drivers, fmt.Errorf("registry https://backup-registry.example.com: failed to fetch driver registry: network timeout") + } + + // Should fail with enhanced error message if the requested driver is not found + m = SyncCmd{ + Path: filepath.Join(suite.tempdir, "dbc.toml"), + }.GetModelCustom( + baseModel{getDriverRegistry: partialFailingRegistry, downloadPkg: downloadTestPkg}) + + out := suite.runCmdErr(m) + // Should show the driver not found error AND the registry error + suite.Contains(out, "driver `nonexistent-driver` not found") + suite.Contains(out, "Note: Some driver registries were unavailable") + suite.Contains(out, "failed to fetch driver registry") + suite.Contains(out, "network timeout") +} + +func (suite *SubcommandTestSuite) TestSyncWithProjectRegistries() { + m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() + suite.runCmd(m) + + err := os.WriteFile(filepath.Join(suite.tempdir, "dbc.toml"), []byte(`# dbc driver list +[[registries]] +url = 'https://custom-registry.example.com' +name = 'custom' + +[drivers] +[drivers.test-driver-1] +`), 0644) + suite.Require().NoError(err) + + m = SyncCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModelCustom(testBaseModel()) + 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")) + + if os.Getenv("DBC_BASE_URL") == "" { + suite.Require().NotNil(dbcClient) + found := false + for _, r := range dbcClient.Registries() { + if r.BaseURL != nil && r.BaseURL.String() == "https://custom-registry.example.com" { + found = true + break + } + } + suite.True(found, "expected custom registry in active client registries after sync with [[registries]] in dbc.toml") + } +} + +func (suite *SubcommandTestSuite) TestSyncWithProjectRegistriesBackwardCompat() { + m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() + suite.runCmd(m) + + m = AddCmd{Path: filepath.Join(suite.tempdir, "dbc.toml"), Driver: []string{"test-driver-1"}}.GetModel() + suite.runCmd(m) + + m = SyncCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModelCustom(testBaseModel()) + 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")) +} + +func (suite *SubcommandTestSuite) TestSyncCompleteRegistryFailure() { + // Initialize driver list + m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() + suite.runCmd(m) + + m = AddCmd{Path: filepath.Join(suite.tempdir, "dbc.toml"), Driver: []string{"test-driver-1"}}.GetModel() + suite.runCmd(m) + + // Test that sync command handles complete registry failure (no drivers returned) + completeFailingRegistry := func() ([]dbc.Driver, error) { + return nil, fmt.Errorf("registry https://primary-registry.example.com: connection refused") + } + + m = SyncCmd{ + Path: filepath.Join(suite.tempdir, "dbc.toml"), + }.GetModelCustom( + baseModel{getDriverRegistry: completeFailingRegistry, downloadPkg: downloadTestPkg}) + + out := suite.runCmdErr(m) + suite.Contains(out, "connection refused") +} + +func (suite *SubcommandTestSuite) TestSync_JSONStream() { + tmpDir := suite.T().TempDir() + driverListPath := filepath.Join(tmpDir, "dbc.toml") + err := os.WriteFile(driverListPath, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0644) + suite.Require().NoError(err) + + m := SyncCmd{Path: driverListPath, Json: true}. + GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) + out := suite.runCmd(m) + + lines := strings.Split(strings.TrimSpace(out), "\n") + suite.Greater(len(lines), 0, "expected at least one NDJSON line") + + var lastEnv jsonschema.Envelope + for _, line := range lines { + if line == "" { + continue + } + var env jsonschema.Envelope + suite.Require().NoError(json.Unmarshal([]byte(line), &env), "line must be valid JSON: %s", line) + suite.Equal(1, env.SchemaVersion) + lastEnv = env + } + suite.Equal("sync.status", lastEnv.Kind) + + var status jsonschema.SyncStatus + suite.Require().NoError(json.Unmarshal(lastEnv.Payload, &status)) + suite.Len(status.Installed, 1) + suite.Equal("test-driver-1", status.Installed[0].Name) +} + +func (suite *SubcommandTestSuite) TestSync_JSONProgressStream() { + tmpDir := suite.T().TempDir() + driverListPath := filepath.Join(tmpDir, "dbc.toml") + err := os.WriteFile(driverListPath, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0644) + suite.Require().NoError(err) + + m := SyncCmd{Path: driverListPath, JsonStreamProgress: true}. + GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) + out := suite.runCmd(m) + + lines := strings.Split(strings.TrimSpace(out), "\n") + suite.Greater(len(lines), 1, "expected multiple NDJSON lines") + + var kinds []string + for _, line := range lines { + if line == "" { + continue + } + var env jsonschema.Envelope + suite.Require().NoError(json.Unmarshal([]byte(line), &env), "line must be valid JSON: %s", line) + suite.Equal(1, env.SchemaVersion) + kinds = append(kinds, env.Kind) + } + + suite.Contains(kinds, "sync.progress") + suite.Equal("sync.status", kinds[len(kinds)-1]) +} diff --git a/cmd/dbc/sync_migration_test.go b/cmd/dbc/sync_migration_test.go new file mode 100644 index 00000000..d9c4cc9d --- /dev/null +++ b/cmd/dbc/sync_migration_test.go @@ -0,0 +1,337 @@ +// 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 main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "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/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSyncMigrationNotificationIsStructuredAndDeferredForJSON(t *testing.T) { + var out bytes.Buffer + worker := newSyncWorker() + model := syncModel{ + jsonOutput: true, + jsonOut: &out, + worker: worker, + } + updated, _ := model.Update(syncLockMigratedMsg{fromVersion: lockFileVersionV1, toVersion: lockFileVersion}) + got := updated.(syncModel) + require.NotNil(t, got.migration) + assert.Equal(t, &jsonschema.SyncMigration{FromVersion: 1, ToVersion: 2}, got.migration) + assert.Empty(t, out.String(), "--json keeps migration in the final status envelope") + + var status jsonschema.SyncStatus + var envelope jsonschema.Envelope + require.NoError(t, json.Unmarshal([]byte(got.FinalOutput()), &envelope)) + require.NoError(t, json.Unmarshal(envelope.Payload, &status)) + require.NotNil(t, status.Migration) + assert.Equal(t, 1, status.Migration.FromVersion) + assert.Equal(t, 2, status.Migration.ToVersion) +} + +func TestSyncMigrationNotificationStreamsOnlyInProgressMode(t *testing.T) { + var out bytes.Buffer + worker := newSyncWorker() + model := syncModel{ + jsonOutput: true, + jsonStreamProgress: true, + jsonOut: &out, + worker: worker, + } + updated, _ := model.Update(syncLockMigratedMsg{fromVersion: lockFileVersionV1, toVersion: lockFileVersion}) + got := updated.(syncModel) + var envelope jsonschema.Envelope + require.NoError(t, json.Unmarshal(bytes.TrimSpace(out.Bytes()), &envelope)) + assert.Equal(t, "sync.migration", envelope.Kind) + assert.NotNil(t, got.migration) +} + +func (suite *SubcommandTestSuite) TestSyncPrepareFailurePreservesRuntimeAndLock() { + path := filepath.Join(suite.tempdir, "dbc.toml") + lockPath := filepath.Join(suite.tempdir, "dbc.lock") + err := os.WriteFile(path, []byte("[drivers]\n[drivers.test-driver-1]\n[drivers.test-driver-manifest-only]\n"), 0644) + suite.Require().NoError(err) + + // Keep a prior runtime installation and lock snapshot to verify that a + // later driver's prepare failure does not partially apply the sync. + cfg := config.Config{Level: config.ConfigEnv, Location: suite.Dir()} + archive, err := os.Open(filepath.Join("testdata", "test-driver-1.tar.gz")) + suite.Require().NoError(err) + _, err = config.InstallPackage(cfg, "test-driver-1", archive, config.ExpectedPackageMetadata{ + ID: "test-driver-1", Version: "1.0.0", Platform: config.PlatformTuple(), + SourceType: "registry", SourceIdentity: testRegistry.BaseURL.String(), + }, config.InstallOptions{}) + suite.Require().NoError(err) + suite.NoError(archive.Close()) + suite.Require().NoError(writeLockFileAtomic(lockPath, LockFile{Version: lockFileVersion})) + oldLock, err := os.ReadFile(lockPath) + suite.Require().NoError(err) + + downloaded := map[string]int{} + downloadCalls := 0 + archives := map[string]*os.File{} + model := SyncCmd{Path: path, NoVerify: true}.GetModelCustom(baseModel{ + getDriverRegistry: getTestDriverRegistry, + downloadPkg: func(pkg dbc.PkgInfo) (*os.File, error) { + downloaded[pkg.Driver.Path]++ + downloadCalls++ + archive, err := downloadTestPkg(pkg) + archives[pkg.Driver.Path] = archive + return archive, err + }, + }).(syncModel) + model.worker.hooks.duringPrepare = func(_ context.Context, index int, _ installItem) error { + if index == 1 { + return errors.New("injected second driver prepare failure") + } + return nil + } + output := suite.runCmdErr(model) + suite.Contains(output, "injected second driver prepare failure") + + installed, err := config.GetDriver(cfg, "test-driver-1") + suite.Require().NoError(err) + suite.Equal("1.0.0", installed.Version.String()) + _, err = config.GetDriver(cfg, "test-driver-manifest-only") + suite.Error(err) + newLock, err := os.ReadFile(lockPath) + suite.Require().NoError(err) + suite.Equal(oldLock, newLock) + suite.Equal(2, len(downloaded)) + suite.Equal(2, downloadCalls) + for _, archive := range archives { + assertFileClosed(suite.T(), archive) + } +} + +func (suite *SubcommandTestSuite) TestSyncCandidateLockFailureDoesNotInstall() { + path := filepath.Join(suite.tempdir, "dbc.toml") + lockPath := filepath.Join(suite.tempdir, "dbc.lock") + suite.Require().NoError(os.WriteFile(path, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0644)) + suite.Require().NoError(writeLockFileAtomic(lockPath, LockFile{Version: lockFileVersion})) + oldLock, err := os.ReadFile(lockPath) + suite.Require().NoError(err) + downloadCount := 0 + var downloadedArchive *os.File + model := SyncCmd{Path: path, NoVerify: true}.GetModelCustom(baseModel{ + getDriverRegistry: getTestDriverRegistry, + downloadPkg: func(pkg dbc.PkgInfo) (*os.File, error) { + downloadCount++ + downloadedArchive, err = downloadTestPkg(pkg) + return downloadedArchive, err + }, + }).(syncModel) + model.writeCandidateLock = func(string, LockFile) error { return fmt.Errorf("injected lock failure") } + suite.runCmdErr(model) + suite.Equal(1, downloadCount) + _, err = config.GetDriver(config.Config{Level: config.ConfigEnv, Location: suite.Dir()}, "test-driver-1") + suite.Error(err) + newLock, err := os.ReadFile(lockPath) + suite.Require().NoError(err) + suite.Equal(oldLock, newLock) + assertFileClosed(suite.T(), downloadedArchive) + assertNoPreparedWorkspaces(suite.T(), suite.Dir()) +} + +func (suite *SubcommandTestSuite) TestSyncLockMigrationNotificationFollowsCandidatePersist() { + path := filepath.Join(suite.tempdir, "dbc.toml") + lockPath := filepath.Join(suite.tempdir, "dbc.lock") + suite.Require().NoError(os.WriteFile(path, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0o644)) + legacyLock := "version = 1\n\n[[drivers]]\nname = \"test-driver-1\"\nversion = \"1.0.0\"\n" + suite.Require().NoError(os.WriteFile(lockPath, []byte(legacyLock), 0o644)) + runPlain := func(model tea.Model) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + var output bytes.Buffer + program := tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(&output), tea.WithContext(ctx), tea.WithFilter(filterProgramMessage)) + prog = program + defer func() { prog = nil }() + programModel := tea.Model(model) + finalModel, runErr := program.Run() + notifyProgramExited(programModel) + program.Wait() + if runErr != nil { + return output.String(), runErr + } + status := finalModel.(HasStatus) + if err := status.Err(); err != nil { + return output.String() + "\n" + formatErr(err), err + } + return output.String(), nil + } + + model := SyncCmd{Path: path, NoVerify: true}.GetModelCustom(testBaseModel()) + first, err := runPlain(model) + suite.Require().NoError(err) + suite.Equal(1, strings.Count(first, "Migrated dbc.lock from v1 to v2.")) + + // The successful migration leaves a v2 lock behind. Replaying it must not + // produce another migration notification for the same lockfile. + model = SyncCmd{Path: path, NoVerify: true}.GetModelCustom(testBaseModel()) + second, err := runPlain(model) + suite.Require().NoError(err) + suite.NotContains(second, "Migrated dbc.lock from v1 to v2.") + + // A failed candidate persist must not claim that migration happened, even + // though the input lock was v1. + suite.Require().NoError(os.WriteFile(lockPath, []byte(legacyLock), 0o644)) + failureModel := SyncCmd{Path: path, NoVerify: true}.GetModelCustom(testBaseModel()).(syncModel) + failureModel.writeCandidateLock = func(string, LockFile) error { return errors.New("injected candidate save failure") } + failure := suite.runCmdErr(failureModel) + suite.NotContains(failure, "Migrated dbc.lock from v1 to v2.") +} + +func (suite *SubcommandTestSuite) TestSyncLockMigrationNotificationSurvivesInstallFailure() { + path := filepath.Join(suite.tempdir, "dbc.toml") + lockPath := filepath.Join(suite.tempdir, "dbc.lock") + suite.Require().NoError(os.WriteFile(path, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0o644)) + suite.Require().NoError(os.WriteFile(lockPath, []byte("version = 1\n\n[[drivers]]\nname = \"test-driver-1\"\nversion = \"1.0.0\"\n"), 0o644)) + + model := SyncCmd{Path: path, NoVerify: true}.GetModelCustom(testBaseModel()).(syncModel) + model.worker.hooks.ensurePackage = func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + return config.EnsurePackageResult{}, errors.New("injected install failure") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + var output bytes.Buffer + program := tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(&output), tea.WithContext(ctx), tea.WithFilter(filterProgramMessage)) + prog = program + defer func() { prog = nil }() + programModel := tea.Model(model) + finalModel, runErr := program.Run() + notifyProgramExited(programModel) + program.Wait() + suite.Require().NoError(runErr) + suite.Equal(1, finalModel.(HasStatus).Status()) + out := output.String() + suite.Equal(1, strings.Count(out, "Migrated dbc.lock from v1 to v2.")) + lock, err := loadLockFile(lockPath) + suite.Require().NoError(err) + suite.Equal(lockFileVersion, lock.Version, "candidate lock was persisted before install failure") +} + +func (suite *SubcommandTestSuite) TestSyncInstallFailureKeepsCompleteCandidateLock() { + path := filepath.Join(suite.tempdir, "dbc.toml") + lockPath := filepath.Join(suite.tempdir, "dbc.lock") + suite.Require().NoError(os.WriteFile(path, []byte("[drivers]\n[drivers.test-driver-1]\n[drivers.test-driver-no-sig]\n"), 0644)) + var downloadedDrivers []string + var downloadedArchives []*os.File + model := SyncCmd{Path: path, Level: suite.configLevel, NoVerify: true}.GetModelCustom(baseModel{ + getDriverRegistry: getTestDriverRegistry, + downloadPkg: func(pkg dbc.PkgInfo) (*os.File, error) { + var source string + switch pkg.Driver.Path { + case "test-driver-1": + source = filepath.Join("testdata", "test-driver-1.1.tar.gz") + case "test-driver-no-sig": + source = filepath.Join("testdata", "test-driver-no-sig.tar.gz") + default: + return nil, fmt.Errorf("unexpected driver %q", pkg.Driver.Path) + } + copyPath := suite.copyArchiveForSyncTest(source) + downloadedDrivers = append(downloadedDrivers, pkg.Driver.Path) + archive, err := os.Open(copyPath) + if err == nil { + downloadedArchives = append(downloadedArchives, archive) + } + return archive, err + }, + }).(syncModel) + ensureCalls := 0 + model.worker.hooks.ensurePackage = func(ctx context.Context, cfg config.Config, runtimeID string, expected config.ExpectedPackageMetadata, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + ensureCalls++ + if ensureCalls == 2 { + return config.EnsurePackageResult{}, errors.New("injected package ensure failure") + } + return config.EnsurePackage(ctx, cfg, runtimeID, expected, callbacks) + } + suite.runCmdErr(model) + + lock, err := loadLockFile(lockPath) + suite.Require().NoError(err) + suite.Len(lock.lockinfo, 2) + installedCount := 0 + for _, name := range []string{"test-driver-1", "test-driver-no-sig"} { + if _, err := config.GetDriver(config.Get()[suite.configLevel], name); err == nil { + installedCount++ + } + } + suite.Equal(1, installedCount) + suite.Require().Len(downloadedDrivers, 2) + suite.Equal(2, ensureCalls) + suite.Require().Len(downloadedArchives, 2) + for _, archive := range downloadedArchives { + assertFileClosed(suite.T(), archive) + } + failedDriver := downloadedDrivers[len(downloadedDrivers)-1] + lockedCandidate, err := os.ReadFile(lockPath) + suite.Require().NoError(err) + candidate, err := loadLockFile(lockPath) + suite.Require().NoError(err) + lockedEntry := candidate.lockinfo[failedDriver] + lockedArtifact, err := selectLockedArtifact(lockedEntry, config.PlatformTuple(), false) + suite.Require().NoError(err) + + registryCalls, downloadCalls := 0, 0 + var observedPackage dbc.PkgInfo + convergenceModel := SyncCmd{Path: path, Level: suite.configLevel, NoVerify: true}.GetModelCustom(baseModel{ + getDriverRegistry: func() ([]dbc.Driver, error) { + registryCalls++ + return nil, fmt.Errorf("registry discovery should not run after candidate lock creation") + }, + downloadPkg: func(pkg dbc.PkgInfo) (*os.File, error) { + downloadCalls++ + observedPackage = pkg + switch pkg.Driver.Path { + case "test-driver-1": + return os.Open(filepath.Join("testdata", "test-driver-1.1.tar.gz")) + case "test-driver-no-sig": + return os.Open(filepath.Join("testdata", "test-driver-no-sig.tar.gz")) + default: + return nil, fmt.Errorf("unexpected locked driver %q", pkg.Driver.Path) + } + }, + }) + suite.runCmd(convergenceModel) + suite.Equal(0, registryCalls) + suite.Equal(1, downloadCalls) + suite.Equal(failedDriver, observedPackage.Driver.Path) + suite.Equal(lockedArtifact.Location.Value, observedPackage.Path.String()) + suite.Equal(lockedArtifact.Hash, observedPackage.ArtifactHash) + suite.Nil(observedPackage.ArtifactSize, "the hash-only registry lock has no source-declared size") + for _, name := range []string{"test-driver-1", "test-driver-no-sig"} { + _, err := config.GetDriver(config.Get()[suite.configLevel], name) + suite.NoError(err) + } + convergedLock, err := os.ReadFile(lockPath) + suite.Require().NoError(err) + suite.Equal(lockedCandidate, convergedLock) +} diff --git a/cmd/dbc/sync_planning.go b/cmd/dbc/sync_planning.go new file mode 100644 index 00000000..85835664 --- /dev/null +++ b/cmd/dbc/sync_planning.go @@ -0,0 +1,722 @@ +// 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 main + +import ( + "context" + "errors" + "fmt" + "io/fs" + "path/filepath" + "reflect" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc" + "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/packslip" + "github.com/columnar-tech/dbc/internal/resolution" + "github.com/columnar-tech/dbc/internal/sourceidentity" + "github.com/columnar-tech/dbc/internal/sourceresolution" +) + +type installItem struct { + Release resolution.ResolvedRelease + ArtifactIndex int + Platform string + InstalledLibraryHash string + ValidatedLibraryHash string + LockEntry *lockInfo + Archive *sourceresolution.OpenedArtifact + Expected config.ExpectedPackageMetadata + Validation *config.PackageValidation + AlreadyInstalled *config.DriverInfo +} + +type plannedSyncItem struct { + Name string + Spec driverSpec + Requirement sourceresolution.Requirement + Plan sourceresolution.Plan + LockEntry *lockInfo + LegacyLock *lockInfo +} + +func (item *installItem) selectedArtifact() (*resolution.Artifact, error) { + if item == nil || item.ArtifactIndex < 0 || item.ArtifactIndex >= len(item.Release.Artifacts) { + return nil, errors.New("install item has no selected artifact") + } + return &item.Release.Artifacts[item.ArtifactIndex], nil +} + +func newInstallItem(release resolution.ResolvedRelease, artifactIndex int, platform string, lockEntry *lockInfo) (installItem, error) { + item := installItem{Release: cloneResolvedReleaseForSync(release), ArtifactIndex: artifactIndex, Platform: platform} + if artifactIndex < 0 || artifactIndex >= len(release.Artifacts) { + return installItem{}, errors.New("resolved release has no selected artifact") + } + if release.DriverID == "" || release.Version == "" || release.Source.Type == "" || release.Source.Reference == "" { + return installItem{}, errors.New("resolved release is missing driver, version, or source identity") + } + if platform == "" { + return installItem{}, errors.New("install item is missing its target platform") + } + target, err := resolution.TargetFromPlatformTuple(platform) + if err != nil { + return installItem{}, fmt.Errorf("invalid install platform %q: %w", platform, err) + } + if release.Artifacts[artifactIndex].Target != target { + return installItem{}, fmt.Errorf("selected artifact target %v does not match platform %q", release.Artifacts[artifactIndex].Target, platform) + } + if lockEntry != nil { + copy := cloneLockInfo(*lockEntry) + item.LockEntry = © + } + return item, nil +} + +func cloneResolvedReleaseForSync(release resolution.ResolvedRelease) resolution.ResolvedRelease { + clone := release + clone.Evidence = append([]resolution.Evidence(nil), release.Evidence...) + clone.Artifacts = make([]resolution.Artifact, len(release.Artifacts)) + for i, artifact := range release.Artifacts { + clone.Artifacts[i] = artifact + clone.Artifacts[i].Size = cloneInt64(artifact.Size) + clone.Artifacts[i].HostRequirements = cloneHostRequirements(artifact.HostRequirements) + } + return clone +} + +type preparedSyncMsg struct { + items []installItem + lock LockFile +} + +type candidateLockSavedMsg struct{ lock LockFile } + +func (s syncModel) planSyncItems(list DriversList) ([]plannedSyncItem, bool, error) { + planned, needsRegistry, _, err := s.planSyncItemsWithVersion(list) + return planned, needsRegistry, err +} + +// planSyncItemsWithVersion returns the version from the exact lock snapshot +// used for planning. Callers that need migration state must use this method so +// they do not load the lockfile a second time between planning and persisting. +func (s syncModel) planSyncItemsWithVersion(list DriversList) ([]plannedSyncItem, bool, int, error) { + lf, err := loadLockFile(s.LockFilePath) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, false, 0, err + } + planned, needsRegistry, err := s.planSyncItemsFromLock(list, lf) + return planned, needsRegistry, lf.Version, err +} + +func (s syncModel) planSyncItemsFromLock(list DriversList, lf LockFile) ([]plannedSyncItem, bool, error) { + + planned := make([]plannedSyncItem, 0, len(list.Drivers)) + needsRegistry := false + for name, spec := range list.Drivers { + requirement, err := requirementForDriverSpec(name, spec) + if err != nil { + return nil, false, err + } + + entry, hasEntry := lf.lockinfo[name] + var plan sourceresolution.Plan + var existing *lockInfo + var legacy *lockInfo + if lf.Version == lockFileVersion && hasEntry { + release := entry.resolvedRelease() + plan = requirement.Plan(&release, false) + if plan.Outcome() == sourceresolution.PlanReplay || plan.Outcome() == sourceresolution.PlanRefreshRequired { + copy := cloneLockInfo(entry) + existing = © + } + } else { + plan = requirement.Plan(nil, false) + // v1 locks prove only a version and (optionally) installed library + // bytes. They are migration inputs, never replayable release snapshots. + // Without source identity, retain that proof only for the historical + // default-registry selection and verify the selected result later. + if lf.Version == lockFileVersionV1 && hasEntry && + requirement.Source().Mode() == sourceresolution.DefaultRegistry { + copy := cloneLockInfo(entry) + legacy = © + } + } + + switch plan.Outcome() { + case sourceresolution.PlanReplay: + case sourceresolution.PlanResolve, sourceresolution.PlanRefreshRequired: + if requirement.Source().Mode() == sourceresolution.DefaultRegistry { + needsRegistry = true + } else if key, ok := requirement.Source().Key(); ok && key.Kind == sourceidentity.Registry { + needsRegistry = true + } + case sourceresolution.PlanLockedArtifactMissing: + return nil, false, &LockedModeArtifactMissingError{DriverID: name, Platform: config.PlatformTuple()} + case sourceresolution.PlanReject: + return nil, false, fmt.Errorf("cannot plan sync for driver %q: %w", name, plan.Err()) + default: + return nil, false, fmt.Errorf("cannot plan sync for driver %q: invalid plan outcome %d", name, plan.Outcome()) + } + planned = append(planned, plannedSyncItem{ + Name: name, Spec: spec, Requirement: requirement, Plan: plan, + LockEntry: existing, LegacyLock: legacy, + }) + } + return planned, needsRegistry, nil +} + +func requirementForDriverSpec(name string, spec driverSpec) (sourceresolution.Requirement, error) { + var selection sourceresolution.SourceSelection + sourceKind := sourceidentity.Registry + if spec.Source == nil { + selection = sourceresolution.DefaultRegistrySelection() + } else { + key, err := driverSourceIdentity(spec.Source) + if err != nil { + return sourceresolution.Requirement{}, fmt.Errorf("driver %q has invalid declared source: %w", name, err) + } + selection, err = sourceresolution.ExplicitSourceSelection(key) + if err != nil { + return sourceresolution.Requirement{}, fmt.Errorf("driver %q has invalid declared source: %w", name, err) + } + sourceKind = key.Kind + } + + var version sourceresolution.VersionRequirement + var err error + switch sourceKind { + case sourceidentity.Registry: + constraint := "" + if spec.Version != nil { + constraint = spec.Version.String() + } + policy := sourceresolution.PrereleaseForbidden + if spec.Prerelease == "allow" || (spec.Version != nil && spec.Version.IncludePrerelease) { + policy = sourceresolution.PrereleaseAllowed + } + version, err = sourceresolution.RegistryVersionRequirement(constraint, policy) + case sourceidentity.Packslip: + if spec.Prerelease != "" { + return sourceresolution.Requirement{}, fmt.Errorf("driver %q packslip source does not support prerelease policy", name) + } + if spec.Version == nil { + // TODO: Exact versions are required only for the current Packslip PoC. + // Before Packslip support is generally available, allow omitted versions + // for GitHub-hosted projects by discovering eligible releases and resolving + // the latest one to an exact ResolvedRelease. Requirements may then be + // moving, but ResolvedRelease and dbc.lock must remain exact. Non-GitHub + // projects can use signed Packslip release lists for version discovery. + return sourceresolution.Requirement{}, fmt.Errorf("driver %q packslip source requires an exact SemVer 2.0.0 version", name) + } + version, err = sourceresolution.PackslipVersionRequirement(spec.Version.String()) + case sourceidentity.Path: + if spec.Prerelease != "" { + return sourceresolution.Requirement{}, fmt.Errorf("driver %q path source does not support prerelease policy", name) + } + if spec.Version == nil { + version = sourceresolution.PathMetadataVersionRequirement() + } else { + version, err = sourceresolution.PathVersionRequirement(spec.Version.String()) + } + default: + return sourceresolution.Requirement{}, fmt.Errorf("source type %q for driver %q is not supported by sync", sourceKind, name) + } + if err != nil { + return sourceresolution.Requirement{}, fmt.Errorf("driver %q has invalid version requirement: %w", name, err) + } + target, err := resolution.TargetFromPlatformTuple(config.PlatformTuple()) + if err != nil { + return sourceresolution.Requirement{}, fmt.Errorf("unsupported sync platform: %w", err) + } + return sourceresolution.NewRequirement(name, selection, version, target) +} + +func (s syncModel) createInstallList(planned []plannedSyncItem) ([]installItem, error) { + return s.createInstallListContext(context.Background(), planned) +} + +func (s syncModel) createInstallListContext(ctx context.Context, planned []plannedSyncItem) ([]installItem, error) { + items := make([]installItem, 0, len(planned)) + var packslipResolver packslip.Resolver + for _, entry := range planned { + switch entry.Plan.Outcome() { + case sourceresolution.PlanReplay: + item, err := installItemFromPlan(entry) + if err != nil { + return nil, err + } + items = append(items, item) + case sourceresolution.PlanResolve, sourceresolution.PlanRefreshRequired: + var item installItem + var err error + kind := sourceidentity.Registry + if entry.Requirement.Source().Mode() == sourceresolution.ExplicitSource { + if key, ok := entry.Requirement.Source().Key(); ok { + kind = key.Kind + } + } + switch kind { + case sourceidentity.Registry: + item, err = s.resolveRegistryPlan(entry) + case sourceidentity.Packslip: + if packslipResolver == nil { + if s.newPackslipResolver == nil { + return nil, errors.New("no Packslip resolver is configured") + } + packslipResolver, err = s.newPackslipResolver() + if err != nil { + return nil, fmt.Errorf("create Packslip resolver: %w", err) + } + } + item, err = s.resolvePackslipPlan(ctx, entry, packslipResolver) + case sourceidentity.Path: + item, err = s.resolvePathPlan(ctx, entry) + default: + err = fmt.Errorf("source type %q for driver %q is not supported by sync", kind, entry.Name) + } + if err != nil { + return nil, err + } + items = append(items, item) + case sourceresolution.PlanReject: + return nil, fmt.Errorf("cannot plan sync for driver %q: %w", entry.Name, entry.Plan.Err()) + case sourceresolution.PlanLockedArtifactMissing: + return nil, &LockedModeArtifactMissingError{DriverID: entry.Name, Platform: config.PlatformTuple()} + default: + return nil, fmt.Errorf("cannot resolve sync plan for driver %q: invalid outcome %d", entry.Name, entry.Plan.Outcome()) + } + } + return items, nil +} + +func (s syncModel) resolvePackslipPlan(ctx context.Context, planned plannedSyncItem, resolver packslip.Resolver) (installItem, error) { + if planned.Spec.Source == nil || planned.Spec.Source.Type != dbc.DriverSourcePackslip { + return installItem{}, fmt.Errorf("Packslip plan for %q has no Packslip source declaration", planned.Name) + } + version := "" + if exact, ok := planned.Requirement.Version().ExactVersion(); ok { + version = exact + } + var priorLock *lockInfo + if planned.Plan.Outcome() == sourceresolution.PlanRefreshRequired { + release, ok := planned.Plan.Release() + if !ok { + return installItem{}, fmt.Errorf("Packslip refresh plan for %q has no prior release", planned.Name) + } + version = release.Version + priorLock = planned.LockEntry + } + release, err := sourceresolution.ResolvePackslip(ctx, resolver, planned.Spec.Source.Project, planned.Name, version) + if err != nil { + return installItem{}, err + } + return installItemFromResolverResult(planned.Requirement, release, priorLock) +} + +func (s syncModel) resolvePathPlan(ctx context.Context, planned plannedSyncItem) (installItem, error) { + if planned.Spec.Source == nil || planned.Spec.Source.Type != dbc.DriverSourcePath { + return installItem{}, fmt.Errorf("path plan for %q has no path source declaration", planned.Name) + } + version := "" + if exact, ok := planned.Requirement.Version().ExactVersion(); ok { + version = exact + } + var priorLock *lockInfo + var priorVersion string + if planned.Plan.Outcome() == sourceresolution.PlanRefreshRequired { + release, ok := planned.Plan.Release() + if !ok { + return installItem{}, fmt.Errorf("path refresh plan for %q has no prior release", planned.Name) + } + priorVersion = release.Version + if planned.Requirement.Version().Mode() != sourceresolution.PathMetadataDerived { + version = release.Version + } + priorLock = planned.LockEntry + } + baseDir, err := s.projectBaseDir() + if err != nil { + return installItem{}, err + } + target, err := resolution.TargetFromPlatformTuple(config.PlatformTuple()) + if err != nil { + return installItem{}, fmt.Errorf("unsupported sync platform: %w", err) + } + release, err := sourceresolution.ResolvePath(ctx, planned.Spec.Source.Path, sourceresolution.Request{ + DriverID: planned.Name, Version: version, Target: target, + Platform: config.PlatformTuple(), BaseDir: baseDir, + }) + if err != nil { + return installItem{}, err + } + if priorLock != nil && priorVersion != "" && release.Version != priorVersion { + // An omitted path version follows the current archive metadata. Once it + // changes, the prior snapshot (including artifacts, evidence, and any + // legacy proof) no longer describes the candidate release. + priorLock = nil + } + return installItemFromResolverResult(planned.Requirement, release, priorLock) +} + +func (s syncModel) projectBaseDir() (string, error) { + basePath := s.LockFilePath + if basePath == "" { + basePath = s.Path + } + if basePath == "" { + return "", errors.New("sync project path is not set") + } + baseDir, err := filepath.Abs(filepath.Dir(basePath)) + if err != nil { + return "", fmt.Errorf("resolve project directory: %w", err) + } + return baseDir, nil +} + +func installItemFromPlan(planned plannedSyncItem) (installItem, error) { + release, ok := planned.Plan.Release() + if !ok { + return installItem{}, fmt.Errorf("replay plan for %q has no release", planned.Name) + } + selected, ok := planned.Plan.Artifact() + if !ok { + return installItem{}, fmt.Errorf("replay plan for %q has no selected artifact", planned.Name) + } + for i := range release.Artifacts { + if release.Artifacts[i].Target == selected.Target { + return newInstallItem(release, i, config.PlatformTuple(), planned.LockEntry) + } + } + return installItem{}, fmt.Errorf("replay plan for %q selected an artifact outside its release", planned.Name) +} + +func (s syncModel) resolveRegistryPlan(planned plannedSyncItem) (installItem, error) { + allowPrerelease := false + if policy, ok := planned.Requirement.Version().PrereleasePolicy(); ok { + allowPrerelease = policy == sourceresolution.PrereleaseAllowed + } + var exactVersion string + var priorLock *lockInfo + refreshingSnapshot := false + var refreshSource *sourceidentity.Key + var expectedRefreshSource *sourceidentity.Key + if planned.Plan.Outcome() == sourceresolution.PlanRefreshRequired { + release, ok := planned.Plan.Release() + if !ok { + return installItem{}, fmt.Errorf("refresh plan for %q has no prior release", planned.Name) + } + key, err := sourceidentity.Parse(sourceidentity.Kind(release.Source.Type), release.Source.Reference) + if err != nil || key.Kind != sourceidentity.Registry { + return installItem{}, fmt.Errorf("refresh plan for %q has an invalid registry source identity", planned.Name) + } + keyCopy := key + expectedRefreshSource = &keyCopy + if planned.Requirement.Source().Mode() == sourceresolution.DefaultRegistry { + // A default-registry requirement normally respects configured registry + // order. Refreshing a locked release is different: complete that same + // source snapshot instead of selecting a different registry. + refreshSource = &key + } + exactVersion = release.Version + refreshingSnapshot = true + priorLock = planned.LockEntry + } else if planned.LegacyLock != nil && planned.LegacyLock.Version != nil && + planned.Requirement.Source().Mode() == sourceresolution.DefaultRegistry && + planned.Requirement.AcceptsVersion(planned.LegacyLock.Version.String()) { + // A v1 lock has no source identity, so its version may guide registry + // selection only under the historical default-registry policy. + exactVersion = planned.LegacyLock.Version.String() + } + driver, err := registryDriverForRequirement(planned.Requirement, s.driverIndex, refreshSource) + if err != nil { + return installItem{}, wrapWithRegistryContext(err, s.registryErrors) + } + + var pkg dbc.PkgInfo + if exactVersion != "" { + version, err := semver.NewVersion(exactVersion) + if err != nil { + return installItem{}, fmt.Errorf("invalid locked version %q for %s: %w", exactVersion, planned.Name, err) + } + // Exact versions reach this path only after Requirement accepts them. + // Ask the legacy registry adapter for that exact prerelease as well; + // otherwise GetPackage's latest-selection filter would reject a version + // explicitly selected by the constraint. + pkg, err = driver.GetPackage(version, config.PlatformTuple(), true) + } else if constraint, ok := planned.Requirement.Version().Constraint(); ok && constraint != "" { + parsed, err := semver.NewConstraint(constraint) + if err != nil { + return installItem{}, fmt.Errorf("invalid registry constraint %q for %s: %w", constraint, planned.Name, err) + } + parsed.IncludePrerelease = allowPrerelease + pkg, err = driver.GetWithConstraint(parsed, config.PlatformTuple()) + } else { + pkg, err = driver.GetPackage(nil, config.PlatformTuple(), allowPrerelease) + } + if err != nil { + return installItem{}, err + } + + release, err := resolvedRegistryReleaseFromMetadata(driver, pkg) + if err != nil { + return installItem{}, err + } + if refreshingSnapshot { + resolvedSource, sourceErr := sourceidentity.Parse(sourceidentity.Kind(release.Source.Type), release.Source.Reference) + if sourceErr != nil || resolvedSource.Kind != sourceidentity.Registry { + return installItem{}, fmt.Errorf("registry refresh for %q returned an invalid source identity", planned.Name) + } + if expectedRefreshSource == nil || resolvedSource != *expectedRefreshSource { + expected := "unknown" + if expectedRefreshSource != nil { + expected = expectedRefreshSource.Reference + } + return installItem{}, fmt.Errorf("registry refresh for %q returned source %q instead of locked source %q", planned.Name, resolvedSource.Reference, expected) + } + lockedVersion, lockErr := semver.NewVersion(exactVersion) + resolvedVersion, resolveErr := semver.NewVersion(release.Version) + if lockErr != nil || resolveErr != nil || + !sourceresolution.SameReleaseVersion(sourceidentity.Registry, lockedVersion, resolvedVersion) { + return installItem{}, fmt.Errorf("registry refresh for %q resolved version %q instead of locked version %q", planned.Name, release.Version, exactVersion) + } + } + item, err := installItemFromResolverResult(planned.Requirement, release, priorLock) + if err != nil { + return installItem{}, err + } + if planned.LegacyLock != nil && planned.LegacyLock.Version != nil && priorLock == nil && + planned.Requirement.Source().Mode() == sourceresolution.DefaultRegistry { + resolvedVersion, versionErr := semver.NewVersion(release.Version) + if versionErr == nil && sourceresolution.SameReleaseVersion(sourceidentity.Registry, planned.LegacyLock.Version, resolvedVersion) { + copy := cloneLockInfo(*planned.LegacyLock) + item.LockEntry = © + } + } + return item, nil +} + +func registryDriverForRequirement(requirement sourceresolution.Requirement, drivers []dbc.Driver, refreshSource *sourceidentity.Key) (dbc.Driver, error) { + if requirement.Source().Mode() == sourceresolution.DefaultRegistry && refreshSource == nil { + return findDriver(requirement.DriverID(), drivers) + } + var key sourceidentity.Key + if requirement.Source().Mode() == sourceresolution.DefaultRegistry { + if refreshSource == nil || refreshSource.Kind != sourceidentity.Registry { + return dbc.Driver{}, fmt.Errorf("driver %q has an invalid pinned registry source", requirement.DriverID()) + } + key = *refreshSource + } else { + var ok bool + key, ok = requirement.Source().Key() + if !ok || key.Kind != sourceidentity.Registry { + return dbc.Driver{}, fmt.Errorf("driver %q has an unsupported registry source selection", requirement.DriverID()) + } + } + var matches []dbc.Driver + for _, driver := range drivers { + if driver.Registry == nil || driver.Registry.BaseURL == nil { + continue + } + candidate, err := sourceidentity.Parse(sourceidentity.Registry, driver.Registry.BaseURL.String()) + if err == nil && candidate == key { + matches = append(matches, driver) + } + } + driver, err := findDriver(requirement.DriverID(), matches) + if err != nil { + if refreshSource != nil { + return dbc.Driver{}, fmt.Errorf("driver %q was not found in locked registry source %q", requirement.DriverID(), key.Reference) + } + return dbc.Driver{}, fmt.Errorf("driver %q was not found in declared registry %q", requirement.DriverID(), key.Reference) + } + return driver, nil +} + +func resolvedReleaseFromRegistryPackage(driver dbc.Driver, pkg dbc.PkgInfo) (resolution.ResolvedRelease, error) { + if driver.Path == "" || pkg.Version == nil || strings.TrimSpace(pkg.PlatformTuple) == "" { + return resolution.ResolvedRelease{}, errors.New("registry package metadata is incomplete") + } + if driver.Registry == nil || driver.Registry.BaseURL == nil { + return resolution.ResolvedRelease{}, fmt.Errorf("driver %q has no registry identity", driver.Path) + } + registryKey, err := sourceidentity.Parse(sourceidentity.Registry, driver.Registry.BaseURL.String()) + if err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("driver %q has invalid registry identity: %w", driver.Path, err) + } + if pkg.Driver.Path != "" && pkg.Driver.Path != driver.Path { + return resolution.ResolvedRelease{}, fmt.Errorf("registry resolver returned package for driver %q while resolving %q", pkg.Driver.Path, driver.Path) + } + if pkg.Driver.Registry != nil { + if pkg.Driver.Registry.BaseURL == nil { + return resolution.ResolvedRelease{}, fmt.Errorf("registry resolver returned package without registry identity for %q", driver.Path) + } + packageRegistry, err := sourceidentity.Parse(sourceidentity.Registry, pkg.Driver.Registry.BaseURL.String()) + if err != nil || packageRegistry != registryKey { + return resolution.ResolvedRelease{}, fmt.Errorf("registry resolver returned package from a different source for %q", driver.Path) + } + } + if pkg.Path == nil { + return resolution.ResolvedRelease{}, fmt.Errorf("registry package metadata for %q has no archive URL", driver.Path) + } + target, err := resolution.TargetFromPlatformTuple(pkg.PlatformTuple) + if err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("invalid registry package platform %q: %w", pkg.PlatformTuple, err) + } + release := resolution.ResolvedRelease{ + DriverID: driver.Path, + Version: pkg.Version.String(), + Source: resolution.SourceSpec{Type: "registry", Reference: registryKey.Reference}, + Artifacts: []resolution.Artifact{{ + Target: target, Format: "tar.gz", Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: pkg.Path.String()}, + Hash: pkg.ArtifactHash, + }}, + } + if err := resolution.ValidateResolvedReleaseCandidate(release); err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("invalid registry package metadata: %w", err) + } + return release, nil +} + +// resolvedRegistryReleaseFromMetadata expands the selected registry artifact +// to the exact release's metadata-only artifact set when every target has a +// hash. If any target lacks a hash, retain the host and every hash-bearing +// target in a partial candidate; sync never downloads other platforms merely +// to complete a lock. +func resolvedRegistryReleaseFromMetadata(driver dbc.Driver, selected dbc.PkgInfo) (resolution.ResolvedRelease, error) { + hostRelease, err := resolvedReleaseFromRegistryPackage(driver, selected) + if err != nil { + return resolution.ResolvedRelease{}, err + } + metadataPackages, err := driver.GetPackages(selected.Version) + if err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("read registry metadata for exact release %s: %w", selected.Version, err) + } + + metadataRelease := hostRelease + metadataRelease.Artifacts = make([]resolution.Artifact, 0, len(metadataPackages)) + allHashesPresent := len(metadataPackages) > 0 + selectedMetadataMatches := false + for _, metadataPackage := range metadataPackages { + if metadataPackage.PlatformTuple == selected.PlatformTuple { + if metadataPackage.Version == nil || metadataPackage.Version.String() != selected.Version.String() || + metadataPackage.Path == nil || selected.Path == nil || metadataPackage.Path.String() != selected.Path.String() || + metadataPackage.ArtifactHash != selected.ArtifactHash { + return resolution.ResolvedRelease{}, errors.New("selected registry artifact does not match the exact release metadata") + } + selectedMetadataMatches = true + } + artifactRelease, err := resolvedReleaseFromRegistryPackage(driver, metadataPackage) + if err != nil { + return resolution.ResolvedRelease{}, err + } + if artifactRelease.Version != hostRelease.Version || artifactRelease.Source != hostRelease.Source { + return resolution.ResolvedRelease{}, errors.New("registry metadata returned artifacts from a different release") + } + artifact := artifactRelease.Artifacts[0] + metadataRelease.Artifacts = append(metadataRelease.Artifacts, artifact) + if artifact.Hash == "" { + allHashesPresent = false + } + } + if !selectedMetadataMatches { + return resolution.ResolvedRelease{}, errors.New("selected registry artifact is missing from exact release metadata") + } + // Validate the complete metadata set before deciding whether it can support + // a full snapshot. This keeps malformed duplicates and conflicting metadata + // from being hidden by the partial-lock fallback. + if err := resolution.ValidateResolvedReleaseCandidate(metadataRelease); err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("invalid registry release metadata: %w", err) + } + if allHashesPresent { + return metadataRelease, nil + } + // Keep every hash-bearing target because its complete artifact identity is + // available from metadata. Keep the selected host target even when its hash + // is absent so installation can finalize that artifact after download. + partialRelease := metadataRelease + partialRelease.Artifacts = make([]resolution.Artifact, 0, len(metadataRelease.Artifacts)) + hostTarget := hostRelease.Artifacts[0].Target + hostFound := false + for _, artifact := range metadataRelease.Artifacts { + if artifact.Target == hostTarget { + hostFound = true + } + if artifact.Hash != "" || artifact.Target == hostTarget { + partialRelease.Artifacts = append(partialRelease.Artifacts, artifact) + } + } + if !hostFound { + return resolution.ResolvedRelease{}, errors.New("selected registry artifact is missing from exact release metadata") + } + if err := resolution.ValidateResolvedReleaseCandidate(partialRelease); err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("invalid partial registry release metadata: %w", err) + } + return partialRelease, nil +} + +func installItemFromResolverResult(requirement sourceresolution.Requirement, release resolution.ResolvedRelease, priorLock *lockInfo) (installItem, error) { + selected, err := requirement.ValidateResolverResult(release) + if err != nil { + return installItem{}, fmt.Errorf("source resolver returned an invalid release: %w", err) + } + for i := range release.Artifacts { + if release.Artifacts[i].Target == selected.Target { + return newInstallItem(release, i, config.PlatformTuple(), priorLock) + } + } + return installItem{}, fmt.Errorf("validated resolver artifact for %q is not part of its release", requirement.DriverID()) +} + +func installItemFromLockedArtifact(name string, entry lockInfo, artifact lockArtifact) (installItem, error) { + if err := validateInstallableArtifactFormat(artifact.Format); err != nil { + return installItem{}, fmt.Errorf("locked artifact for %s: %w", name, err) + } + requirements := resolutionHostRequirementsFromLock(artifact.HostRequirements) + if err := validateHostRequirements(name, requirements); err != nil { + return installItem{}, err + } + if err := resolution.ValidateArtifactLocation(artifact.Location); err != nil { + return installItem{}, fmt.Errorf("invalid locked artifact location for %s: %w", name, err) + } + release := entry.resolvedRelease() + artifactIndex := -1 + for index := range release.Artifacts { + if release.Artifacts[index].Target == artifact.Target { + artifactIndex = index + break + } + } + if artifactIndex < 0 { + return installItem{}, fmt.Errorf("locked artifact for %s is not part of its release snapshot", name) + } + if !reflect.DeepEqual(lockArtifactFromResolved(release.Artifacts[artifactIndex]), artifact) { + return installItem{}, fmt.Errorf("locked artifact for %s does not match its release snapshot", name) + } + if release.DriverID != name { + return installItem{}, fmt.Errorf("locked release driver ID %q does not match %q", release.DriverID, name) + } + return newInstallItem(release, artifactIndex, config.PlatformTuple(), &entry) +} + +func cloneHostRequirements(requirements resolution.HostRequirements) resolution.HostRequirements { + return resolution.HostRequirements{ + OSMin: requirements.OSMin, + GLibCMin: requirements.GLibCMin, + Libs: append([]string(nil), requirements.Libs...), + Bins: append([]resolution.NamedRequirement(nil), requirements.Bins...), + } +} diff --git a/cmd/dbc/sync_planning_test.go b/cmd/dbc/sync_planning_test.go new file mode 100644 index 00000000..c319e920 --- /dev/null +++ b/cmd/dbc/sync_planning_test.go @@ -0,0 +1,996 @@ +// 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 main + +import ( + "context" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc" + "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/packslip" + "github.com/columnar-tech/dbc/internal/resolution" + "github.com/columnar-tech/dbc/internal/sourceresolution" + "github.com/go-faster/yaml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSyncProgressPercentTracksCompletedItems(t *testing.T) { + if got := syncProgressPercent(0, 2); got != 0.5 { + t.Fatalf("first completed item progress = %v, want 0.5", got) + } + if got := syncProgressPercent(1, 2); got != 1 { + t.Fatalf("second completed item progress = %v, want 1", got) + } +} + +func TestFreshRegistryInstallItemDefaultsToTarGZWithoutHostRequirements(t *testing.T) { + drivers, err := getTestDriverRegistry() + require.NoError(t, err) + model := syncModel{ + LockFilePath: filepath.Join(t.TempDir(), "dbc.lock"), + driverIndex: drivers, + } + planned, needsRegistry, err := model.planSyncItems(DriversList{Drivers: map[string]driverSpec{"test-driver-1": {}}}) + require.NoError(t, err) + assert.True(t, needsRegistry) + items, err := model.createInstallList(planned) + require.NoError(t, err) + require.Len(t, items, 1) + selected, err := items[0].selectedArtifact() + require.NoError(t, err) + assert.Equal(t, "tar.gz", selected.Format) + assert.Empty(t, selected.HostRequirements) +} + +func TestNonRegistryRequirementDoesNotRequestRegistryDiscovery(t *testing.T) { + for _, source := range []dbc.DriverSource{ + {Type: dbc.DriverSourcePackslip, Project: "github.com/example/driver"}, + {Type: dbc.DriverSourcePath, Path: "./driver.tar.gz"}, + } { + t.Run(string(source.Type), func(t *testing.T) { + var version *semver.Constraints + if source.Type == dbc.DriverSourcePackslip { + version, _ = semver.NewConstraint("1.2.3") + } + model := syncModel{LockFilePath: filepath.Join(t.TempDir(), "dbc.lock")} + planned, needsRegistry, err := model.planSyncItems(DriversList{Drivers: map[string]driverSpec{ + "test-driver-1": {Version: version, Source: &source}, + }}) + require.NoError(t, err) + assert.False(t, needsRegistry) + require.Len(t, planned, 1) + assert.Equal(t, sourceresolution.PlanResolve, planned[0].Plan.Outcome()) + }) + } +} + +func TestFreshPackslipResolutionSnapshotsAndReplaysWithoutDiscovery(t *testing.T) { + const driverID = "test-driver-1" + const version = "1.2.3+build.5" + dir := t.TempDir() + archivePath := filepath.Join(dir, "driver.tgz") + archiveBytes, archiveHash := makeSyncLegacyPackageArchive(t, archivePath, version) + archiveSize := int64(len(archiveBytes)) + location := "https://assets.example.test/driver.tgz" + release := makeSyncPackslipRelease(driverID, version, location, archiveHash, archiveSize) + resolver := &syncPackslipResolverStub{release: release} + constraint, err := semver.NewConstraint(version) + require.NoError(t, err) + source := dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "github.com/example/driver"} + list := DriversList{Drivers: map[string]driverSpec{driverID: {Version: constraint, Source: &source}}} + projectPath := filepath.Join(dir, "dbc.toml") + model := syncModel{ + baseModel: baseModel{ + newPackslipResolver: func() (packslip.Resolver, error) { return resolver, nil }, + fetchPackslipArtifact: func(_ context.Context, artifactURL *url.URL) (io.ReadCloser, error) { + require.Equal(t, location, artifactURL.String()) + return os.Open(archivePath) + }, + }, + Path: projectPath, LockFilePath: filepath.Join(dir, "dbc.lock"), + cfg: config.Config{Level: config.ConfigEnv, Location: filepath.Join(dir, "install")}, + } + + planned, needsRegistry, err := model.planSyncItems(list) + require.NoError(t, err) + assert.False(t, needsRegistry) + items, err := model.createInstallListContext(context.Background(), planned) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, 1, resolver.calls) + assert.Equal(t, packslip.Request{DriverID: driverID, Version: version}, resolver.request) + assert.Equal(t, release, items[0].Release, "verified source evidence and every signed artifact remain the execution input") + + prepared, err := model.prepareInstallItems(context.Background(), items) + require.NoError(t, err) + defer closePreparedItems(prepared.items) + require.Len(t, prepared.lock.Drivers, 1) + locked := prepared.lock.Drivers[0] + assert.Equal(t, "packslip", locked.Source.Type) + assert.Equal(t, source.Project, locked.Source.Project) + assert.Equal(t, version, locked.Version.String()) + assert.Equal(t, release.Evidence[0].Hash, locked.Evidence[0].Hash) + require.Len(t, locked.Artifacts, 2) + lockedByLocation := make(map[string]lockArtifact, len(locked.Artifacts)) + for _, artifact := range locked.Artifacts { + lockedByLocation[artifact.Location.Value] = artifact + } + primaryArtifact, ok := lockedByLocation[release.Artifacts[0].Location.Value] + require.True(t, ok, "the lock retains the selected host artifact regardless of canonical artifact order") + assert.Equal(t, release.Artifacts[0].Target, primaryArtifact.Target) + assert.Equal(t, "tgz", primaryArtifact.Format) + secondaryArtifact, ok := lockedByLocation[release.Artifacts[1].Location.Value] + require.True(t, ok, "the lock retains the secondary artifact regardless of canonical artifact order") + assert.Equal(t, release.Artifacts[1].Target, secondaryArtifact.Target) + assert.Equal(t, "tar.gz", secondaryArtifact.Format) + require.NoError(t, writeLockFileAtomic(model.LockFilePath, prepared.lock)) + + var resolverConstructions, registryCalls, downloadCalls int + replay := syncModel{ + baseModel: baseModel{ + getDriverRegistry: func() ([]dbc.Driver, error) { + registryCalls++ + return nil, errors.New("replay must not discover registries") + }, + newPackslipResolver: func() (packslip.Resolver, error) { + resolverConstructions++ + return nil, errors.New("replay must not construct a resolver") + }, + fetchPackslipArtifact: func(_ context.Context, artifactURL *url.URL) (io.ReadCloser, error) { + downloadCalls++ + assert.Equal(t, location, artifactURL.String()) + return os.Open(archivePath) + }, + }, + Path: projectPath, LockFilePath: model.LockFilePath, + cfg: config.Config{Level: config.ConfigEnv, Location: filepath.Join(dir, "replay-install")}, + } + replayPlan, replayNeedsRegistry, err := replay.planSyncItems(list) + require.NoError(t, err) + assert.False(t, replayNeedsRegistry) + replayItems, err := replay.createInstallListContext(context.Background(), replayPlan) + require.NoError(t, err) + require.Len(t, replayItems, 1) + assert.Zero(t, resolverConstructions) + assert.Zero(t, registryCalls) + replayedArtifact, err := replayItems[0].selectedArtifact() + require.NoError(t, err) + assert.Equal(t, archiveHash, replayedArtifact.Hash) + assert.Equal(t, archiveSize, *replayedArtifact.Size) + replayed, err := replay.prepareInstallItems(context.Background(), replayItems) + require.NoError(t, err) + defer closePreparedItems(replayed.items) + assert.Equal(t, 1, downloadCalls) + assert.ElementsMatch(t, prepared.lock.Drivers[0].Artifacts, replayed.lock.Drivers[0].Artifacts) +} + +func assertFileClosed(t *testing.T, file *os.File) { + t.Helper() + _, err := file.Stat() + assert.Error(t, err, "stat on a closed file must fail") +} + +func assertNoPreparedWorkspaces(t *testing.T, dir string) { + t.Helper() + entries, err := os.ReadDir(dir) + if errors.Is(err, os.ErrNotExist) { + return + } + require.NoError(t, err) + for _, entry := range entries { + assert.False(t, strings.HasPrefix(entry.Name(), ".dbc-install-"), "unexpected prepared workspace %q", entry.Name()) + } +} + +func TestPathResolutionDerivesVersionAndUsesProjectRelativeArchive(t *testing.T) { + dir := t.TempDir() + packagesDir := filepath.Join(dir, "packages") + require.NoError(t, os.MkdirAll(packagesDir, 0o700)) + declaredPath := "./packages/test-driver-1.tar.gz" + archiveBytes, err := os.ReadFile(filepath.Join("testdata", "test-driver-1.tar.gz")) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(declaredPath, "./"))), archiveBytes, 0o600)) + cwd := t.TempDir() + t.Chdir(cwd) + source := dbc.DriverSource{Type: dbc.DriverSourcePath, Path: declaredPath} + list := DriversList{Drivers: map[string]driverSpec{"test-driver-1": {Source: &source}}} + model := syncModel{ + Path: filepath.Join(dir, "dbc.toml"), LockFilePath: filepath.Join(dir, "dbc.lock"), NoVerify: true, + cfg: config.Config{Level: config.ConfigEnv, Location: filepath.Join(dir, "install")}, + } + planned, needsRegistry, err := model.planSyncItems(list) + require.NoError(t, err) + assert.False(t, needsRegistry) + items, err := model.createInstallListContext(context.Background(), planned) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "1.0.0", items[0].Release.Version) + assert.Equal(t, declaredPath, items[0].Release.Source.Reference) + require.Equal(t, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: declaredPath}, items[0].Release.Artifacts[0].Location) + + prepared, err := model.prepareInstallItems(context.Background(), items) + require.NoError(t, err) + defer closePreparedItems(prepared.items) + locked := prepared.lock.Drivers[0] + assert.Equal(t, "1.0.0", locked.Version.String()) + assert.Equal(t, declaredPath, locked.Source.Path) + require.Len(t, locked.Artifacts, 1) + assert.Equal(t, declaredPath, locked.Artifacts[0].Location.Value) + assert.NotEmpty(t, locked.Artifacts[0].Hash) +} + +func TestPathLegacyPackageResolutionAndLockSnapshot(t *testing.T) { + dir := t.TempDir() + archivePath := filepath.Join(dir, "driver.tar.gz") + makeSyncLegacyPackageArchive(t, archivePath, "1.2.3") + source := dbc.DriverSource{Type: dbc.DriverSourcePath, Path: "driver.tar.gz"} + model := syncModel{Path: filepath.Join(dir, "dbc.toml"), LockFilePath: filepath.Join(dir, "dbc.lock"), NoVerify: true, + cfg: config.Config{Level: config.ConfigEnv, Location: filepath.Join(dir, "install")}} + planned, needsRegistry, err := model.planSyncItems(DriversList{Drivers: map[string]driverSpec{ + "test-driver-1": {Source: &source}, + }}) + require.NoError(t, err) + assert.False(t, needsRegistry) + items, err := model.createInstallListContext(context.Background(), planned) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "1.2.3", items[0].Release.Version) + prepared, err := model.prepareInstallItems(context.Background(), items) + require.NoError(t, err) + defer closePreparedItems(prepared.items) + require.Len(t, prepared.lock.Drivers, 1) + expected, _, err := expectedSyncPackageMetadata(prepared.items[0]) + require.NoError(t, err) + assert.Equal(t, items[0].Release.Artifacts[0].Hash, expected.ArchiveHash) +} + +func TestPathArchiveMutationAfterResolutionFailsBeforeCandidateLock(t *testing.T) { + dir := t.TempDir() + archivePath := filepath.Join(dir, "driver.tar.gz") + archiveBytes, err := os.ReadFile(filepath.Join("testdata", "test-driver-1.tar.gz")) + require.NoError(t, err) + require.NoError(t, os.WriteFile(archivePath, archiveBytes, 0o600)) + source := dbc.DriverSource{Type: dbc.DriverSourcePath, Path: "driver.tar.gz"} + model := syncModel{Path: filepath.Join(dir, "dbc.toml"), LockFilePath: filepath.Join(dir, "dbc.lock"), NoVerify: true, + cfg: config.Config{Level: config.ConfigEnv, Location: filepath.Join(dir, "install")}} + planned, _, err := model.planSyncItems(DriversList{Drivers: map[string]driverSpec{"test-driver-1": {Source: &source}}}) + require.NoError(t, err) + items, err := model.createInstallListContext(context.Background(), planned) + require.NoError(t, err) + require.NoError(t, os.WriteFile(archivePath, append(archiveBytes, []byte("changed")...), 0o600)) + prepared, err := model.prepareInstallItems(context.Background(), items) + defer closePreparedItems(prepared.items) + require.ErrorContains(t, err, "package archive hash mismatch") + assert.NoFileExists(t, model.LockFilePath) +} + +func TestPathMetadataDerivedRefreshAdoptsArchiveVersionWithoutOldSnapshotProof(t *testing.T) { + dir := t.TempDir() + const declaredPath = "./packages/driver.tar.gz" + archivePath := filepath.Join(dir, "packages", "driver.tar.gz") + require.NoError(t, os.MkdirAll(filepath.Dir(archivePath), 0o700)) + _, newHash := makeSyncLegacyPackageArchive(t, archivePath, "1.2.3") + stat, err := os.Stat(archivePath) + require.NoError(t, err) + oldEntry := partialPathLockForSync(t, declaredPath, "1.1.0", "sha256:"+strings.Repeat("b", 64), 17) + lockPath := filepath.Join(dir, "dbc.lock") + require.NoError(t, writeLockFileAtomic(lockPath, LockFile{Version: lockFileVersion, Drivers: []lockInfo{oldEntry}})) + oldLockBytes, err := os.ReadFile(lockPath) + require.NoError(t, err) + source := dbc.DriverSource{Type: dbc.DriverSourcePath, Path: declaredPath} + model := syncModel{Path: filepath.Join(dir, "dbc.toml"), LockFilePath: lockPath, NoVerify: true, + cfg: config.Config{Level: config.ConfigEnv, Location: filepath.Join(dir, "install")}} + planned, needsRegistry, err := model.planSyncItems(DriversList{Drivers: map[string]driverSpec{ + "test-driver-1": {Source: &source}, + }}) + require.NoError(t, err) + assert.False(t, needsRegistry) + require.Len(t, planned, 1) + assert.Equal(t, sourceresolution.PlanRefreshRequired, planned[0].Plan.Outcome()) + items, err := model.createInstallListContext(context.Background(), planned) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "1.2.3", items[0].Release.Version, "metadata-derived refresh follows the current archive") + assert.Nil(t, items[0].LockEntry, "old-version snapshot evidence must not reach a different-version candidate") + + prepared, err := model.prepareInstallItems(context.Background(), items) + require.NoError(t, err) + defer closePreparedItems(prepared.items) + require.Len(t, prepared.lock.Drivers, 1) + candidate := prepared.lock.Drivers[0] + assert.Equal(t, "1.2.3", candidate.Version.String()) + assert.Empty(t, candidate.Evidence, "old release evidence must not be merged") + assert.Nil(t, candidate.Legacy, "old legacy proof must not be merged") + require.Len(t, candidate.Artifacts, 1, "old target artifacts must not be carried into the changed release") + assert.Equal(t, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: declaredPath}, candidate.Artifacts[0].Location) + assert.Equal(t, newHash, candidate.Artifacts[0].Hash) + assert.Equal(t, stat.Size(), *candidate.Artifacts[0].Size) + assert.NotEqual(t, oldEntry.Artifacts[0].Hash, candidate.Artifacts[0].Hash) + lockAfterPrepare, err := os.ReadFile(lockPath) + require.NoError(t, err) + assert.Equal(t, oldLockBytes, lockAfterPrepare, "prepare does not persist the candidate over the existing lock") +} + +func TestPathExactRefreshStillRejectsChangedArchiveVersion(t *testing.T) { + dir := t.TempDir() + const declaredPath = "./packages/driver.tar.gz" + archivePath := filepath.Join(dir, "packages", "driver.tar.gz") + require.NoError(t, os.MkdirAll(filepath.Dir(archivePath), 0o700)) + makeSyncLegacyPackageArchive(t, archivePath, "1.2.3") + oldEntry := partialPathLockForSync(t, declaredPath, "1.1.0", "sha256:"+strings.Repeat("b", 64), 17) + lockPath := filepath.Join(dir, "dbc.lock") + require.NoError(t, writeLockFileAtomic(lockPath, LockFile{Version: lockFileVersion, Drivers: []lockInfo{oldEntry}})) + oldLockBytes, err := os.ReadFile(lockPath) + require.NoError(t, err) + version, err := semver.NewConstraint("1.1.0") + require.NoError(t, err) + source := dbc.DriverSource{Type: dbc.DriverSourcePath, Path: declaredPath} + model := syncModel{Path: filepath.Join(dir, "dbc.toml"), LockFilePath: lockPath} + planned, _, err := model.planSyncItems(DriversList{Drivers: map[string]driverSpec{ + "test-driver-1": {Version: version, Source: &source}, + }}) + require.NoError(t, err) + require.Equal(t, sourceresolution.PlanRefreshRequired, planned[0].Plan.Outcome()) + _, err = model.createInstallListContext(context.Background(), planned) + require.ErrorContains(t, err, "does not match requested version") + afterFailure, err := os.ReadFile(lockPath) + require.NoError(t, err) + assert.Equal(t, oldLockBytes, afterFailure, "exact-version rejection leaves the existing lock untouched") +} + +func partialPathLockForSync(t *testing.T, declaredPath, version, hash string, size int64) lockInfo { + t.Helper() + release := resolution.ResolvedRelease{ + DriverID: "test-driver-1", + Version: version, + Source: resolution.SourceSpec{Type: "path", Reference: declaredPath}, + Evidence: []resolution.Evidence{{ + Kind: resolution.EvidenceKindReleaseMetadata, + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/old-release.json"}, + Hash: "sha256:" + strings.Repeat("d", 64), + }}, + Artifacts: []resolution.Artifact{{ + Target: resolution.Target{OS: "plan9", Arch: "amd64"}, Format: "tar.gz", + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: declaredPath}, + Hash: hash, Size: &size, + }}, + } + entry, err := lockInfoFromResolvedRelease(release.DriverID, release) + require.NoError(t, err) + entry.Legacy = &legacyLibraryProof{Platform: config.PlatformTuple(), LibraryHash: strings.Repeat("c", 64)} + return entry +} + +func TestSourceVersionOrIdentityMismatchDiscardsOldLockProof(t *testing.T) { + entry := testResolvedRelease() + entry.DriverID = "test-driver-1" + entry.Source = resolution.SourceSpec{Type: "packslip", Reference: "github.com/example/old"} + entry.Version = "1.2.3+foo" + oldLock, err := lockInfoFromResolvedRelease(entry.DriverID, entry) + require.NoError(t, err) + oldLock.Legacy = &legacyLibraryProof{Platform: config.PlatformTuple(), LibraryHash: strings.Repeat("c", 64)} + lockPath := filepath.Join(t.TempDir(), "dbc.lock") + require.NoError(t, writeLockFileAtomic(lockPath, LockFile{Version: lockFileVersion, Drivers: []lockInfo{oldLock}})) + + constraint, err := semver.NewConstraint("1.2.3+bar") + require.NoError(t, err) + newSource := dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "github.com/example/new"} + model := syncModel{LockFilePath: lockPath} + planned, needsRegistry, err := model.planSyncItems(DriversList{Drivers: map[string]driverSpec{ + "test-driver-1": {Version: constraint, Source: &newSource}, + }}) + require.NoError(t, err) + assert.False(t, needsRegistry) + require.Len(t, planned, 1) + assert.Equal(t, sourceresolution.PlanResolve, planned[0].Plan.Outcome()) + assert.Nil(t, planned[0].LockEntry, "stale source/version metadata must not enter the new candidate") + assert.Nil(t, planned[0].LegacyLock) +} + +func TestRegistrySourceChangeDiscardsOldLockEntryBeforeFallback(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "dbc.lock") + entry := testRegistryLockEntryForPlatform(config.PlatformTuple()) + entry.Name = "test-driver-1" + entry.Version = semver.MustParse("1.0.0") + entry.Source.URL = "https://registry-a.example.test" + entry.Evidence = []lockEvidence{{ + Kind: resolution.EvidenceKindReleaseMetadata, + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://registry-a.example.test/release.json"}, + Hash: "sha256:" + strings.Repeat("b", 64), + }} + entry.Legacy = &legacyLibraryProof{Platform: config.PlatformTuple(), LibraryHash: strings.Repeat("c", 64)} + require.NoError(t, writeLockFileAtomic(lockPath, LockFile{Version: lockFileVersion, Drivers: []lockInfo{entry}})) + + registryBURL, err := url.Parse("https://registry-b.example.test") + require.NoError(t, err) + registryB := testRegistry + registryB.BaseURL = registryBURL + drivers, err := getTestDriverRegistry() + require.NoError(t, err) + for i := range drivers { + if drivers[i].Path == "test-driver-1" { + drivers[i].Registry = ®istryB + } + } + + list := DriversList{Drivers: map[string]driverSpec{ + "test-driver-1": { + Source: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: registryBURL.String()}, + }, + }} + model := syncModel{LockFilePath: lockPath, driverIndex: drivers} + planned, needsRegistry, err := model.planSyncItems(list) + require.NoError(t, err) + assert.True(t, needsRegistry, "a lock for registry A cannot be reused for registry B") + + for _, plan := range planned { + assert.Equal(t, sourceresolution.PlanResolve, plan.Plan.Outcome()) + assert.Nil(t, plan.LockEntry, "the old registry A entry must not influence registry B planning") + } + items, err := model.createInstallList(planned) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Nil(t, items[0].LockEntry, "the old registry A entry must not be merged into a registry B candidate") + assert.Equal(t, registryBURL.String(), items[0].Release.Source.Reference) +} + +func TestRegistryVersionMismatchDoesNotCarryOldReleaseEvidenceOrLegacyProof(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "dbc.lock") + entry := testRegistryLockEntryForPlatform(config.PlatformTuple()) + entry.Name = "test-driver-1" + entry.Version = semver.MustParse("1.0.0") + entry.Source.URL = testRegistry.BaseURL.String() + entry.Evidence = []lockEvidence{{ + Kind: resolution.EvidenceKindReleaseMetadata, + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://registry.example.test/old-release.json"}, + Hash: "sha256:" + strings.Repeat("b", 64), + }} + entry.Legacy = &legacyLibraryProof{Platform: config.PlatformTuple(), LibraryHash: strings.Repeat("c", 64)} + require.NoError(t, writeLockFileAtomic(lockPath, LockFile{Version: lockFileVersion, Drivers: []lockInfo{entry}})) + + constraint, err := semver.NewConstraint("=1.1.0") + require.NoError(t, err) + model := syncModel{LockFilePath: lockPath, driverIndex: mustTestRegistryDrivers(t)} + planned, needsRegistry, err := model.planSyncItems(DriversList{Drivers: map[string]driverSpec{ + "test-driver-1": {Version: constraint}, + }}) + require.NoError(t, err) + assert.True(t, needsRegistry) + require.Len(t, planned, 1) + assert.Equal(t, sourceresolution.PlanResolve, planned[0].Plan.Outcome()) + assert.Nil(t, planned[0].LockEntry, "a version-mismatched v2 snapshot is not refresh input") + assert.Nil(t, planned[0].LegacyLock, "v2 proof cannot be reinterpreted as a v1 migration proof") + + items, err := model.createInstallList(planned) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Nil(t, items[0].LockEntry, "the mismatched release evidence must not reach the candidate") + assert.Equal(t, "1.1.0", items[0].Release.Version) +} + +func TestRegistryTargetRefreshRetainsMatchingReleaseSnapshot(t *testing.T) { + otherPlatform := "linux_amd64" + if config.PlatformTuple() == otherPlatform { + otherPlatform = "macos_arm64" + } + entry := testRegistryLockEntryForPlatform(otherPlatform) + entry.Name = "test-driver-1" + entry.Source.URL = testRegistry.BaseURL.String() + lockPath := filepath.Join(t.TempDir(), "dbc.lock") + require.NoError(t, writeLockFileAtomic(lockPath, LockFile{Version: lockFileVersion, Drivers: []lockInfo{entry}})) + model := syncModel{LockFilePath: lockPath, driverIndex: mustTestRegistryDrivers(t)} + planned, needsRegistry, err := model.planSyncItems(DriversList{Drivers: map[string]driverSpec{ + "test-driver-1": {}, + }}) + require.NoError(t, err) + assert.True(t, needsRegistry) + require.Len(t, planned, 1) + assert.Equal(t, sourceresolution.PlanRefreshRequired, planned[0].Plan.Outcome()) + require.NotNil(t, planned[0].LockEntry, "a matching release snapshot may supply prior artifacts for target refresh") +} + +func TestDefaultRegistryRefreshStaysPinnedToLockedRegistry(t *testing.T) { + otherPlatform := "linux_amd64" + if config.PlatformTuple() == otherPlatform { + otherPlatform = "macos_arm64" + } + entry := testRegistryLockEntryForPlatform(otherPlatform) + entry.Name = "test-driver-1" + entry.Version = semver.MustParse("1.1.0") + entry.Source.URL = "https://registry-b.example.test" + lockPath := filepath.Join(t.TempDir(), "dbc.lock") + require.NoError(t, writeLockFileAtomic(lockPath, LockFile{Version: lockFileVersion, Drivers: []lockInfo{entry}})) + + registryA := registryScopedTestDriver(t, "test-driver-1", "https://registry-a.example.test") + registryB := registryScopedTestDriver(t, "test-driver-1", "https://registry-b.example.test") + model := syncModel{LockFilePath: lockPath, driverIndex: []dbc.Driver{registryA, registryB}} + planned, needsRegistry, err := model.planSyncItems(DriversList{Drivers: map[string]driverSpec{ + "test-driver-1": {}, + }}) + require.NoError(t, err) + assert.True(t, needsRegistry) + require.Len(t, planned, 1) + assert.Equal(t, sourceresolution.PlanRefreshRequired, planned[0].Plan.Outcome()) + + items, err := model.createInstallList(planned) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "https://registry-b.example.test", items[0].Release.Source.Reference, + "a refresh must resolve missing target metadata from the source already named by the lock") + require.NotNil(t, items[0].LockEntry) + assert.Equal(t, "https://registry-b.example.test", items[0].LockEntry.Source.URL) + selected, err := items[0].selectedArtifact() + require.NoError(t, err) + assert.Contains(t, selected.Location.Value, "registry-b.example.test") +} + +func TestDefaultRegistryRefreshDoesNotFallbackWhenLockedRegistryLacksDriver(t *testing.T) { + otherPlatform := "linux_amd64" + if config.PlatformTuple() == otherPlatform { + otherPlatform = "macos_arm64" + } + entry := testRegistryLockEntryForPlatform(otherPlatform) + entry.Name = "test-driver-1" + entry.Version = semver.MustParse("1.1.0") + entry.Source.URL = "https://registry-b.example.test" + lockPath := filepath.Join(t.TempDir(), "dbc.lock") + require.NoError(t, writeLockFileAtomic(lockPath, LockFile{Version: lockFileVersion, Drivers: []lockInfo{entry}})) + before, err := os.ReadFile(lockPath) + require.NoError(t, err) + + registryA := registryScopedTestDriver(t, "test-driver-1", "https://registry-a.example.test") + registryBWithoutDriver := registryScopedTestDriver(t, "test-driver-2", "https://registry-b.example.test") + model := syncModel{LockFilePath: lockPath, driverIndex: []dbc.Driver{registryA, registryBWithoutDriver}} + planned, needsRegistry, err := model.planSyncItems(DriversList{Drivers: map[string]driverSpec{ + "test-driver-1": {}, + }}) + require.NoError(t, err) + assert.True(t, needsRegistry) + require.Len(t, planned, 1) + assert.Equal(t, sourceresolution.PlanRefreshRequired, planned[0].Plan.Outcome()) + + items, err := model.createInstallList(planned) + require.ErrorContains(t, err, "was not found in locked registry source") + assert.Empty(t, items, "resolution must fail before the package enters preparation") + after, err := os.ReadFile(lockPath) + require.NoError(t, err) + assert.Equal(t, before, after, "a failed pinned-source refresh cannot mutate the existing lock") +} + +func TestV1ProofIsNotCarriedAcrossUndeclaredToExplicitRegistrySource(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "dbc.lock") + proof := strings.Repeat("d", 64) + body := fmt.Sprintf("version = 1\n\n[[drivers]]\nname = %q\nversion = %q\nplatform = %q\nchecksum = %q\n", + "test-driver-1", "1.1.0", config.PlatformTuple(), proof) + require.NoError(t, os.WriteFile(lockPath, []byte(body), 0o600)) + + constraint, err := semver.NewConstraint("=1.1.0") + require.NoError(t, err) + list := DriversList{Drivers: map[string]driverSpec{ + "test-driver-1": { + Version: constraint, + Source: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: testRegistry.BaseURL.String()}, + }, + }} + model := syncModel{LockFilePath: lockPath, driverIndex: mustTestRegistryDrivers(t)} + planned, needsRegistry, err := model.planSyncItems(list) + require.NoError(t, err) + assert.True(t, needsRegistry) + require.Len(t, planned, 1) + assert.Nil(t, planned[0].LegacyLock, "v1 has no source key to prove it came from the newly explicit registry") + + items, err := model.createInstallList(planned) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "1.1.0", items[0].Release.Version) + assert.Nil(t, items[0].LockEntry, "unattributed v1 library proof must not survive into the candidate") +} + +func TestExplicitPrereleaseConstraintSyncsThroughReplayRefreshAndV1Migration(t *testing.T) { + constraint, err := semver.NewConstraint("=2.1.0-beta.1") + require.NoError(t, err) + list := DriversList{Drivers: map[string]driverSpec{ + "test-driver-2": {Version: constraint}, + }} + drivers := mustTestRegistryDrivers(t) + + t.Run("fresh resolve", func(t *testing.T) { + model := syncModel{LockFilePath: filepath.Join(t.TempDir(), "dbc.lock"), driverIndex: drivers} + planned, needsRegistry, err := model.planSyncItems(list) + require.NoError(t, err) + assert.True(t, needsRegistry) + items, err := model.createInstallList(planned) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "2.1.0-beta.1", items[0].Release.Version) + }) + + t.Run("complete v2 replay", func(t *testing.T) { + entry := testRegistryLockEntryForPlatform(config.PlatformTuple()) + entry.Name = "test-driver-2" + entry.Version = semver.MustParse("2.1.0-beta.1") + entry.Source.URL = testRegistry.BaseURL.String() + lockPath := filepath.Join(t.TempDir(), "dbc.lock") + require.NoError(t, writeLockFileAtomic(lockPath, LockFile{Version: lockFileVersion, Drivers: []lockInfo{entry}})) + model := syncModel{LockFilePath: lockPath} + planned, needsRegistry, err := model.planSyncItems(list) + require.NoError(t, err) + assert.False(t, needsRegistry) + items, err := model.createInstallList(planned) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "2.1.0-beta.1", items[0].Release.Version) + }) + + t.Run("target refresh", func(t *testing.T) { + otherPlatform := "linux_amd64" + if config.PlatformTuple() == otherPlatform { + otherPlatform = "macos_arm64" + } + entry := testRegistryLockEntryForPlatform(otherPlatform) + entry.Name = "test-driver-2" + entry.Version = semver.MustParse("2.1.0-beta.1") + entry.Source.URL = testRegistry.BaseURL.String() + lockPath := filepath.Join(t.TempDir(), "dbc.lock") + require.NoError(t, writeLockFileAtomic(lockPath, LockFile{Version: lockFileVersion, Drivers: []lockInfo{entry}})) + model := syncModel{LockFilePath: lockPath, driverIndex: drivers} + planned, needsRegistry, err := model.planSyncItems(list) + require.NoError(t, err) + assert.True(t, needsRegistry) + require.Len(t, planned, 1) + assert.Equal(t, sourceresolution.PlanRefreshRequired, planned[0].Plan.Outcome()) + items, err := model.createInstallList(planned) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "2.1.0-beta.1", items[0].Release.Version) + require.NotNil(t, items[0].LockEntry) + }) + + t.Run("v1 migration", func(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "dbc.lock") + proof := strings.Repeat("d", 64) + body := fmt.Sprintf("version = 1\n\n[[drivers]]\nname = %q\nversion = %q\nplatform = %q\nchecksum = %q\n", + "test-driver-2", "2.1.0-beta.1", config.PlatformTuple(), proof) + require.NoError(t, os.WriteFile(lockPath, []byte(body), 0o600)) + model := syncModel{LockFilePath: lockPath, driverIndex: drivers} + planned, needsRegistry, err := model.planSyncItems(list) + require.NoError(t, err) + assert.True(t, needsRegistry) + items, err := model.createInstallList(planned) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "2.1.0-beta.1", items[0].Release.Version) + require.NotNil(t, items[0].LockEntry) + require.NotNil(t, items[0].LockEntry.Legacy) + assert.Equal(t, proof, items[0].LockEntry.Legacy.LibraryHash) + }) +} + +func TestRegistryReplayPlansDefaultAndExplicitSourceWithoutDiscovery(t *testing.T) { + for _, explicit := range []bool{false, true} { + name := "default" + if explicit { + name = "explicit" + } + t.Run(name, func(t *testing.T) { + entry := testRegistryLockEntryForPlatform(config.PlatformTuple()) + entry.Name = "test-driver-1" + entry.Source.URL = testRegistry.BaseURL.String() + lockPath := filepath.Join(t.TempDir(), "dbc.lock") + require.NoError(t, writeLockFileAtomic(lockPath, LockFile{Version: lockFileVersion, Drivers: []lockInfo{entry}})) + spec := driverSpec{} + if explicit { + spec.Source = &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: testRegistry.BaseURL.String()} + } + model := syncModel{LockFilePath: lockPath} + planned, needsRegistry, err := model.planSyncItems(DriversList{Drivers: map[string]driverSpec{"test-driver-1": spec}}) + require.NoError(t, err) + assert.False(t, needsRegistry) + items, err := model.createInstallList(planned) + require.NoError(t, err) + require.Len(t, items, 1) + require.NotNil(t, items[0].LockEntry) + assert.Nil(t, model.driverIndex, "complete v2 replay must not require registry discovery") + }) + } +} + +func TestRegistryPackageAdapterRejectsDifferentSourceIdentity(t *testing.T) { + driver := registryScopedTestDriver(t, "test-driver-1", "https://registry-a.example.test") + other := registryScopedTestDriver(t, "test-driver-1", "https://registry-b.example.test") + archiveURL, err := url.Parse("https://assets.example.test/archive.tar.gz") + require.NoError(t, err) + _, err = resolvedReleaseFromRegistryPackage(driver, dbc.PkgInfo{ + Driver: other, + Version: semver.MustParse("1.1.0"), + PlatformTuple: config.PlatformTuple(), + Path: archiveURL, + }) + require.ErrorContains(t, err, "different source") +} + +func TestRegistrySyncUsesOnlyExactReleaseMetadataForAllTargetLock(t *testing.T) { + for _, completeMetadata := range []bool{true, false} { + name := "complete metadata" + if !completeMetadata { + name = "missing non-host hash keeps partial lock" + } + t.Run(name, func(t *testing.T) { + const driverID, version = "test-driver-1", "1.2.3" + dir := t.TempDir() + archivePath := filepath.Join(dir, "host.tar.gz") + _, hostHash := makeSyncLegacyPackageArchive(t, archivePath, version) + platforms := []string{"windows_amd64", "linux_amd64", "darwin_arm64"} + var otherPlatform, missingHashPlatform string + for _, platform := range platforms { + if platform == config.PlatformTuple() { + continue + } + if otherPlatform == "" { + otherPlatform = platform + } else { + missingHashPlatform = platform + break + } + } + require.NotEmpty(t, otherPlatform) + require.NotEmpty(t, missingHashPlatform) + missingHashLine := " hash: sha256:" + strings.Repeat("c", 64) + "\n" + if !completeMetadata { + missingHashLine = "" + } + index := fmt.Sprintf(`drivers: + - name: Test Driver + description: synthetic multi-target registry fixture + license: MIT + path: %s + pkginfo: + - version: %s + packages: + - platform: %s + url: host.tar.gz + hash: %s + future_metadata: ignored + - platform: %s + url: other.tar.gz + hash: sha256:%s + - platform: %s + url: missing-hash.tar.gz + +%s`, driverID, version, config.PlatformTuple(), hostHash, + otherPlatform, strings.Repeat("b", 64), missingHashPlatform, missingHashLine) + var fixture struct { + Drivers []dbc.Driver `yaml:"drivers"` + } + require.NoError(t, yaml.NewDecoder(strings.NewReader(index)).Decode(&fixture)) + require.Len(t, fixture.Drivers, 1) + registryURL, err := url.Parse("https://registry.example.test") + require.NoError(t, err) + fixture.Drivers[0].Registry = &dbc.Registry{BaseURL: registryURL} + + constraint, err := semver.NewConstraint(version) + require.NoError(t, err) + source := dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: registryURL.String()} + var downloaded []string + model := syncModel{ + baseModel: baseModel{ + downloadArtifact: func(_ context.Context, pkg dbc.PkgInfo) (io.ReadCloser, error) { + downloaded = append(downloaded, pkg.PlatformTuple) + return os.Open(archivePath) + }, + }, + Path: filepath.Join(dir, "dbc.toml"), LockFilePath: filepath.Join(dir, "dbc.lock"), + NoVerify: true, driverIndex: fixture.Drivers, + cfg: config.Config{Level: config.ConfigEnv, Location: filepath.Join(dir, "install")}, + } + planned, needsRegistry, err := model.planSyncItems(DriversList{Drivers: map[string]driverSpec{ + driverID: {Version: constraint, Source: &source}, + }}) + require.NoError(t, err) + assert.True(t, needsRegistry, "the registry source requires registry resolution") + items, err := model.createInstallList(planned) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Empty(t, downloaded, "planning must use index metadata without fetching archives") + if completeMetadata { + require.Len(t, items[0].Release.Artifacts, 3) + var nonHost *resolution.Artifact + for i := range items[0].Release.Artifacts { + if items[0].Release.Artifacts[i].Target != items[0].Release.Artifacts[items[0].ArtifactIndex].Target { + nonHost = &items[0].Release.Artifacts[i] + } + } + require.NotNil(t, nonHost) + assert.Equal(t, "sha256:"+strings.Repeat("b", 64), nonHost.Hash) + assert.Nil(t, nonHost.Size, "hash-only registry metadata must not invent a size") + } else { + require.Len(t, items[0].Release.Artifacts, 2) + } + + prepared, err := model.prepareInstallItems(context.Background(), items) + require.NoError(t, err) + defer closePreparedItems(prepared.items) + assert.Equal(t, []string{config.PlatformTuple()}, downloaded, "only the current host artifact may be fetched") + wantArtifacts := 2 // Host plus the hash-bearing non-host target. + if completeMetadata { + wantArtifacts = 3 + } + require.Len(t, prepared.lock.Drivers, 1) + require.Len(t, prepared.lock.Drivers[0].Artifacts, wantArtifacts) + if completeMetadata { + var nonHost *lockArtifact + for i := range prepared.lock.Drivers[0].Artifacts { + if prepared.lock.Drivers[0].Artifacts[i].Target != items[0].Release.Artifacts[items[0].ArtifactIndex].Target { + nonHost = &prepared.lock.Drivers[0].Artifacts[i] + } + } + require.NotNil(t, nonHost) + assert.Contains(t, []string{ + "sha256:" + strings.Repeat("b", 64), + "sha256:" + strings.Repeat("c", 64), + }, nonHost.Hash) + assert.Nil(t, nonHost.Size) + } + }) + } +} + +func mustTestRegistryDrivers(t *testing.T) []dbc.Driver { + t.Helper() + drivers, err := getTestDriverRegistry() + require.NoError(t, err) + return drivers +} + +func registryScopedTestDriver(t *testing.T, driverID, registryURL string) dbc.Driver { + t.Helper() + drivers, err := getTestDriverRegistry() + require.NoError(t, err) + baseURL, err := url.Parse(registryURL) + require.NoError(t, err) + for _, driver := range drivers { + if driver.Path != driverID { + continue + } + registry := *driver.Registry + registry.BaseURL = baseURL + driver.Registry = ®istry + return driver + } + t.Fatalf("test registry has no driver %q", driverID) + return dbc.Driver{} +} + +func TestCreateInstallListSelectsDriverFromDeclaredRegistry(t *testing.T) { + registryA := registryScopedTestDriver(t, "test-driver-1", "https://registry-a.example.test") + registryB := registryScopedTestDriver(t, "test-driver-1", "https://registry-b.example.test") + model := syncModel{ + LockFilePath: filepath.Join(t.TempDir(), "dbc.lock"), + driverIndex: []dbc.Driver{registryA, registryB}, + } + declaredSource := &dbc.DriverSource{ + Type: dbc.DriverSourceRegistry, + URL: "HTTPS://REGISTRY-B.EXAMPLE.TEST/", + } + planned, needsRegistry, err := model.planSyncItems(DriversList{Drivers: map[string]driverSpec{ + "test-driver-1": {Source: declaredSource}, + }}) + require.NoError(t, err) + assert.True(t, needsRegistry) + items, err := model.createInstallList(planned) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "https://registry-b.example.test", items[0].Release.Source.Reference) + expected, _, err := expectedSyncPackageMetadata(items[0]) + require.NoError(t, err) + assert.Equal(t, "https://registry-b.example.test", expected.SourceIdentity) +} + +func TestCreateInstallListDoesNotFallbackOutsideDeclaredRegistry(t *testing.T) { + registryA := registryScopedTestDriver(t, "test-driver-1", "https://registry-a.example.test") + registryBWithoutDriver := registryScopedTestDriver(t, "test-driver-2", "https://registry-b.example.test") + tests := []struct { + name string + url string + }{ + {name: "driver missing in declared registry", url: "https://registry-b.example.test"}, + {name: "path mismatch", url: "https://registry-b.example.test/tenant"}, + {name: "query mismatch", url: "https://registry-b.example.test?tenant=b"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + model := syncModel{ + LockFilePath: filepath.Join(t.TempDir(), "dbc.lock"), + driverIndex: []dbc.Driver{ + {Path: "test-driver-1"}, + {Path: "test-driver-1", Registry: &dbc.Registry{}}, + registryA, + registryBWithoutDriver, + }, + } + planned, needsRegistry, err := model.planSyncItems(DriversList{Drivers: map[string]driverSpec{ + "test-driver-1": {Source: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: tt.url}}, + }}) + require.NoError(t, err) + assert.True(t, needsRegistry) + items, err := model.createInstallList(planned) + require.ErrorContains(t, err, "driver \"test-driver-1\" was not found in declared registry") + assert.ErrorContains(t, err, tt.url) + assert.Empty(t, items) + }) + } +} + +func TestCreateInstallListPreservesDefaultRegistryPrecedence(t *testing.T) { + registryA := registryScopedTestDriver(t, "test-driver-1", "https://registry-a.example.test") + registryB := registryScopedTestDriver(t, "test-driver-1", "https://registry-b.example.test") + model := syncModel{ + LockFilePath: filepath.Join(t.TempDir(), "dbc.lock"), + driverIndex: []dbc.Driver{registryA, registryB}, + } + planned, needsRegistry, err := model.planSyncItems(DriversList{Drivers: map[string]driverSpec{ + "test-driver-1": {}, + }}) + require.NoError(t, err) + assert.True(t, needsRegistry) + items, err := model.createInstallList(planned) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "https://registry-a.example.test", items[0].Release.Source.Reference) +} + +func TestExplicitRegistryURLNormalizationAllowsOfflineLockedReplay(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "dbc.lock") + entry := testRegistryLockEntryForPlatform(config.PlatformTuple()) + entry.Name = "test-driver-1" + entry.Source.URL = "https://registry.example.test" + require.NoError(t, writeLockFileAtomic(lockPath, LockFile{Version: lockFileVersion, Drivers: []lockInfo{entry}})) + + version, err := semver.NewConstraint("1.2.3") + require.NoError(t, err) + list := DriversList{Drivers: map[string]driverSpec{ + "test-driver-1": { + Version: version, + Source: &dbc.DriverSource{ + Type: dbc.DriverSourceRegistry, + URL: "HTTPS://REGISTRY.EXAMPLE.TEST/", + }, + }, + }} + model := syncModel{LockFilePath: lockPath} + planned, needsRegistry, err := model.planSyncItems(list) + require.NoError(t, err) + assert.False(t, needsRegistry, "normalized explicit registry identity should permit offline lock replay") + + items, err := model.createInstallList(planned) + require.NoError(t, err) + require.Len(t, items, 1) + require.NotNil(t, items[0].LockEntry) + assert.Nil(t, model.driverIndex, "locked replay must not need registry discovery") + assert.Equal(t, "https://registry.example.test", items[0].Release.Source.Reference) + expected, _, err := expectedSyncPackageMetadata(items[0]) + require.NoError(t, err) + assert.Equal(t, "https://registry.example.test", expected.SourceIdentity) +} diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index 38338983..90d63207 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -15,356 +15,26 @@ package main import ( - "encoding/json" - "fmt" "os" "path/filepath" - "strings" - - "github.com/columnar-tech/dbc" - "github.com/columnar-tech/dbc/internal/jsonschema" ) -func (suite *SubcommandTestSuite) TestSync() { - m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() - suite.runCmd(m) - - m = AddCmd{Path: filepath.Join(suite.tempdir, "dbc.toml"), Driver: []string{"test-driver-1"}}.GetModel() - suite.runCmd(m) - - m = SyncCmd{ - Path: filepath.Join(suite.tempdir, "dbc.toml"), - }.GetModelCustom( - testBaseModel()) - 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")) - - m = SyncCmd{ - Path: filepath.Join(suite.tempdir, "dbc.toml"), - }.GetModelCustom( - testBaseModel()) - suite.validateOutput("✓ test-driver-1-1.1.0 already installed\r\n\rDone!\r\n", "", suite.runCmd(m)) -} - -func (suite *SubcommandTestSuite) TestSyncRejectsUnsupportedLockFileWithoutRewriting() { +func (suite *SubcommandTestSuite) TestSyncRejectsFutureLockFileWithoutRewriting() { listPath := filepath.Join(suite.tempdir, "dbc.toml") lockPath := filepath.Join(suite.tempdir, "dbc.lock") - lockContents := []byte("version = 2\nrevision = 0\n" + + lockContents := []byte("version = 3\nrevision = 0\n" + "[[drivers]]\nname = 'test-driver-1'\nversion = '1.0.0'\n" + - "[drivers.source]\ntype = 'packslip'\n" + + "[drivers.source]\ntype = 'packslip'\nproject = 'github.com/example/test-driver-1'\n" + "[[drivers.artifacts]]\nformat = 'tar.gz'\n") suite.Require().NoError(os.WriteFile(listPath, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0o644)) suite.Require().NoError(os.WriteFile(lockPath, lockContents, 0o644)) - m := SyncCmd{Path: listPath}.GetModelCustom(testBaseModel()) - output := suite.runCmdErr(m) - suite.Contains(output, "unsupported lock file version 2") + model := SyncCmd{Path: listPath}.GetModelCustom(testBaseModel()) + output := suite.runCmdErr(model) + suite.Contains(output, "unsupported lock file version 3") actual, err := os.ReadFile(lockPath) suite.Require().NoError(err) suite.Equal(lockContents, actual) suite.NoFileExists(filepath.Join(suite.tempdir, "test-driver-1.toml")) } - -func (suite *SubcommandTestSuite) TestSyncWithVersion() { - tests := []struct { - driver string - expectedVersion string - }{ - {"test-driver-1=1.0.0", "1.0.0"}, - {"test-driver-1<=1.0.0", "1.0.0"}, - {"test-driver-1<1.1.0", "1.0.0"}, - {"test-driver-1~1.0", "1.0.0"}, - {"test-driver-1^1.0", "1.1.0"}, - } - - for _, tt := range tests { - suite.Run(tt.driver, func() { - m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() - suite.runCmd(m) - - m = AddCmd{Path: filepath.Join(suite.tempdir, "dbc.toml"), Driver: []string{tt.driver}}.GetModel() - suite.runCmd(m) - - m = SyncCmd{ - Path: filepath.Join(suite.tempdir, "dbc.toml"), - }.GetModelCustom( - testBaseModel()) - suite.validateOutput("✓ test-driver-1-"+tt.expectedVersion+"\r\n\rDone!\r\n", "", suite.runCmd(m)) - suite.FileExists(filepath.Join(suite.tempdir, "test-driver-1.toml")) - suite.FileExists(filepath.Join(suite.tempdir, "dbc.lock")) - - for _, f := range suite.getFilesInTempDir() { - os.Remove(filepath.Join(suite.tempdir, f)) - } - }) - } -} - -func (suite *SubcommandTestSuite) TestSyncVirtualEnv() { - suite.T().Setenv("ADBC_DRIVER_PATH", "") - - m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() - suite.runCmd(m) - - m = AddCmd{Path: filepath.Join(suite.tempdir, "dbc.toml"), Driver: []string{"test-driver-1"}}.GetModel() - suite.runCmd(m) - - suite.T().Setenv("VIRTUAL_ENV", suite.tempdir) - - m = SyncCmd{ - Path: filepath.Join(suite.tempdir, "dbc.toml"), - }.GetModelCustom( - testBaseModel()) - suite.validateOutput("✓ test-driver-1-1.1.0\r\n\rDone!\r\n", "", suite.runCmd(m)) - suite.FileExists(filepath.Join(suite.tempdir, "etc", "adbc", "drivers", "test-driver-1.toml")) - - m = SyncCmd{ - Path: filepath.Join(suite.tempdir, "dbc.toml"), - }.GetModelCustom( - testBaseModel()) - suite.validateOutput("✓ test-driver-1-1.1.0 already installed\r\n\rDone!\r\n", "", suite.runCmd(m)) -} - -func (suite *SubcommandTestSuite) TestSyncCondaPrefix() { - suite.T().Setenv("ADBC_DRIVER_PATH", "") - - m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() - suite.runCmd(m) - - m = AddCmd{Path: filepath.Join(suite.tempdir, "dbc.toml"), Driver: []string{"test-driver-1"}}.GetModel() - suite.runCmd(m) - - suite.T().Setenv("CONDA_PREFIX", suite.tempdir) - - m = SyncCmd{ - Path: filepath.Join(suite.tempdir, "dbc.toml"), - }.GetModelCustom( - testBaseModel()) - suite.validateOutput("✓ test-driver-1-1.1.0\r\n\rDone!\r\n", "", suite.runCmd(m)) - suite.FileExists(filepath.Join(suite.tempdir, "etc", "adbc", "drivers", "test-driver-1.toml")) - - m = SyncCmd{ - Path: filepath.Join(suite.tempdir, "dbc.toml"), - }.GetModelCustom( - testBaseModel()) - suite.validateOutput("✓ test-driver-1-1.1.0 already installed\r\n\rDone!\r\n", "", suite.runCmd(m)) -} - -func (suite *SubcommandTestSuite) TestSyncInstallFailSig() { - m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() - suite.runCmd(m) - - m = AddCmd{Path: filepath.Join(suite.tempdir, "dbc.toml"), Driver: []string{"test-driver-no-sig"}}.GetModel() - suite.runCmd(m) - - m = SyncCmd{ - 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()) -} - -func (suite *SubcommandTestSuite) TestSyncInstallNoVerify() { - m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() - suite.runCmd(m) - - m = AddCmd{Path: filepath.Join(suite.tempdir, "dbc.toml"), Driver: []string{"test-driver-no-sig"}}.GetModel() - suite.runCmd(m) - - m = SyncCmd{ - Path: filepath.Join(suite.tempdir, "dbc.toml"), - NoVerify: true, - }.GetModelCustom( - testBaseModel()) - suite.validateOutput("✓ test-driver-no-sig-1.1.0\r\n\rDone!\r\n", "", suite.runCmd(m)) -} - -func (suite *SubcommandTestSuite) TestSyncPartialRegistryFailure() { - // Initialize driver list - m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() - suite.runCmd(m) - - m = AddCmd{Path: filepath.Join(suite.tempdir, "dbc.toml"), Driver: []string{"test-driver-1"}}.GetModel() - suite.runCmd(m) - - // Test that sync command handles partial registry failure gracefully - // (one registry succeeds, another fails - returns both drivers and error) - partialFailingRegistry := func() ([]dbc.Driver, error) { - // Get drivers from the test registry (simulating one successful registry) - drivers, _ := getTestDriverRegistry() - // But also return an error (simulating another registry that failed) - return drivers, fmt.Errorf("registry https://backup-registry.example.com: failed to fetch driver registry: network timeout") - } - - // Should succeed if the requested driver is found in the available drivers - m = SyncCmd{ - Path: filepath.Join(suite.tempdir, "dbc.toml"), - }.GetModelCustom( - baseModel{getDriverRegistry: partialFailingRegistry, downloadPkg: downloadTestPkg}) - - // Should install successfully without printing the registry error - 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")) -} - -func (suite *SubcommandTestSuite) TestSyncPartialRegistryFailureDriverNotFound() { - // Initialize driver list with a driver that doesn't exist - m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() - suite.runCmd(m) - - // Manually create a driver list with a nonexistent driver - err := os.WriteFile(filepath.Join(suite.tempdir, "dbc.toml"), []byte(`# dbc driver list -[drivers] -[drivers.nonexistent-driver] -`), 0644) - suite.Require().NoError(err) - - // Test that sync command shows registry errors when the requested driver is not found - partialFailingRegistry := func() ([]dbc.Driver, error) { - // Get drivers from the test registry (simulating one successful registry) - drivers, _ := getTestDriverRegistry() - // But also return an error (simulating another registry that failed) - return drivers, fmt.Errorf("registry https://backup-registry.example.com: failed to fetch driver registry: network timeout") - } - - // Should fail with enhanced error message if the requested driver is not found - m = SyncCmd{ - Path: filepath.Join(suite.tempdir, "dbc.toml"), - }.GetModelCustom( - baseModel{getDriverRegistry: partialFailingRegistry, downloadPkg: downloadTestPkg}) - - out := suite.runCmdErr(m) - // Should show the driver not found error AND the registry error - suite.Contains(out, "driver `nonexistent-driver` not found") - suite.Contains(out, "Note: Some driver registries were unavailable") - suite.Contains(out, "failed to fetch driver registry") - suite.Contains(out, "network timeout") -} - -func (suite *SubcommandTestSuite) TestSyncWithProjectRegistries() { - m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() - suite.runCmd(m) - - err := os.WriteFile(filepath.Join(suite.tempdir, "dbc.toml"), []byte(`# dbc driver list -[[registries]] -url = 'https://custom-registry.example.com' -name = 'custom' - -[drivers] -[drivers.test-driver-1] -`), 0644) - suite.Require().NoError(err) - - m = SyncCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModelCustom(testBaseModel()) - 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")) - - if os.Getenv("DBC_BASE_URL") == "" { - suite.Require().NotNil(dbcClient) - found := false - for _, r := range dbcClient.Registries() { - if r.BaseURL != nil && r.BaseURL.String() == "https://custom-registry.example.com" { - found = true - break - } - } - suite.True(found, "expected custom registry in active client registries after sync with [[registries]] in dbc.toml") - } -} - -func (suite *SubcommandTestSuite) TestSyncWithProjectRegistriesBackwardCompat() { - m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() - suite.runCmd(m) - - m = AddCmd{Path: filepath.Join(suite.tempdir, "dbc.toml"), Driver: []string{"test-driver-1"}}.GetModel() - suite.runCmd(m) - - m = SyncCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModelCustom(testBaseModel()) - 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")) -} - -func (suite *SubcommandTestSuite) TestSyncCompleteRegistryFailure() { - // Initialize driver list - m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() - suite.runCmd(m) - - m = AddCmd{Path: filepath.Join(suite.tempdir, "dbc.toml"), Driver: []string{"test-driver-1"}}.GetModel() - suite.runCmd(m) - - // Test that sync command handles complete registry failure (no drivers returned) - completeFailingRegistry := func() ([]dbc.Driver, error) { - return nil, fmt.Errorf("registry https://primary-registry.example.com: connection refused") - } - - m = SyncCmd{ - Path: filepath.Join(suite.tempdir, "dbc.toml"), - }.GetModelCustom( - baseModel{getDriverRegistry: completeFailingRegistry, downloadPkg: downloadTestPkg}) - - out := suite.runCmdErr(m) - suite.Contains(out, "connection refused") -} - -func (suite *SubcommandTestSuite) TestSync_JSONStream() { - tmpDir := suite.T().TempDir() - driverListPath := filepath.Join(tmpDir, "dbc.toml") - err := os.WriteFile(driverListPath, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0644) - suite.Require().NoError(err) - - m := SyncCmd{Path: driverListPath, Json: true}. - GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) - out := suite.runCmd(m) - - lines := strings.Split(strings.TrimSpace(out), "\n") - suite.Greater(len(lines), 0, "expected at least one NDJSON line") - - var lastEnv jsonschema.Envelope - for _, line := range lines { - if line == "" { - continue - } - var env jsonschema.Envelope - suite.Require().NoError(json.Unmarshal([]byte(line), &env), "line must be valid JSON: %s", line) - suite.Equal(1, env.SchemaVersion) - lastEnv = env - } - suite.Equal("sync.status", lastEnv.Kind) - - var status jsonschema.SyncStatus - suite.Require().NoError(json.Unmarshal(lastEnv.Payload, &status)) - suite.Len(status.Installed, 1) - suite.Equal("test-driver-1", status.Installed[0].Name) -} - -func (suite *SubcommandTestSuite) TestSync_JSONProgressStream() { - tmpDir := suite.T().TempDir() - driverListPath := filepath.Join(tmpDir, "dbc.toml") - err := os.WriteFile(driverListPath, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0644) - suite.Require().NoError(err) - - m := SyncCmd{Path: driverListPath, JsonStreamProgress: true}. - GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) - out := suite.runCmd(m) - - lines := strings.Split(strings.TrimSpace(out), "\n") - suite.Greater(len(lines), 1, "expected multiple NDJSON lines") - - var kinds []string - for _, line := range lines { - if line == "" { - continue - } - var env jsonschema.Envelope - suite.Require().NoError(json.Unmarshal([]byte(line), &env), "line must be valid JSON: %s", line) - suite.Equal(1, env.SchemaVersion) - kinds = append(kinds, env.Kind) - } - - suite.Contains(kinds, "sync.progress") - suite.Equal("sync.status", kinds[len(kinds)-1]) -} diff --git a/cmd/dbc/sync_test_helpers_test.go b/cmd/dbc/sync_test_helpers_test.go new file mode 100644 index 00000000..5f8b6d5b --- /dev/null +++ b/cmd/dbc/sync_test_helpers_test.go @@ -0,0 +1,121 @@ +// 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 main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "fmt" + "os" + "strings" + "testing" + + "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/packslip" + "github.com/columnar-tech/dbc/internal/resolution" + "github.com/stretchr/testify/require" +) + +func mustTestInstallItem(t *testing.T, release resolution.ResolvedRelease, platform string, lockEntry *lockInfo) installItem { + t.Helper() + item, err := newInstallItem(release, 0, platform, lockEntry) + require.NoError(t, err) + return item +} + +type syncPackslipResolverStub struct { + release resolution.ResolvedRelease + calls int + project string + request packslip.Request +} + +func (stub *syncPackslipResolverStub) Resolve(_ context.Context, source packslip.PackslipSource, request packslip.Request) (resolution.ResolvedRelease, error) { + stub.calls++ + stub.project = source.Project + stub.request = request + return cloneResolvedReleaseForSync(stub.release), nil +} + +func makeSyncLegacyPackageArchive(t *testing.T, path, version string) ([]byte, string) { + t.Helper() + metadata := []byte(fmt.Sprintf(`manifest_version = 1 +name = "Test Driver" +version = %q + +[Driver] +entrypoint = "TestDriverInit" + +[Files] +driver = "driver.so" +`, version)) + var archive bytes.Buffer + gzipWriter := gzip.NewWriter(&archive) + tarWriter := tar.NewWriter(gzipWriter) + for _, entry := range []struct { + name string + data []byte + }{{name: "MANIFEST", data: metadata}, {name: "driver.so", data: []byte("test library bytes")}} { + require.NoError(t, tarWriter.WriteHeader(&tar.Header{Name: entry.name, Mode: 0o600, Size: int64(len(entry.data)), Typeflag: tar.TypeReg})) + _, err := tarWriter.Write(entry.data) + require.NoError(t, err) + } + require.NoError(t, tarWriter.Close()) + require.NoError(t, gzipWriter.Close()) + archiveBytes := archive.Bytes() + require.NoError(t, os.WriteFile(path, archiveBytes, 0o600)) + digest, err := checksum(path) + require.NoError(t, err) + return archiveBytes, "sha256:" + digest +} + +func makeSyncPackslipRelease(id, version, url, hash string, size int64) resolution.ResolvedRelease { + primary := testTarget(config.PlatformTuple()) + secondary := testTarget(differentTestPlatformTuple(config.PlatformTuple())) + otherSize := int64(9) + return resolution.ResolvedRelease{ + DriverID: id, + Version: version, + Source: resolution.SourceSpec{Type: "packslip", Reference: "github.com/example/driver"}, + Evidence: []resolution.Evidence{{ + Kind: resolution.EvidenceKindReleaseMetadata, + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://github.com/example/driver/releases/download/v1.2.3/packslip.sigstore.json"}, + Hash: "sha256:" + strings.Repeat("a", 64), + }}, + Artifacts: []resolution.Artifact{ + {Target: primary, Format: "tgz", Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: url}, Hash: hash, Size: &size}, + {Target: secondary, Format: "tar.gz", Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://assets.example.test/macos.tar.gz"}, Hash: "sha256:" + strings.Repeat("b", 64), Size: &otherSize}, + }, + } +} + +func differentTestPlatformTuple(platform string) string { + host, err := resolution.TargetFromPlatformTuple(platform) + if err != nil { + panic(err) + } + for _, candidate := range []string{"linux_amd64", "linux_arm64", "macos_amd64", "macos_arm64", "windows_amd64"} { + target, err := resolution.TargetFromPlatformTuple(candidate) + if err != nil { + panic(err) + } + if target != host { + return candidate + } + } + panic("no alternate test platform is available") +} diff --git a/cmd/dbc/sync_ui.go b/cmd/dbc/sync_ui.go new file mode 100644 index 00000000..cf5be335 --- /dev/null +++ b/cmd/dbc/sync_ui.go @@ -0,0 +1,197 @@ +// 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 main + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/progress" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/columnar-tech/dbc/internal/jsonschema" +) + +func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + s.width, s.height = msg.Width, msg.Height + case spinner.TickMsg: + var cmd tea.Cmd + s.spinner, cmd = s.spinner.Update(msg) + return s, cmd + case progress.FrameMsg: + var cmd tea.Cmd + s.progress, cmd = s.progress.Update(msg) + return s, cmd + case syncItemsMsg: + s.spinner = spinner.New() + s.progress = progress.New( + progress.WithDefaultBlend(), + progress.WithWidth(40), + progress.WithoutPercentage(), + ) + s.installItems = msg.items + return s, tea.Batch(s.worker.nextEvent(), s.spinner.Tick) + case syncResolvingMsg: + if s.jsonStreamProgress { + s.emitJSON("sync.progress", jsonschema.SyncProgressEvent{ + Phase: "resolving", + Driver: msg.driver, + }) + } + return s, s.worker.nextEvent() + case syncLockMigratedMsg: + // The worker sends this only after the candidate v2 lock has been + // persisted atomically. Keep it on the model for the final JSON status, + // while streaming an explicit event only in progress mode. + s.migration = &jsonschema.SyncMigration{ + FromVersion: msg.fromVersion, + ToVersion: msg.toVersion, + } + if s.jsonStreamProgress { + s.emitJSON("sync.migration", *s.migration) + return s, s.worker.nextEvent() + } + if s.jsonOutput { + return s, s.worker.nextEvent() + } + return s, tea.Sequence( + tea.Printf("%s", syncMigrationMessage(*s.migration)), + s.worker.nextEvent(), + ) + case alreadyInstalledDrvMsg: + s.skippedDrivers = append(s.skippedDrivers, jsonschema.SyncedDriver{ + Name: msg.info.ID, + Version: msg.info.Version.String(), + }) + + if s.jsonStreamProgress { + s.emitJSON("sync.progress", jsonschema.SyncProgressEvent{ + Phase: "skipped", + Driver: msg.info.ID, + Version: msg.info.Version.String(), + }) + } + percent := syncProgressPercent(s.index, len(s.installItems)) + if s.index < len(s.installItems)-1 { + s.index++ + } + progressCmd := s.progress.SetPercent(percent) + if s.jsonOutput { + return s, tea.Batch(progressCmd, s.worker.nextEvent()) + } + return s, tea.Batch( + progressCmd, + tea.Sequence(tea.Printf("%s %s-%s already installed", checkMark, msg.info.ID, msg.info.Version), s.worker.nextEvent()), + ) + case installedDrvMsg: + s.newlyInstalled = append(s.newlyInstalled, jsonschema.SyncedDriver{ + Name: msg.info.ID, + Version: msg.info.Version.String(), + }) + + if s.jsonStreamProgress { + s.emitJSON("sync.progress", jsonschema.SyncProgressEvent{ + Phase: "installed", + Driver: msg.info.ID, + Version: msg.info.Version.String(), + }) + } + + 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 { + printCmd = tea.Sequence( + printCmd, + tea.Printf("%s post-install: %s", checkMark, m), + ) + } + } + } + + percent := syncProgressPercent(s.index, len(s.installItems)) + if s.index < len(s.installItems)-1 { + s.index++ + } + progressCmd := s.progress.SetPercent(percent) + if s.jsonOutput { + return s, tea.Batch( + progressCmd, + s.worker.nextEvent(), + ) + } + return s, tea.Batch( + progressCmd, + tea.Sequence(printCmd, s.worker.nextEvent()), + ) + case syncWorkerResultMsg: + if msg.err != nil { + return s.fail(msg.code, msg.err) + } + s.locked = msg.lock + s.done = true + s.terminal = true + return s, tea.Quit + case error: + return s.fail("sync_failed", msg) + } + + bm, cmd := s.baseModel.Update(msg) + s.baseModel = bm.(baseModel) + + return s, cmd +} + +func (s syncModel) View() tea.View { + if s.status != 0 { + return tea.NewView("") + } + + n := len(s.installItems) + if n == 0 { + return tea.NewView("Determining drivers to install...") + } + w := lipgloss.Width(fmt.Sprintf("%d", n)) + + if s.done { + return tea.NewView("Done!\n") + } + + driverCount := fmt.Sprintf(" %*d/%*d", w, s.index, w, n) + + spin := s.spinner.View() + " " + prog := s.progress.View() + cellsAvail := max(0, s.width-lipgloss.Width(spin+prog+driverCount)) + + driverIndex := min(s.index, len(s.installItems)-1) + driverName := s.installItems[driverIndex].Release.DriverID + info := lipgloss.NewStyle().MaxWidth(cellsAvail).Render("Installing " + driverName) + + cellsRemaining := max(0, s.width-lipgloss.Width(spin+info+prog+driverCount)) + gap := strings.Repeat(" ", max(0, cellsRemaining)) + + return tea.NewView(spin + info + gap + prog + driverCount) +} diff --git a/cmd/dbc/sync_worker.go b/cmd/dbc/sync_worker.go new file mode 100644 index 00000000..1acae52e --- /dev/null +++ b/cmd/dbc/sync_worker.go @@ -0,0 +1,178 @@ +// 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 main + +import ( + "context" + "fmt" + "sync" + + "charm.land/bubbletea/v2" + "github.com/columnar-tech/dbc/config" +) + +type syncWorkerHooks struct { + duringPrepare func(context.Context, int, installItem) error + beforeCandidateSave func(context.Context) error + ensurePackage func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) +} + +type syncWorker struct { + ctx context.Context + cancel context.CancelFunc + done chan struct{} + events chan tea.Msg + abandon chan struct{} + stateMu sync.Mutex + started bool + abandoned bool + finish sync.Once + hooks syncWorkerHooks + // inputLockVersion belongs to the worker that owns the sync transaction. + // Keeping it here avoids relying on a copied tea model after the worker has + // read the input lock and before it persists the v2 candidate. + inputLockVersion int +} + +func newSyncWorker() *syncWorker { + ctx, cancel := context.WithCancel(context.Background()) + return &syncWorker{ + ctx: ctx, cancel: cancel, done: make(chan struct{}), + events: make(chan tea.Msg, 1), abandon: make(chan struct{}), + } +} + +type syncWorkerResultMsg struct { + err error + code string + lock LockFile +} + +type syncLockMigratedMsg struct { + fromVersion int + toVersion int +} + +type syncResolvingMsg struct{ driver string } + +type syncItemsMsg struct{ items []installItem } + +func (s syncModel) Init() tea.Cmd { + return func() tea.Msg { + if s.worker == nil { + s.worker = newSyncWorker() + } + return s.worker.start(s) + } +} + +func (w *syncWorker) start(s syncModel) tea.Msg { + w.stateMu.Lock() + if w.abandoned { + w.stateMu.Unlock() + return nil + } + if !w.started { + w.started = true + go w.run(s) + } + w.stateMu.Unlock() + return w.receive() +} + +func (w *syncWorker) hasStarted() bool { + w.stateMu.Lock() + defer w.stateMu.Unlock() + return w.started +} + +func (w *syncWorker) nextEvent() tea.Cmd { + return func() tea.Msg { return w.receive() } +} + +func (w *syncWorker) send(ctx context.Context, msg tea.Msg) bool { + select { + case w.events <- msg: + return true + case <-ctx.Done(): + return false + case <-w.abandon: + return false + } +} + +func (w *syncWorker) receive() tea.Msg { + select { + case <-w.abandon: + return nil + default: + } + select { + case msg := <-w.events: + return msg + case <-w.abandon: + return nil + } +} + +func (w *syncWorker) run(s syncModel) { + defer w.finish.Do(func() { close(w.done) }) + defer w.cancel() + result := s.runSyncWorker(w) + select { + case w.events <- result: + case <-w.abandon: + } +} + +func (w *syncWorker) abandonProgram() { + w.stateMu.Lock() + if !w.abandoned { + w.abandoned = true + w.cancel() + close(w.abandon) + } + started := w.started + if !started { + w.finish.Do(func() { close(w.done) }) + } + w.stateMu.Unlock() + if started { + <-w.done + } +} + +func loadDriverList(path string) (DriversList, error) { + list, err := openAndDecodeDriverList(path) + if err != nil { + return DriversList{}, err + } + if len(list.Drivers) == 0 { + return DriversList{}, fmt.Errorf("no drivers found in driver list `%s`", path) + } + return list, nil +} + +func reloadConfigTarget(selected config.Config) config.Config { + refreshed, ok := config.Get()[selected.Level] + if !ok { + return selected + } + // Keep the exact target resolved when the sync model was constructed while + // refreshing its runtime manifest state after acquiring the project lock. + refreshed.Level = selected.Level + refreshed.Location = selected.Location + return refreshed +} diff --git a/cmd/dbc/testdata/test_index.yaml b/cmd/dbc/testdata/test_index.yaml index 1416321e..f94e81ec 100644 --- a/cmd/dbc/testdata/test_index.yaml +++ b/cmd/dbc/testdata/test_index.yaml @@ -87,7 +87,7 @@ drivers: license: Apache-2.0 path: test-driver-no-sig pkginfo: - - version: v1.0.0 + - version: v1.1.0 packages: - platform: linux_amd64 - platform: linux_arm64 diff --git a/cmd/dbc/uninstall_test.go b/cmd/dbc/uninstall_test.go index 3d6c1f00..db6b54fd 100644 --- a/cmd/dbc/uninstall_test.go +++ b/cmd/dbc/uninstall_test.go @@ -16,11 +16,11 @@ package main import ( "encoding/json" - "fmt" "os" "path" "path/filepath" "runtime" + "strings" "github.com/columnar-tech/dbc/config" "github.com/columnar-tech/dbc/internal/jsonschema" @@ -175,20 +175,30 @@ func (suite *SubcommandTestSuite) TestUninstallManifestOnlyDriver() { "\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)) + // Verify the receipt-owned package generation exists before uninstall. + entries, err := os.ReadDir(suite.Dir()) + suite.Require().NoError(err) + var generationPath string + for _, entry := range entries { + if entry.IsDir() && strings.HasPrefix(entry.Name(), ".dbc-package-test-driver-manifest-only-") { + generationPath = filepath.Join(suite.Dir(), entry.Name()) + break + } } - suite.DirExists(filepath.Join(suite.Dir(), new_sidecar_path)) + suite.Require().NotEmpty(generationPath) + suite.DirExists(generationPath) + externalLibrary := filepath.Join(suite.Dir(), "test_driver") + suite.Require().NoError(os.WriteFile(externalLibrary, []byte("shared external library"), 0o644)) // 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)) + suite.NoDirExists(generationPath) + externalContents, err := os.ReadFile(externalLibrary) + suite.Require().NoError(err) + suite.Equal("shared external library", string(externalContents)) } // See https://github.com/columnar-tech/dbc/issues/37 @@ -200,10 +210,11 @@ func (suite *SubcommandTestSuite) TestUninstallInvalidManifest() { 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")) + manifestPath := filepath.Join(suite.Dir(), "test-driver-invalid-manifest.toml") + suite.FileExists(manifestPath) - // 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 + // The legacy package explicitly declares a relative runtime load path. It must + // remain separate from the managed package generation. type partialManifest struct { Driver struct { Shared map[string]string `toml:"shared"` @@ -211,31 +222,40 @@ func (suite *SubcommandTestSuite) TestUninstallInvalidManifest() { } var invalidManifest partialManifest f, err := os.Open(filepath.Join(suite.Dir(), "test-driver-invalid-manifest.toml")) - if err != nil { - suite.Error(err) - } + suite.Require().NoError(err) err = toml.NewDecoder(f).Decode(&invalidManifest) - if err != nil { - suite.Error(err) - } + suite.Require().NoError(err) + suite.Require().NoError(f.Close()) value := invalidManifest.Driver.Shared[config.PlatformTuple()] - // Assert that it's a folder - suite.DirExists(value) - // and continue + suite.Equal("libadbc_driver_invalid_manifest.so", value) + externalLibrary := filepath.Join(suite.Dir(), value) + suite.Require().NoError(os.WriteFile(externalLibrary, []byte("external library"), 0o644)) + packageEntries, err := os.ReadDir(suite.Dir()) + suite.Require().NoError(err) + runtimeManifest, err := os.ReadFile(manifestPath) + suite.Require().NoError(err) + suite.Contains(string(runtimeManifest), value) 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 + // 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")) + // The receipt-owned package is removed, while the external runtime library is + // retained because it is not inside that package generation. + for _, entry := range packageEntries { + if entry.IsDir() && strings.HasPrefix(entry.Name(), ".dbc-package-test-driver-invalid-manifest-") { + suite.NoDirExists(filepath.Join(suite.Dir(), entry.Name())) + } + } + externalContents, err := os.ReadFile(externalLibrary) + suite.Require().NoError(err) + suite.Equal("external library", string(externalContents)) } func (suite *SubcommandTestSuite) TestUninstallRemovesSymlink() { diff --git a/config/config.go b/config/config.go index a7b725fb..e35824c6 100644 --- a/config/config.go +++ b/config/config.go @@ -15,8 +15,6 @@ package config import ( - "archive/tar" - "compress/gzip" "errors" "fmt" "io" @@ -224,95 +222,64 @@ func getEnvConfigDir() string { } func InstallDriver(cfg Config, shortName string, downloaded *os.File) (Manifest, error) { - var ( - loc string - err error - ) - if loc, err = EnsureLocation(cfg); err != nil { - return Manifest{}, fmt.Errorf("could not ensure config location: %w", err) + if downloaded == nil { + return Manifest{}, errors.New("package archive is nil") } + defer downloaded.Close() base := strings.TrimSuffix(strings.TrimSuffix(filepath.Base(downloaded.Name()), ".tar.gz"), ".tgz") - finalDir := filepath.Join(loc, base) - - if err := os.MkdirAll(finalDir, 0o755); err != nil { - return Manifest{}, fmt.Errorf("failed to create driver directory %s: %w", finalDir, err) - } - - manifest, err := InflateTarball(downloaded, finalDir) - if err != nil { - return Manifest{}, fmt.Errorf("failed to extract tarball: %w", err) + expected := ExpectedPackageMetadata{ + ID: shortName, SourceType: "dbc", SourceIdentity: "legacy-install", } - - driverPath := filepath.Join(finalDir, manifest.Files.Driver) - - manifest.DriverInfo.ID = shortName - manifest.DriverInfo.Source = "dbc" - manifest.DriverInfo.Driver.Shared.Set(PlatformTuple(), driverPath) - - return manifest, nil + return installPackageArchive(cfg, base, shortName, downloaded, expected) } // 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) { + if f == nil { + return Manifest{}, errors.New("package archive is nil") + } 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) + info, err := os.Stat(outDir) + if err != nil { + return Manifest{}, fmt.Errorf("could not access output directory %s: %w", outDir, err) + } + if !info.IsDir() { + return Manifest{}, fmt.Errorf("output path %s is not a directory", outDir) } - rdr, err := gzip.NewReader(f) + workDir, err := os.MkdirTemp(outDir, ".dbc-inflate-") if err != nil { - return m, fmt.Errorf("could not create gzip reader: %w", err) + return Manifest{}, fmt.Errorf("could not create private extraction staging directory: %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) - } - - } + defer os.RemoveAll(workDir) + archivePath := filepath.Join(workDir, "archive.tgz") + if _, _, err := snapshotArchive(f, archivePath); err != nil { + return Manifest{}, fmt.Errorf("could not snapshot archive: %w", err) } - - return m, nil + payloadDir := filepath.Join(workDir, "payload") + if err := os.Mkdir(payloadDir, 0o700); err != nil { + return Manifest{}, fmt.Errorf("could not create private extraction directory: %w", err) + } + manifest, files, err := extractPackageArchive(archivePath, payloadDir) + if err != nil { + return Manifest{}, fmt.Errorf("could not extract tarball: %w", err) + } + names := make([]string, 0, len(files)) + for _, name := range files { + names = append(names, name) + } + slices.Sort(names) + if err := publishExtractedFiles(payloadDir, outDir, names); err != nil { + return Manifest{}, err + } + return manifest, nil } func decodeManifest(r io.Reader, driverName string, requireShared bool) (Manifest, error) { - var di tomlDriverInfo + var di runtimeManifestWire if err := toml.NewDecoder(r).Decode(&di); err != nil { return Manifest{}, fmt.Errorf("error decoding manifest: %w", err) } - if di.ManifestVersion > currentManifestVersion { return Manifest{}, fmt.Errorf("manifest version %d is unsupported, only %d and lower are supported by this version of dbc", di.ManifestVersion, currentManifestVersion) @@ -326,6 +293,10 @@ func decodeManifest(r io.Reader, driverName string, requireShared bool) (Manifes return Manifest{}, fmt.Errorf("%w: version is required", ErrInvalidManifest) } + shared, err := decodeDriverShared(di.Driver.Shared, requireShared) + if err != nil { + return Manifest{}, err + } result := Manifest{ DriverInfo: DriverInfo{ ID: driverName, @@ -341,23 +312,7 @@ func decodeManifest(r io.Reader, driverName string, requireShared bool) (Manifes } result.Driver.Entrypoint = di.Driver.Entrypoint - switch s := di.Driver.Shared.(type) { - case string: - result.Driver.Shared.defaultPath = s - case map[string]any: - result.Driver.Shared.platformMap = make(map[string]string) - for k, v := range s { - if strVal, ok := v.(string); ok { - result.Driver.Shared.platformMap[k] = strVal - } else { - return Manifest{}, fmt.Errorf("%w: invalid type for platform %s, expected string", ErrInvalidManifest, k) - } - } - default: - if requireShared { - return Manifest{}, fmt.Errorf("%w: invalid type for 'Driver.shared' in manifest, expected string or table", ErrInvalidManifest) - } - } + result.Driver.Shared = shared return result, nil } @@ -374,6 +329,9 @@ func UninstallDriverShared(info DriverInfo) error { } else if strings.Contains(info.FilePath, "HKLM\\") { filesystemLocation = ConfigSystem.ConfigLocation() } + if info.Source == "dbc" { + return cleanupOwnedPackageDirectories(filesystemLocation, info.ID, &info, "", DriverInfo{}) + } root, err := os.OpenRoot(filesystemLocation) if err != nil { @@ -391,49 +349,17 @@ func UninstallDriverShared(info DriverInfo) error { 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 == "." { + if err := root.Remove(sharedPath); err != nil { + // Ignore only when not found. This supports manifest-only drivers. + if errors.Is(err, fs.ErrNotExist) { 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) - } + 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." + // Preserve the historical non-dbc cleanup behavior for guessed directories. + // dbc packages use receipt or legacy ownership checks above. extraFolder := fmt.Sprintf("%s_%s_v%s", info.ID, platformTuple, info.Version) extraFolder = filepath.Clean(extraFolder) finfo, err := root.Stat(extraFolder) @@ -444,3 +370,33 @@ func UninstallDriverShared(info DriverInfo) error { return nil } + +func cleanupUninstalledDriverPackagesWithRemoveAll(cfg Config, info DriverInfo, removeAll func(string) error) error { + if info.Source != "dbc" { + return nil + } + location, err := uninstallPackageCleanupLocation(cfg, info) + if err != nil { + return fmt.Errorf("could not resolve package cleanup location: %w", err) + } + return cleanupOwnedPackageDirectoriesWithRemoveAll(location, info.ID, &info, "", DriverInfo{}, removeAll) +} + +func cleanupUninstalledDriverPackages(cfg Config, info DriverInfo) error { + return cleanupUninstalledDriverPackagesWithRemoveAll(cfg, info, os.RemoveAll) +} + +func cleanupUninstalledDriverPackagesAfterRegistrationRemoval(cfg Config, info DriverInfo) error { + return packageCleanupAfterUninstallError(cleanupUninstalledDriverPackages(cfg, info)) +} + +func cleanupUninstalledDriverPackagesAfterRegistrationRemovalWithRemoveAll(cfg Config, info DriverInfo, removeAll func(string) error) error { + return packageCleanupAfterUninstallError(cleanupUninstalledDriverPackagesWithRemoveAll(cfg, info, removeAll)) +} + +func packageCleanupAfterUninstallError(err error) error { + if err == nil { + return nil + } + return fmt.Errorf("driver registration was removed but owned package cleanup was incomplete: %w", err) +} diff --git a/config/config_api_test.go b/config/config_api_test.go index 8422caaf..6c0572b2 100644 --- a/config/config_api_test.go +++ b/config/config_api_test.go @@ -316,18 +316,22 @@ 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") + driverID := "test-driver-1" + version := semver.MustParse("1.0.0") + platform := config.PlatformTuple() + driverDir := filepath.Join(tmpDir, driverID+"_"+platform+"_v"+version.String()) 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", + ID: driverID, FilePath: tmpDir, + Version: version, Source: "dbc", } - di.Driver.Shared.Set("linux_amd64", driverPath) + di.Driver.Shared.Set(platform, driverPath) err := config.UninstallDriverShared(di) require.NoError(t, err) diff --git a/config/dirs_unixlike.go b/config/dirs_unixlike.go index 8e019f0f..fc86dc2f 100644 --- a/config/dirs_unixlike.go +++ b/config/dirs_unixlike.go @@ -112,20 +112,27 @@ func CreateManifest(cfg Config, driver DriverInfo) (err error) { 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) - } +func UninstallDriver(cfg Config, info DriverInfo) error { + return uninstallDriverWithInstallLock(cfg, info, func() 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) + } - // 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) + // 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) - } + if info.Source != "dbc" { + if err := UninstallDriverShared(info); err != nil { + return fmt.Errorf("failed to delete driver shared object: %w", err) + } + } + if err := cleanupUninstalledDriverPackagesAfterRegistrationRemoval(cfg, info); err != nil { + return err + } - return nil + return nil + }) } diff --git a/config/dirs_windows.go b/config/dirs_windows.go index 5805fee5..91866f5a 100644 --- a/config/dirs_windows.go +++ b/config/dirs_windows.go @@ -22,8 +22,10 @@ import ( "os" "path/filepath" "slices" + "unsafe" "github.com/Masterminds/semver/v3" + "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" ) @@ -108,6 +110,17 @@ func keyOptional(k registry.Key, name string) string { return val } +func keyStringsOptional(k registry.Key, name string) []string { + val, _, err := k.GetStringsValue(name) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + return nil + } + panic(err) + } + return val +} + func setKeyMust(k registry.Key, name, value string) { if err := k.SetStringValue(name, value); err != nil { panic(err) @@ -151,6 +164,14 @@ func driverInfoFromKey(k registry.Key, driverName string, lvl ConfigLevel) (di D di.License = keyOptional(dkey, "license") di.Version = semver.MustParse(keyMust(dkey, "version")) di.Source = keyOptional(dkey, "source") + if adbcVersion := keyOptional(dkey, "adbc_version"); adbcVersion != "" { + di.AdbcInfo.Version, err = semver.NewVersion(adbcVersion) + if err != nil { + return DriverInfo{}, fmt.Errorf("invalid ADBC version in registry: %w", err) + } + } + di.AdbcInfo.Features.Supported = keyStringsOptional(dkey, "adbc_supported_features") + di.AdbcInfo.Features.Unsupported = keyStringsOptional(dkey, "adbc_unsupported_features") di.Driver.Shared.defaultPath = keyMust(dkey, "driver") di.Driver.Entrypoint = keyOptional(dkey, "entrypoint") @@ -270,85 +291,209 @@ func CreateManifest(cfg Config, driver DriverInfo) (err error) { } return createDriverManifest(loc, driver) } + if driver.Version == nil { + return fmt.Errorf("driver %s has no version", driver.ID) + } - var k registry.Key + k, _, err := registry.CreateKey(cfg.Level.key(), regKeyADBC, registry.ALL_ACCESS) + if err != nil { + return err + } + defer k.Close() - if !cfg.Exists { - k, _, err = registry.CreateKey(cfg.Level.key(), "SOFTWARE\\ADBC", registry.ALL_ACCESS) - if err != nil { - return err + dkey, openedExisting, err := registry.CreateKey(k, driver.ID, registry.ALL_ACCESS) + if err != nil { + return err + } + dkeyOpen := true + defer func() { + if dkeyOpen { + _ = dkey.Close() } - defer k.Close() - - k, _, err = registry.CreateKey(k, "Drivers", registry.ALL_ACCESS) - if err != nil { + }() + created := !openedExisting + valueNames := []string{ + "name", "manifest_version", "publisher", "license", "version", "source", "driver", "entrypoint", + "adbc_version", "adbc_supported_features", "adbc_unsupported_features", + } + snapshot, err := snapshotRegistryValues(dkey, valueNames) + if err != nil { + var rollbackErr error + if created { + dkeyOpen = false + rollbackErr = errors.Join(dkey.Close(), registry.DeleteKey(k, driver.ID)) + } + return registrationFailure(fmt.Errorf("could not snapshot existing driver registration: %w", err), rollbackErr) + } + rollback := func() error { + if created { + dkeyOpen = false + return errors.Join(dkey.Close(), registry.DeleteKey(k, driver.ID)) + } + return restoreRegistryValues(dkey, snapshot) + } + writeString := func(name, value string) error { + return dkey.SetStringValue(name, value) + } + writeInt := func(name string, value uint32) error { + return dkey.SetDWordValue(name, value) + } + for _, item := range []struct{ name, 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())}, + } { + if err := writeString(item.name, item.value); err != nil { + return registrationFailure(err, rollback()) + } + } + setOptionalString := func(name, value string) error { + if value != "" { + return dkey.SetStringValue(name, value) + } + if err := dkey.DeleteValue(name); err != nil && !errors.Is(err, registry.ErrNotExist) { return err } - defer k.Close() - } else { - k, err = registry.OpenKey(cfg.Level.key(), regKeyADBC, registry.ALL_ACCESS) - if err != nil { + return nil + } + setOptionalStrings := func(name string, values []string) error { + if len(values) > 0 { + return dkey.SetStringsValue(name, values) + } + if err := dkey.DeleteValue(name); err != nil && !errors.Is(err, registry.ErrNotExist) { return err } - defer k.Close() + return nil + } + adbcVersion := "" + if driver.AdbcInfo.Version != nil { + adbcVersion = driver.AdbcInfo.Version.String() + } + for _, write := range []struct { + name string + fn func() error + }{ + {name: "adbc_version", fn: func() error { return setOptionalString("adbc_version", adbcVersion) }}, + {name: "adbc_supported_features", fn: func() error { + return setOptionalStrings("adbc_supported_features", driver.AdbcInfo.Features.Supported) + }}, + {name: "adbc_unsupported_features", fn: func() error { + return setOptionalStrings("adbc_unsupported_features", driver.AdbcInfo.Features.Unsupported) + }}, + } { + if err := write.fn(); err != nil { + return registrationFailure(fmt.Errorf("could not write registry value %s: %w", write.name, err), rollback()) + } + } + if err := writeInt("manifest_version", currentManifestVersion); err != nil { + return registrationFailure(err, rollback()) + } + if driver.Driver.Entrypoint == "" { + if err := dkey.DeleteValue("entrypoint"); err != nil && !errors.Is(err, registry.ErrNotExist) { + return registrationFailure(err, rollback()) + } + } else if err := writeString("entrypoint", driver.Driver.Entrypoint); err != nil { + return registrationFailure(err, rollback()) } + return nil +} - dkey, _, err := registry.CreateKey(k, driver.ID, registry.ALL_ACCESS) - if err != nil { - return err +type registryValueSnapshot struct { + present bool + typ uint32 + data []byte +} + +func snapshotRegistryValues(key registry.Key, names []string) (map[string]registryValueSnapshot, error) { + snapshot := make(map[string]registryValueSnapshot, len(names)) + for _, name := range names { + buffer := make([]byte, 64*1024) + n, typ, err := key.GetValue(name, buffer) + if errors.Is(err, registry.ErrNotExist) { + snapshot[name] = registryValueSnapshot{} + continue + } + if err != nil { + return nil, err + } + snapshot[name] = registryValueSnapshot{present: true, typ: typ, data: append([]byte(nil), buffer[:n]...)} } - defer dkey.Close() + return snapshot, nil +} - 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) - } +func restoreRegistryValues(key registry.Key, snapshot map[string]registryValueSnapshot) error { + var rollbackErr error + for name, value := range snapshot { + if value.present { + rollbackErr = errors.Join(rollbackErr, restoreRegistryValue(key, name, value)) + continue } - }() + if err := key.DeleteValue(name); err != nil && !errors.Is(err, registry.ErrNotExist) { + rollbackErr = errors.Join(rollbackErr, err) + } + } + return rollbackErr +} - 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 restoreRegistryValue(key registry.Key, name string, value registryValueSnapshot) error { + // Restore the captured registry type and bytes exactly as they were. + valueName, err := windows.UTF16PtrFromString(name) + if err != nil { + return err + } + var data uintptr + if len(value.data) > 0 { + data = uintptr(unsafe.Pointer(&value.data[0])) + } + proc := windows.NewLazySystemDLL("advapi32.dll").NewProc("RegSetValueExW") + status, _, _ := proc.Call(uintptr(key), uintptr(unsafe.Pointer(valueName)), 0, uintptr(value.typ), data, uintptr(uint32(len(value.data)))) + if status != 0 { + return windows.Errno(status) } 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 registrationFailure(writeErr, rollbackErr error) error { + if rollbackErr != nil { + return &manifestRollbackError{writeErr: writeErr, rollbackErr: rollbackErr} } + return writeErr +} - if cfg.Level != ConfigEnv { - k, err := registry.OpenKey(cfg.Level.key(), regKeyADBC, registry.ALL_ACCESS) - if err != nil { - return err +func UninstallDriver(cfg Config, info DriverInfo) error { + return uninstallDriverWithInstallLock(cfg, info, func() error { + if info.Source != "dbc" { + if err := UninstallDriverShared(info); err != nil { + return fmt.Errorf("failed to delete driver shared object: %w", err) + } } - defer k.Close() - if err := registry.DeleteKey(k, info.ID); err != nil { - return fmt.Errorf("failed to delete driver registry key: %w", err) + if cfg.Level != ConfigEnv { + k, err := registry.OpenKey(cfg.Level.key(), regKeyADBC, registry.ALL_ACCESS) + if err != nil { + return err + } + defer k.Close() + + 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) + } + + // TODO: Remove this when the driver managers are fixed (>=1.8.1). + removeManifestSymlink(info.FilePath, info.ID) } - } 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) + if err := cleanupUninstalledDriverPackagesAfterRegistrationRemoval(cfg, info); err != nil { + return err } - // TODO: Remove this when the driver managers are fixed (>=1.8.1). - removeManifestSymlink(info.FilePath, info.ID) - } - - return nil + return nil + }) } diff --git a/config/driver.go b/config/driver.go index c53b7767..d23ad1ca 100644 --- a/config/driver.go +++ b/config/driver.go @@ -15,6 +15,7 @@ package config import ( + "bytes" "errors" "fmt" "io/fs" @@ -24,6 +25,7 @@ import ( "strings" "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc/internal/atomicfile" "github.com/pelletier/go-toml/v2" ) @@ -115,7 +117,9 @@ func (d driverMap) String() string { return sb.String() } -type tomlDriverInfo struct { +// runtimeManifestWire is the installed ADBC Driver Manifest format. A legacy +// package's MANIFEST uses legacyPackageManifestWire. +type runtimeManifestWire struct { ManifestVersion int32 `toml:"manifest_version"` Name string `toml:"name"` Publisher string `toml:"publisher"` @@ -171,7 +175,16 @@ func createManifestSymlink(location, driverID, manifestPath string) { symlink := filepath.Join(parentDir, safeDriverID+".toml") if filepath.Dir(symlink) == parentDir { - os.Symlink(manifestPath, symlink) + parentAbs, parentErr := filepath.Abs(parentDir) + manifestAbs, manifestErr := filepath.Abs(manifestPath) + if parentErr != nil || manifestErr != nil { + return + } + target, err := filepath.Rel(parentAbs, manifestAbs) + if err != nil { + target = manifestAbs + } + _ = os.Symlink(target, symlink) } } @@ -181,8 +194,45 @@ func removeManifestSymlink(filePath, driverID string) { safeDriverID := filepath.Base(driverID) symlink := filepath.Join(parentDir, safeDriverID+".toml") - if filepath.Dir(symlink) == parentDir { - os.Remove(symlink) + if filepath.Dir(symlink) != parentDir { + return + } + linkInfo, err := os.Lstat(symlink) + if err != nil || linkInfo.Mode()&os.ModeSymlink == 0 { + return + } + target, err := os.Readlink(symlink) + if err != nil { + return + } + symlinkAbs, symlinkErr := filepath.Abs(symlink) + if symlinkErr != nil { + return + } + symlinkParentAbs := filepath.Dir(symlinkAbs) + expected, expectedErr := filepath.Abs(filepath.Join(filePath, safeDriverID+".toml")) + if expectedErr != nil { + return + } + targetsExpectedManifest := func(candidate string) bool { + actual, err := filepath.Abs(candidate) + return err == nil && filepath.Clean(actual) == filepath.Clean(expected) + } + isTargetRegistration := false + if filepath.IsAbs(target) { + isTargetRegistration = targetsExpectedManifest(target) + } else { + // New links are relative to their parent directory. Older links may + // contain the original relative manifestPath. Only the exact original + // value is accepted for that legacy form; do not reinterpret arbitrary + // relative targets from the process working directory. Resolve the link + // target against the symlink's absolute parent, as the filesystem does. + isTargetRegistration = targetsExpectedManifest(filepath.Join(symlinkParentAbs, target)) + legacyManifestPath := filepath.Join(filePath, safeDriverID+".toml") + isTargetRegistration = isTargetRegistration || target == legacyManifestPath + } + if isTargetRegistration { + _ = os.Remove(symlink) } } @@ -194,23 +244,7 @@ func createDriverManifest(location string, driver DriverInfo) error { } 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) - - toEncode := tomlDriverInfo{ + toEncode := runtimeManifestWire{ ManifestVersion: currentManifestVersion, Name: driver.Name, Publisher: driver.Publisher, @@ -227,11 +261,29 @@ func createDriverManifest(location string, driver DriverInfo) error { toEncode.Driver.Shared = driver.Driver.Shared.platformMap } - enc := toml.NewEncoder(f).SetIndentTables(false) - + var encoded bytes.Buffer + enc := toml.NewEncoder(&encoded).SetIndentTables(false) if err := enc.Encode(toEncode); err != nil { return fmt.Errorf("error encoding manifest %s: %w", driver.ID, err) } + if err := atomicfile.WriteFile(manifestPath, encoded.Bytes(), 0o644); err != nil { + return fmt.Errorf("error writing manifest %s: %w", driver.ID, err) + } + + // Work around older driver managers that look one directory above the + // configured manifest directory. + createManifestSymlink(location, driver.ID, manifestPath) return nil } + +type manifestRollbackError struct { + writeErr error + rollbackErr error +} + +func (e *manifestRollbackError) Error() string { + return fmt.Sprintf("%v; restoring previous manifest registration failed: %v", e.writeErr, e.rollbackErr) +} + +func (e *manifestRollbackError) Unwrap() error { return e.writeErr } diff --git a/config/driver_test.go b/config/driver_test.go index 6c94ee9a..d3225443 100644 --- a/config/driver_test.go +++ b/config/driver_test.go @@ -108,6 +108,124 @@ linux_amd64 = '/path/to/driver.so' `, string(data)) } +func TestRemoveManifestSymlinkOnlyRemovesTargetRegistration(t *testing.T) { + parent := t.TempDir() + registered := filepath.Join(parent, "registered") + other := filepath.Join(parent, "other") + require.NoError(t, os.MkdirAll(registered, 0755)) + require.NoError(t, os.MkdirAll(other, 0755)) + registeredManifest := filepath.Join(registered, "example.toml") + otherManifest := filepath.Join(other, "example.toml") + require.NoError(t, os.WriteFile(registeredManifest, []byte("registered"), 0644)) + require.NoError(t, os.WriteFile(otherManifest, []byte("other"), 0644)) + + link := filepath.Join(parent, "example.toml") + if err := os.Symlink(registeredManifest, link); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + removeManifestSymlink(registered, "example") + if _, err := os.Lstat(link); !os.IsNotExist(err) { + t.Fatalf("symlink to the requested manifest was not removed: %v", err) + } + + require.NoError(t, os.Symlink(otherManifest, link)) + removeManifestSymlink(registered, "example") + linkInfo, err := os.Lstat(link) + require.NoError(t, err, "symlink to a different manifest should be retained") + assert.NotZero(t, linkInfo.Mode()&os.ModeSymlink, "retained entry should still be a symlink") + target, err := os.Readlink(link) + require.NoError(t, err) + assert.Equal(t, otherManifest, target) +} + +func TestManifestSymlinkHandlesRelativeNestedLocations(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + location := filepath.Join(root, "nested", "registered") + parent := filepath.Dir(location) + manifest := filepath.Join(location, "example.toml") + otherManifest := filepath.Join(root, "other", "example.toml") + require.NoError(t, os.MkdirAll(location, 0755)) + require.NoError(t, os.MkdirAll(filepath.Dir(otherManifest), 0755)) + require.NoError(t, os.WriteFile(manifest, []byte("registered"), 0644)) + require.NoError(t, os.WriteFile(otherManifest, []byte("other"), 0644)) + + workingDirectory, err := os.Getwd() + require.NoError(t, err) + relativeLocation, err := filepath.Rel(workingDirectory, location) + require.NoError(t, err) + relativeManifest := filepath.Join(relativeLocation, "example.toml") + link := filepath.Join(parent, "example.toml") + + if err := os.Symlink(relativeManifest, link); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + removeManifestSymlink(relativeLocation, "example") + if _, err := os.Lstat(link); !os.IsNotExist(err) { + t.Fatalf("legacy relative symlink was not removed: %v", err) + } + + createManifestSymlink(relativeLocation, "example", relativeManifest) + linkTarget, err := os.Readlink(link) + require.NoError(t, err) + if !filepath.IsAbs(linkTarget) { + linkTarget = filepath.Join(parent, linkTarget) + } + actualTarget, err := filepath.Abs(linkTarget) + require.NoError(t, err) + expectedTarget, err := filepath.Abs(manifest) + require.NoError(t, err) + assert.Equal(t, filepath.Clean(expectedTarget), filepath.Clean(actualTarget), "relative nested link should resolve to its manifest") + removeManifestSymlink(relativeLocation, "example") + if _, err := os.Lstat(link); !os.IsNotExist(err) { + t.Fatalf("new relative nested symlink was not removed: %v", err) + } + + otherTarget, err := filepath.Rel(parent, otherManifest) + require.NoError(t, err) + require.NoError(t, os.Symlink(otherTarget, link)) + removeManifestSymlink(relativeLocation, "example") + retainedTarget, err := os.Readlink(link) + require.NoError(t, err, "symlink to another registration should remain") + assert.Equal(t, otherTarget, retainedTarget) +} + +func TestRemoveManifestSymlinkDoesNotResolveForeignTargetFromWorkingDirectory(t *testing.T) { + root := t.TempDir() + location := filepath.Join(root, "nested", "registered") + manifest := filepath.Join(location, "example.toml") + parent := filepath.Dir(location) + require.NoError(t, os.MkdirAll(location, 0755)) + t.Chdir(root) + require.NoError(t, os.WriteFile(manifest, []byte("registered"), 0644)) + + workingDirectory, err := os.Getwd() + require.NoError(t, err) + relativeLocation, err := filepath.Rel(workingDirectory, location) + require.NoError(t, err) + legacyManifestPath := filepath.Join(relativeLocation, "example.toml") + foreignTarget := relativeLocation + string(filepath.Separator) + "." + string(filepath.Separator) + "example.toml" + require.NotEqual(t, legacyManifestPath, foreignTarget) + + expected, err := filepath.Abs(manifest) + require.NoError(t, err) + fromWorkingDirectory, err := filepath.Abs(foreignTarget) + require.NoError(t, err) + assert.Equal(t, filepath.Clean(expected), filepath.Clean(fromWorkingDirectory)) + fromParent, err := filepath.Abs(filepath.Join(parent, foreignTarget)) + require.NoError(t, err) + assert.NotEqual(t, filepath.Clean(expected), filepath.Clean(fromParent)) + + link := filepath.Join(parent, "example.toml") + if err := os.Symlink(foreignTarget, link); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + removeManifestSymlink(relativeLocation, "example") + target, err := os.Readlink(link) + require.NoError(t, err, "foreign relative symlink should be retained") + assert.Equal(t, foreignTarget, target) +} + func TestLoadDriverFromUnsupportedManifest(t *testing.T) { prefix := t.TempDir() driverName := "test_driver" diff --git a/config/ensure_package_test.go b/config/ensure_package_test.go new file mode 100644 index 00000000..268ea202 --- /dev/null +++ b/config/ensure_package_test.go @@ -0,0 +1,898 @@ +// 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" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Masterminds/semver/v3" +) + +func installEnsureFixture(t *testing.T, cfg Config, version, source string, archive []byte) DriverInfo { + t.Helper() + file := writeInstallArchive(t, archive, "ensure-fixture") + _, err := InstallPackage(cfg, "example", file, expectedEnsurePackage("example", version, source, archive), InstallOptions{}) + _ = file.Close() + if err != nil { + t.Fatal(err) + } + info, err := GetDriver(cfg, "example") + if err != nil { + t.Fatal(err) + } + if info.Version == nil || info.Version.String() != version { + t.Fatalf("fixture version = %v, want %s", info.Version, version) + } + return info +} + +func packagePreparer(t *testing.T, cfg Config, runtimeID string, expected ExpectedPackageMetadata, data []byte, options InstallOptions) func(context.Context) (*PreparedPackage, error) { + t.Helper() + return func(ctx context.Context) (*PreparedPackage, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + file := writeInstallArchive(t, data, "ensure-candidate") + defer file.Close() + validation, err := PreparePackage(cfg, runtimeID, file, expected, options) + return validation.Prepared, err + } +} + +func expectedEnsurePackage(id, version, source string, archive []byte) ExpectedPackageMetadata { + expected := installExpected(id, source, archive) + expected.Version = version + return expected +} + +func TestEnsurePackageSkipsWithoutPreparingPackage(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + archive := makeInstallArchive(t, "example", "1.0.0", "driver.so", []byte("same library")) + initial := installEnsureFixture(t, cfg, "1.0.0", "source", archive) + providerCalls := 0 + currentMatchesCalls := 0 + var validated EnsurePackageResult + result, err := EnsurePackage(context.Background(), cfg, "example", installExpected("example", "source", archive), EnsurePackageCallbacks{ + CurrentMatches: func(current *DriverInfo) (bool, error) { + currentMatchesCalls++ + return current != nil && sameDriverRegistration(initial, *current), nil + }, + Prepare: func(context.Context) (*PreparedPackage, error) { + providerCalls++ + return nil, errors.New("package preparer must not run for a skip") + }, + ValidateResult: func(result EnsurePackageResult) error { + validated = result + return nil + }, + }) + if err != nil { + t.Fatal(err) + } + if !result.Skipped || providerCalls != 0 || currentMatchesCalls != 1 { + t.Fatalf("EnsurePackage result skipped=%t prepare calls=%d CurrentMatches calls=%d, want skip, zero prepare calls, and one predicate call", result.Skipped, providerCalls, currentMatchesCalls) + } + if result.Current == nil || result.Installed == nil || result.Previous != nil || result.Manifest != nil { + t.Fatalf("skip result fields are inconsistent: %#v", result) + } + if validated.Skipped != result.Skipped || validated.Installed == nil { + t.Fatalf("validator did not receive completed skip result: %#v", validated) + } +} + +func TestEnsurePackageInstallsExactPreparedPayload(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + oldArchive := makeInstallArchive(t, "example", "1.0.0", "old.so", []byte("old library")) + initial := installEnsureFixture(t, cfg, "1.0.0", "old-source", oldArchive) + newArchive := makeInstallArchive(t, "example", "2.0.0", "new.so", []byte("new library")) + providerCalls := 0 + expected := expectedEnsurePackage("example", "2.0.0", "new-source", newArchive) + result, err := EnsurePackage(context.Background(), cfg, "example", expected, EnsurePackageCallbacks{ + CurrentMatches: func(current *DriverInfo) (bool, error) { return false, nil }, + Prepare: func(ctx context.Context) (*PreparedPackage, error) { + providerCalls++ + return packagePreparer(t, cfg, "example", expected, newArchive, InstallOptions{})(ctx) + }, + ValidateResult: func(result EnsurePackageResult) error { + if result.Skipped || result.Current == nil || result.Previous == nil || result.Installed == nil || result.Manifest == nil { + return errors.New("install result is missing distinct registration evidence") + } + return nil + }, + }) + if err != nil { + t.Fatal(err) + } + if result.Skipped || providerCalls != 1 { + t.Fatalf("EnsurePackage result skipped=%t provider calls=%d, want install and one call", result.Skipped, providerCalls) + } + if result.Current == nil || result.Current.Driver.Shared.Get(PlatformTuple()) != initial.Driver.Shared.Get(PlatformTuple()) { + t.Fatalf("current registration does not describe the pre-install generation: %#v", result.Current) + } + if result.Previous == nil || result.Previous.Driver.Shared.Get(PlatformTuple()) != initial.Driver.Shared.Get(PlatformTuple()) { + t.Fatalf("previous registration does not describe the replaced generation: %#v", result.Previous) + } + if result.Installed == nil || result.Installed.Version.String() != "2.0.0" || result.Manifest == nil { + t.Fatalf("installed registration/manifest does not describe the candidate: %#v", result) + } +} + +func TestEnsurePackagePublishesPreparedPayloadWithoutRepeatedVerification(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + archiveData := makeInstallArchive(t, "example", "1.0.0", "driver.so", []byte("prepared library")) + archive := writeInstallArchive(t, archiveData, "prepared-install") + defer archive.Close() + verifyCalls := 0 + expected := expectedEnsurePackage("example", "1.0.0", "prepared", archiveData) + validation, err := PreparePackage(cfg, "example", archive, expected, InstallOptions{ + Verify: func(string, Manifest) error { + verifyCalls++ + return nil + }, + }) + if err != nil { + t.Fatal(err) + } + if validation.Prepared == nil { + t.Fatal("PreparePackage returned no prepared payload") + } + if !validation.Prepared.MatchesExpected(expected) { + t.Fatal("prepared payload does not match its expected package metadata") + } + wrongExpected := expected + wrongExpected.SourceIdentity = "different-source" + if validation.Prepared.MatchesExpected(wrongExpected) { + t.Fatal("prepared payload matched different source identity") + } + workspace := validation.Prepared.workDir + if _, err := os.Stat(workspace); err != nil { + t.Fatalf("prepared workspace is missing before EnsurePackage: %v", err) + } + result, err := EnsurePackage(context.Background(), cfg, "example", expected, EnsurePackageCallbacks{ + CurrentMatches: func(*DriverInfo) (bool, error) { + return false, nil + }, + Prepare: func(context.Context) (*PreparedPackage, error) { return validation.Prepared, nil }, + }) + if err != nil { + t.Fatal(err) + } + if result.Skipped || result.Installed == nil || result.Manifest == nil { + t.Fatalf("EnsurePackage did not publish prepared payload: %#v", result) + } + if verifyCalls != 1 { + t.Fatalf("package verifier calls = %d, want one across preparation and install", verifyCalls) + } + if _, err := os.Stat(workspace); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("prepared workspace remains after publication: stat error %v", err) + } + installedLibrary := result.Installed.Driver.Shared.Get(PlatformTuple()) + if contents, err := os.ReadFile(installedLibrary); err != nil || string(contents) != "prepared library" { + t.Fatalf("published library contents = %q, error = %v", contents, err) + } + if err := validation.Prepared.Close(); err != nil { + t.Fatalf("idempotent close after publication: %v", err) + } + if validation.Prepared.MatchesExpected(expected) { + t.Fatal("closed prepared payload still reports a matching package") + } +} + +func TestEnsurePackageClosesPreparedPayloadOnCancellation(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + archiveData := makeInstallArchive(t, "example", "1.0.0", "driver.so", []byte("cancelled library")) + archive := writeInstallArchive(t, archiveData, "prepared-cancel") + defer archive.Close() + validation, err := PreparePackage(cfg, "example", archive, expectedEnsurePackage("example", "1.0.0", "cancelled", archiveData), InstallOptions{}) + if err != nil { + t.Fatal(err) + } + workspace := validation.Prepared.workDir + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + _, err = EnsurePackage(ctx, cfg, "example", expectedEnsurePackage("example", "1.0.0", "cancelled", archiveData), EnsurePackageCallbacks{ + CurrentMatches: func(*DriverInfo) (bool, error) { + return false, nil + }, + Prepare: func(context.Context) (*PreparedPackage, error) { + cancel() + return validation.Prepared, nil + }, + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled EnsurePackage error = %v, want context.Canceled", err) + } + if _, err := os.Stat(workspace); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("prepared workspace was not cleaned after cancellation: %v", err) + } + if current, err := GetDriver(cfg, "example"); err == nil { + t.Fatalf("cancelled prepared package changed runtime registration: %#v", current) + } +} + +func TestEnsurePackageErrorsBeforeMutationAndReportsPostInstallValidationFailure(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + oldArchive := makeInstallArchive(t, "example", "1.0.0", "old.so", []byte("old library")) + installEnsureFixture(t, cfg, "1.0.0", "old-source", oldArchive) + oldManifest, err := os.ReadFile(filepath.Join(root, "example.toml")) + if err != nil { + t.Fatal(err) + } + newArchive := makeInstallArchive(t, "example", "2.0.0", "new.so", []byte("new library")) + + callbackErr := errors.New("predicate failed") + providerCalls := 0 + _, err = EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "new-source", newArchive), EnsurePackageCallbacks{ + CurrentMatches: func(*DriverInfo) (bool, error) { return false, callbackErr }, + Prepare: func(context.Context) (*PreparedPackage, error) { + providerCalls++ + return nil, errors.New("preparer must not run after CurrentMatches failure") + }, + }) + if !errors.Is(err, callbackErr) || providerCalls != 0 { + t.Fatalf("predicate error = %v, provider calls = %d", err, providerCalls) + } + + providerErr := errors.New("provider failed") + providerResult, err := EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "new-source", newArchive), EnsurePackageCallbacks{ + CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, + Prepare: func(context.Context) (*PreparedPackage, error) { return nil, providerErr }, + }) + if !errors.Is(err, providerErr) || providerResult.Current == nil || providerResult.Current.Version.String() != "1.0.0" { + t.Fatalf("provider error result=%#v error=%v, want phase-one current and %v", providerResult, err, providerErr) + } + currentManifest, err := os.ReadFile(filepath.Join(root, "example.toml")) + if err != nil || !bytes.Equal(currentManifest, oldManifest) { + t.Fatalf("pre-install callback/provider failure changed registration: %v", err) + } + + validationErr := errors.New("post-install validation failed") + result, err := EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "new-source", newArchive), EnsurePackageCallbacks{ + CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, + Prepare: packagePreparer(t, cfg, "example", expectedEnsurePackage("example", "2.0.0", "new-source", newArchive), newArchive, InstallOptions{}), + ValidateResult: func(result EnsurePackageResult) error { + if result.Manifest == nil || result.Installed == nil || result.Previous == nil { + return errors.New("post-install validator received incomplete result") + } + return validationErr + }, + }) + if !errors.Is(err, validationErr) || result.Manifest == nil || result.Installed == nil { + t.Fatalf("post-install validation result=%#v error=%v", result, err) + } + installed, err := GetDriver(cfg, "example") + if err != nil || installed.Version.String() != "2.0.0" { + t.Fatalf("post-validation error rolled back committed install: version=%v error=%v", installed.Version, err) + } + release, err := acquireDriverInstallLock(filepath.Join(root), "example") + if err != nil { + t.Fatalf("driver lock remained held after validation error: %v", err) + } + release() +} + +func TestEnsurePackageRechecksRegistrationAfterAcquiringDriverLock(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + locks, err := func() (func(), error) { + _, roots, err := resolvePackageInstallRoots(cfg) + if err != nil { + return nil, err + } + return acquireDriverInstallLocks(context.Background(), roots, "example") + }() + if err != nil { + t.Fatal(err) + } + locksHeld := true + defer func() { + if locksHeld { + locks() + } + }() + archive := makeInstallArchive(t, "example", "1.0.0", "driver.so", []byte("library")) + started := make(chan struct{}) + done := make(chan struct { + result EnsurePackageResult + err error + }, 1) + providerCalls := 0 + go func() { + close(started) + result, err := EnsurePackage(context.Background(), cfg, "example", installExpected("example", "source", archive), EnsurePackageCallbacks{ + CurrentMatches: func(current *DriverInfo) (bool, error) { + return current != nil && current.Version.String() == "1.0.0", nil + }, + Prepare: func(context.Context) (*PreparedPackage, error) { + providerCalls++ + return nil, errors.New("preparer must not run") + }, + }) + done <- struct { + result EnsurePackageResult + err error + }{result, err} + }() + <-started + latest := DriverInfo{ID: "example", Name: "Latest", Version: semver.MustParse("1.0.0"), Source: "latest"} + latest.Driver.Shared.Set(PlatformTuple(), filepath.Join(root, "externally-managed.so")) + if err := CreateManifest(cfg, latest); err != nil { + t.Fatal(err) + } + locks() + locksHeld = false + select { + case result := <-done: + if result.err != nil { + t.Fatal(result.err) + } + if !result.result.Skipped || result.result.Current == nil || result.result.Current.Name != "Latest" || providerCalls != 0 { + t.Fatalf("EnsurePackage did not recheck latest registration: result=%#v provider calls=%d", result.result, providerCalls) + } + case <-time.After(5 * time.Second): + t.Fatal("EnsurePackage did not finish after driver lock release") + } +} + +func TestEnsurePackageCancellationReleasesPartiallyAcquiredLocks(t *testing.T) { + root := t.TempDir() + early := filepath.Join(root, "a") + late := filepath.Join(root, "z") + if err := os.MkdirAll(early, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(late, 0o755); err != nil { + t.Fatal(err) + } + // Reverse precedence relative to lock order: EnsurePackage should lock a + // first, then wait for z. Holding z lets the test observe that a is already + // acquired and that cancellation releases it. + cfg := Config{Level: ConfigEnv, Location: late + string(os.PathListSeparator) + early} + holdLate, err := acquireDriverInstallLock(late, "example") + if err != nil { + t.Fatal(err) + } + lateHeld := true + defer func() { + if lateHeld { + holdLate() + } + }() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + earlyLockAcquired := make(chan struct{}, 1) + done := make(chan error, 1) + go func() { + _, err := ensurePackageWithLockObserver(ctx, cfg, "example", ExpectedPackageMetadata{ID: "example"}, EnsurePackageCallbacks{ + CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, + }, func(root string) { + if root == early { + earlyLockAcquired <- struct{}{} + } + }) + done <- err + }() + select { + case <-earlyLockAcquired: + case <-time.After(5 * time.Second): + t.Fatal("EnsurePackage did not acquire the earlier root lock") + } + contenderCtx, contenderCancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + contenderRelease, contenderErr := acquireDriverInstallLockContext(contenderCtx, early, "example") + contenderCancel() + if contenderRelease != nil { + contenderRelease() + } + if !errors.Is(contenderErr, context.DeadlineExceeded) { + t.Fatalf("earlier root lock was not held after the acquisition observer signal: %v", contenderErr) + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("EnsurePackage cancellation error = %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("EnsurePackage did not stop waiting after cancellation") + } + holdLate() + lateHeld = false + releaseEarly, err := acquireDriverInstallLockContext(context.Background(), early, "example") + if err != nil { + t.Fatalf("partial lock was not released after cancellation: %v", err) + } + releaseEarly() +} + +func TestEnsurePackageCancellationDuringPhaseTwoLockAcquisitionReleasesPartialLocks(t *testing.T) { + root := t.TempDir() + early := filepath.Join(root, "a") + late := filepath.Join(root, "z") + for _, path := range []string{early, late} { + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } + } + cfg := Config{Level: ConfigEnv, Location: late + string(os.PathListSeparator) + early} + archiveData := makeInstallArchive(t, "example", "2.0.0", "new.so", []byte("new library")) + archiveFile := writeInstallArchive(t, archiveData, "phase-two-cancel") + defer archiveFile.Close() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + earlyAcquired := make(chan struct{}, 2) + providerStarted := make(chan struct{}) + releaseProvider := make(chan struct{}) + var releaseOnce sync.Once + unblockProvider := func() { releaseOnce.Do(func() { close(releaseProvider) }) } + defer unblockProvider() + type ensureOutcome struct { + result EnsurePackageResult + err error + } + done := make(chan ensureOutcome, 1) + currentMatchesCalls := 0 + go func() { + result, err := ensurePackageWithLockObserver(ctx, cfg, "example", expectedEnsurePackage("example", "2.0.0", "new", archiveData), EnsurePackageCallbacks{ + CurrentMatches: func(*DriverInfo) (bool, error) { + currentMatchesCalls++ + return false, nil + }, + Prepare: func(ctx context.Context) (*PreparedPackage, error) { + close(providerStarted) + <-releaseProvider + return packagePreparer(t, cfg, "example", expectedEnsurePackage("example", "2.0.0", "new", archiveData), archiveData, InstallOptions{})(ctx) + }, + }, func(path string) { + if path == early { + earlyAcquired <- struct{}{} + } + }) + done <- ensureOutcome{result: result, err: err} + }() + select { + case <-earlyAcquired: + case <-time.After(5 * time.Second): + t.Fatal("phase one did not acquire the earlier root") + } + select { + case <-providerStarted: + case <-time.After(5 * time.Second): + t.Fatal("phase one did not release locks before entering the provider") + } + holdLate, err := acquireDriverInstallLock(late, "example") + if err != nil { + t.Fatalf("could not hold late root during provider: %v", err) + } + lateHeld := true + defer func() { + if lateHeld { + holdLate() + } + }() + unblockProvider() + select { + case <-earlyAcquired: + // Phase two acquired the early lock and is proceeding to the held late + // lock. Cancellation must release this partial lock set. + case <-time.After(5 * time.Second): + t.Fatal("phase two did not acquire the earlier root before waiting for the held root") + } + cancel() + select { + case outcome := <-done: + if !errors.Is(outcome.err, context.Canceled) { + t.Fatalf("phase-two cancellation result=%#v error=%v", outcome.result, outcome.err) + } + case <-time.After(5 * time.Second): + t.Fatal("phase-two lock acquisition did not stop after cancellation") + } + if currentMatchesCalls != 1 { + t.Fatalf("CurrentMatches calls before phase-two lock completion = %d, want 1", currentMatchesCalls) + } + holdLate() + lateHeld = false + for _, path := range []string{early, late} { + release, err := acquireDriverInstallLockContext(context.Background(), path, "example") + if err != nil { + t.Fatalf("phase-two cancellation leaked lock %s: %v", path, err) + } + release() + } +} + +func TestEnsurePackageObserverReportsBothLockingPhases(t *testing.T) { + root := t.TempDir() + early := filepath.Join(root, "a") + late := filepath.Join(root, "z") + for _, path := range []string{early, late} { + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } + } + cfg := Config{Level: ConfigEnv, Location: late + string(os.PathListSeparator) + early} + archiveData := makeInstallArchive(t, "example", "1.0.0", "driver.so", []byte("library")) + archiveFile := writeInstallArchive(t, archiveData, "observer-phases") + defer archiveFile.Close() + observed := make(chan string, 4) + currentMatchesCalls := 0 + result, err := ensurePackageWithLockObserver(context.Background(), cfg, "example", expectedEnsurePackage("example", "1.0.0", "source", archiveData), EnsurePackageCallbacks{ + CurrentMatches: func(*DriverInfo) (bool, error) { + currentMatchesCalls++ + return false, nil + }, + Prepare: packagePreparer(t, cfg, "example", expectedEnsurePackage("example", "1.0.0", "source", archiveData), archiveData, InstallOptions{}), + }, func(path string) { + observed <- path + }) + if err != nil { + t.Fatal(err) + } + want := []string{early, late, early, late} + for i, expected := range want { + select { + case path := <-observed: + if path != expected { + t.Fatalf("lock observer[%d] = %q, want %q", i, path, expected) + } + case <-time.After(5 * time.Second): + t.Fatalf("lock observer did not report acquisition %d", i) + } + } + if !result.Skipped && (result.Installed == nil || result.Installed.Version.String() != "1.0.0") { + t.Fatalf("phase-two install result = %#v", result) + } + if currentMatchesCalls != 2 { + t.Fatalf("CurrentMatches calls = %d, want two phase checks", currentMatchesCalls) + } +} + +func TestEnsurePackageCancellationHonorsProviderAndWaitsForInstall(t *testing.T) { + t.Run("provider", func(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + oldArchive := makeInstallArchive(t, "example", "1.0.0", "old.so", []byte("old library")) + initial := installEnsureFixture(t, cfg, "1.0.0", "old", oldArchive) + newArchive := makeInstallArchive(t, "example", "2.0.0", "new.so", []byte("new library")) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + providerStarted := make(chan struct{}) + done := make(chan struct { + result EnsurePackageResult + err error + }, 1) + go func() { + result, err := EnsurePackage(ctx, cfg, "example", expectedEnsurePackage("example", "2.0.0", "new", newArchive), EnsurePackageCallbacks{ + CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, + Prepare: func(providerCtx context.Context) (*PreparedPackage, error) { + close(providerStarted) + <-providerCtx.Done() + return nil, providerCtx.Err() + }, + }) + done <- struct { + result EnsurePackageResult + err error + }{result, err} + }() + select { + case <-providerStarted: + case <-time.After(5 * time.Second): + t.Fatal("EnsurePackage did not enter provider") + } + release, err := acquireDriverInstallLockContext(context.Background(), root, "example") + if err != nil { + t.Fatalf("provider ran while retaining the driver lock: %v", err) + } + release() + cancel() + var cancelledResult EnsurePackageResult + select { + case result := <-done: + if !errors.Is(result.err, context.Canceled) { + t.Fatalf("provider cancellation result=%#v error=%v", result.result, result.err) + } + cancelledResult = result.result + case <-time.After(5 * time.Second): + t.Fatal("EnsurePackage did not finish after provider observed cancellation") + } + current, err := GetDriver(cfg, "example") + if err != nil || current.Version.String() != "1.0.0" { + t.Fatalf("cancelled provider mutated registration: current=%#v error=%v", current, err) + } + if cancelledResult.Current == nil || !sameDriverRegistration(initial, *cancelledResult.Current) { + t.Fatalf("provider cancellation did not retain phase-one current snapshot: %#v", cancelledResult) + } + release, err = acquireDriverInstallLock(root, "example") + if err != nil { + t.Fatalf("provider cancellation leaked driver lock: %v", err) + } + release() + }) + + t.Run("install", func(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + oldArchive := makeInstallArchive(t, "example", "1.0.0", "old.so", []byte("old library")) + installEnsureFixture(t, cfg, "1.0.0", "old", oldArchive) + newArchive := makeInstallArchive(t, "example", "2.0.0", "new.so", []byte("new library")) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + validationStarted := make(chan struct{}) + releaseValidation := make(chan struct{}) + var releaseOnce sync.Once + unblockValidation := func() { releaseOnce.Do(func() { close(releaseValidation) }) } + defer unblockValidation() + done := make(chan struct { + result EnsurePackageResult + err error + }, 1) + go func() { + result, err := EnsurePackage(ctx, cfg, "example", expectedEnsurePackage("example", "2.0.0", "new", newArchive), EnsurePackageCallbacks{ + CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, + Prepare: packagePreparer(t, cfg, "example", expectedEnsurePackage("example", "2.0.0", "new", newArchive), newArchive, InstallOptions{}), + ValidateResult: func(EnsurePackageResult) error { + close(validationStarted) + <-releaseValidation + return nil + }, + }) + done <- struct { + result EnsurePackageResult + err error + }{result, err} + }() + select { + case <-validationStarted: + case <-time.After(5 * time.Second): + t.Fatal("EnsurePackage did not enter locked result validation") + } + cancel() + lockCtx, cancelLock := context.WithTimeout(context.Background(), 100*time.Millisecond) + lockRelease, lockErr := acquireDriverInstallLockContext(lockCtx, root, "example") + cancelLock() + if !errors.Is(lockErr, context.DeadlineExceeded) { + t.Fatalf("driver lock was not held while result validation was blocked: %v", lockErr) + } + if lockRelease != nil { + lockRelease() + } + unblockValidation() + select { + case result := <-done: + if !errors.Is(result.err, context.Canceled) || result.result.Installed == nil || result.result.Installed.Version.String() != "2.0.0" { + t.Fatalf("completed install cancellation result=%#v error=%v", result.result, result.err) + } + case <-time.After(5 * time.Second): + t.Fatal("EnsurePackage did not finish after install verifier returned") + } + current, err := GetDriver(cfg, "example") + if err != nil || current.Version.String() != "2.0.0" { + t.Fatalf("completed install was not retained after cancellation: current=%#v error=%v", current, err) + } + }) +} + +func TestEnsurePackageUsesConfigEnvPrecedenceAndExplicitlyRejectsMissingRoots(t *testing.T) { + root := t.TempDir() + primary := filepath.Join(root, "primary") + secondary := filepath.Join(root, "secondary") + if err := os.MkdirAll(primary, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(secondary, 0o755); err != nil { + t.Fatal(err) + } + primaryArchive := makeInstallArchive(t, "example", "1.0.0", "primary.so", []byte("primary library")) + secondaryArchive := makeInstallArchive(t, "example", "2.0.0", "secondary.so", []byte("secondary library")) + installEnsureFixture(t, Config{Level: ConfigEnv, Location: primary}, "1.0.0", "primary", primaryArchive) + installEnsureFixture(t, Config{Level: ConfigEnv, Location: secondary}, "2.0.0", "secondary", secondaryArchive) + combined := Config{Level: ConfigEnv, Location: primary + string(os.PathListSeparator) + filepath.Join(primary, ".", "..", "primary") + string(os.PathListSeparator) + secondary} + _, roots, err := resolvePackageInstallRoots(combined) + if err != nil { + t.Fatal(err) + } + if len(roots) != 2 || roots[0] != primary || roots[1] != secondary { + t.Fatalf("resolved ConfigEnv roots = %#v, want deduplicated precedence [%s %s]", roots, primary, secondary) + } + providerCalls := 0 + result, err := EnsurePackage(context.Background(), combined, "example", installExpected("example", "primary", primaryArchive), EnsurePackageCallbacks{ + CurrentMatches: func(current *DriverInfo) (bool, error) { + return current != nil && current.FilePath == primary && current.Version.String() == "1.0.0", nil + }, + Prepare: func(context.Context) (*PreparedPackage, error) { + providerCalls++ + return nil, errors.New("preparer must not run") + }, + }) + if err != nil || !result.Skipped || providerCalls != 0 || result.Current.FilePath != primary { + t.Fatalf("ConfigEnv precedence result=%#v prepare calls=%d error=%v", result, providerCalls, err) + } + + missing := filepath.Join(root, "missing") + _, err = EnsurePackage(context.Background(), Config{Level: ConfigEnv, Location: primary + string(os.PathListSeparator) + missing}, "example", installExpected("example", "primary", primaryArchive), EnsurePackageCallbacks{ + CurrentMatches: func(*DriverInfo) (bool, error) { return true, nil }, + }) + if err == nil || !strings.Contains(err.Error(), "configured driver config root") { + t.Fatalf("missing secondary ConfigEnv root was silently ignored: %v", err) + } +} + +func TestEnsurePackagePreparationDoesNotBlockConcurrentUninstall(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + oldArchive := makeInstallArchive(t, "example", "1.0.0", "old.so", []byte("old library")) + oldInfo := installEnsureFixture(t, cfg, "1.0.0", "old", oldArchive) + newArchive := makeInstallArchive(t, "example", "2.0.0", "new.so", []byte("new library")) + prepareStarted := make(chan struct{}) + releasePrepare := make(chan struct{}) + var releaseOnce sync.Once + unblockPrepare := func() { releaseOnce.Do(func() { close(releasePrepare) }) } + defer unblockPrepare() + ensureDone := make(chan struct { + result EnsurePackageResult + err error + }, 1) + go func() { + result, err := EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "new", newArchive), EnsurePackageCallbacks{ + CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, + Prepare: func(ctx context.Context) (*PreparedPackage, error) { + close(prepareStarted) + <-releasePrepare + return packagePreparer(t, cfg, "example", expectedEnsurePackage("example", "2.0.0", "new", newArchive), newArchive, InstallOptions{})(ctx) + }, + }) + ensureDone <- struct { + result EnsurePackageResult + err error + }{result, err} + }() + select { + case <-prepareStarted: + case <-time.After(5 * time.Second): + t.Fatal("EnsurePackage did not reach package preparation") + } + if err := UninstallDriver(cfg, oldInfo); err != nil { + t.Fatalf("uninstall did not complete while archive retrieval was blocked: %v", err) + } + unblockPrepare() + select { + case result := <-ensureDone: + if result.err != nil || result.result.Current != nil || result.result.Previous != nil || result.result.Installed == nil || result.result.Installed.Version.String() != "2.0.0" { + t.Fatalf("EnsurePackage result=%#v error=%v", result.result, result.err) + } + case <-time.After(5 * time.Second): + t.Fatal("EnsurePackage did not finish after package preparation returned") + } + current, err := GetDriver(cfg, "example") + if err != nil || current.Version.String() != "2.0.0" { + t.Fatalf("concurrent uninstall removed ensured package: current=%#v err=%v", current, err) + } +} + +func TestEnsurePackageRechecksConcurrentInstallAfterPreparation(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + oldArchive := makeInstallArchive(t, "example", "1.0.0", "old.so", []byte("old library")) + installEnsureFixture(t, cfg, "1.0.0", "old", oldArchive) + ensuredArchive := makeInstallArchive(t, "example", "2.0.0", "ensured.so", []byte("ensured library")) + expected := expectedEnsurePackage("example", "2.0.0", "ensured", ensuredArchive) + ensuredFile := writeInstallArchive(t, ensuredArchive, "ensure-preparer") + concurrentFile := writeInstallArchive(t, ensuredArchive, "concurrent-install") + defer concurrentFile.Close() + var prepared *PreparedPackage + providerStarted := make(chan struct{}) + releaseProvider := make(chan struct{}) + var releaseOnce sync.Once + unblockProvider := func() { releaseOnce.Do(func() { close(releaseProvider) }) } + defer unblockProvider() + ensureDone := make(chan struct { + result EnsurePackageResult + err error + }, 1) + currentMatchesCalls := 0 + go func() { + result, err := EnsurePackage(context.Background(), cfg, "example", expected, EnsurePackageCallbacks{ + CurrentMatches: func(current *DriverInfo) (bool, error) { + currentMatchesCalls++ + if current == nil || current.ID != "example" || current.Name != "Example Driver" || current.Version == nil || current.Version.String() != expected.Version || current.Source != "dbc" || current.Driver.Entrypoint != "AdbcDriverExampleInit" { + return false, nil + } + receipt, managed, present, valid, err := InspectDriverInstallReceipt(cfg, *current) + if err != nil { + return false, err + } + return managed && present && valid && + receipt.DriverID == expected.ID && + receipt.DriverVersion == expected.Version && + receipt.SourceType == expected.SourceType && + receipt.SourceIdentity == expected.SourceIdentity && + receipt.Platform == expected.Platform && + receipt.ArchiveHash == expected.ArchiveHash && + receipt.ArchiveSize == expected.ArchiveSize && + VerifyInstallReceiptLibraryIntegrity(current.Driver.Shared.Get(PlatformTuple()), receipt), nil + }, + Prepare: func(ctx context.Context) (*PreparedPackage, error) { + close(providerStarted) + <-releaseProvider + defer ensuredFile.Close() + validation, err := PreparePackage(cfg, "example", ensuredFile, expected, InstallOptions{}) + prepared = validation.Prepared + return validation.Prepared, err + }, + }) + ensureDone <- struct { + result EnsurePackageResult + err error + }{result, err} + }() + select { + case <-providerStarted: + case <-time.After(5 * time.Second): + t.Fatal("EnsurePackage did not reach package preparation") + } + if _, err := InstallPackage(cfg, "example", concurrentFile, expected, InstallOptions{}); err != nil { + t.Fatalf("concurrent install did not complete while archive retrieval was blocked: %v", err) + } + concurrentRegistration, err := GetDriver(cfg, "example") + if err != nil { + t.Fatalf("could not inspect concurrent candidate install: %v", err) + } + unblockProvider() + select { + case result := <-ensureDone: + if result.err != nil || !result.result.Skipped || result.result.Current == nil || result.result.Current.Version.String() != "2.0.0" || result.result.Current.Source != "dbc" || result.result.Installed == nil || result.result.Installed.Version.String() != "2.0.0" || result.result.Installed.Source != "dbc" || result.result.Manifest != nil { + t.Fatalf("EnsurePackage did not skip the concurrently installed healthy generation: result=%#v error=%v", result.result, result.err) + } + case <-time.After(5 * time.Second): + t.Fatal("EnsurePackage did not finish after preparation returned") + } + if currentMatchesCalls != 2 { + t.Fatalf("CurrentMatches calls = %d, want phase-one and phase-two checks", currentMatchesCalls) + } + current, err := GetDriver(cfg, "example") + if err != nil || current.Version.String() != "2.0.0" || current.Source != "dbc" || !sameDriverRegistration(concurrentRegistration, current) { + t.Fatalf("phase-two candidate registration was overwritten: current=%#v error=%v", current, err) + } + if prepared == nil { + t.Fatal("EnsurePackage did not create the prepared package") + } + if _, err := os.Stat(prepared.workDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("phase-two skip did not clean prepared workspace: %v", err) + } +} + +func TestPackageInstallRootKeyCaseSensitivity(t *testing.T) { + first := filepath.Clean("C:\\Drivers\\Primary") + second := filepath.Clean("c:\\drivers\\primary") + if packageInstallRootKey(first, true) != packageInstallRootKey(second, true) { + t.Fatalf("Windows root keys differ for case variants: %q and %q", packageInstallRootKey(first, true), packageInstallRootKey(second, true)) + } + if packageInstallRootKey(first, false) == packageInstallRootKey(second, false) { + t.Fatal("case-sensitive root key unexpectedly folded case") + } +} diff --git a/config/install_lock_js.go b/config/install_lock_js.go new file mode 100644 index 00000000..e5b6bb94 --- /dev/null +++ b/config/install_lock_js.go @@ -0,0 +1,48 @@ +// 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 ( + "context" + "path/filepath" + "sync" +) + +var packageInstallLocksMu sync.Mutex +var packageInstallLocks = map[string]chan struct{}{} + +func acquirePackageInstallLock(path string) (func(), error) { + return acquirePackageInstallLockContext(context.Background(), path) +} + +func acquirePackageInstallLockContext(ctx context.Context, path string) (func(), error) { + key := filepath.Clean(path) + packageInstallLocksMu.Lock() + lock := packageInstallLocks[key] + if lock == nil { + lock = make(chan struct{}, 1) + packageInstallLocks[key] = lock + } + packageInstallLocksMu.Unlock() + + select { + case lock <- struct{}{}: + return func() { <-lock }, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} diff --git a/config/install_lock_native.go b/config/install_lock_native.go new file mode 100644 index 00000000..ef035e44 --- /dev/null +++ b/config/install_lock_native.go @@ -0,0 +1,40 @@ +// 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 ( + "context" + "time" + + "github.com/columnar-tech/dbc/internal/fslock" +) + +func acquirePackageInstallLock(path string) (func(), error) { + lock, err := fslock.Acquire(path, 10*time.Second) + if err != nil { + return nil, err + } + return func() { _ = lock.Release() }, nil +} + +func acquirePackageInstallLockContext(ctx context.Context, path string) (func(), error) { + lock, err := fslock.AcquireContext(ctx, path) + if err != nil { + return nil, err + } + return func() { _ = lock.Release() }, nil +} diff --git a/config/install_transaction_helpers_test.go b/config/install_transaction_helpers_test.go new file mode 100644 index 00000000..fba67574 --- /dev/null +++ b/config/install_transaction_helpers_test.go @@ -0,0 +1,97 @@ +// 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" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "strings" + "testing" +) + +type installArchiveEntry struct { + name string + data []byte +} + +func makeInstallArchive(t *testing.T, id, version, library string, libraryData []byte) []byte { + t.Helper() + manifest := []byte(fmt.Sprintf(`name = %q +version = %q + +[Driver] +entrypoint = "AdbcDriverExampleInit" + +[Files] +driver = %q +`, strings.ToUpper(id[:1])+id[1:]+" Driver", version, library)) + return makeInstallArchiveWithEntries(t, + installArchiveEntry{name: "MANIFEST", data: manifest}, + installArchiveEntry{name: library, data: libraryData}, + ) +} + +func makeInstallArchiveWithEntries(t *testing.T, entries ...installArchiveEntry) []byte { + t.Helper() + var output bytes.Buffer + gz := gzip.NewWriter(&output) + writer := tar.NewWriter(gz) + for _, entry := range entries { + err := writer.WriteHeader(&tar.Header{Name: entry.name, Mode: 0o644, Size: int64(len(entry.data)), Typeflag: tar.TypeReg, Format: tar.FormatPAX}) + if err != nil { + t.Fatal(err) + } + if _, err := writer.Write(entry.data); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return output.Bytes() +} + +func writeInstallArchive(t *testing.T, data []byte, name string) *os.File { + t.Helper() + file, err := os.CreateTemp(t.TempDir(), name+"-*.tar.gz") + if err != nil { + t.Fatal(err) + } + if _, err := file.Write(data); err != nil { + t.Fatal(err) + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + t.Fatal(err) + } + return file +} + +func installExpected(id, source string, data []byte) ExpectedPackageMetadata { + digest := sha256.Sum256(data) + return ExpectedPackageMetadata{ + ID: id, Version: "1.0.0", Platform: PlatformTuple(), + SourceType: "registry", SourceIdentity: source, + ArchiveHash: "sha256:" + hex.EncodeToString(digest[:]), ArchiveSize: int64(len(data)), + } +} diff --git a/config/install_transaction_install_test.go b/config/install_transaction_install_test.go new file mode 100644 index 00000000..6a10fa5e --- /dev/null +++ b/config/install_transaction_install_test.go @@ -0,0 +1,616 @@ +// 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" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/Masterminds/semver/v3" +) + +func TestInstallPackageVerifiesBeforeReplacingRuntimeRegistration(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + firstArchive := makeInstallArchive(t, "example", "1.0.0", "old-library.so", []byte("old library")) + firstFile := writeInstallArchive(t, firstArchive, "first") + first, err := InstallPackage(cfg, "example", firstFile, installExpected("example", "source-one", firstArchive), InstallOptions{}) + _ = firstFile.Close() + if err != nil { + t.Fatal(err) + } + oldPath := first.Driver.Shared.Get(PlatformTuple()) + oldManifest, err := os.ReadFile(filepath.Join(root, "example.toml")) + if err != nil { + t.Fatal(err) + } + + secondArchive := makeInstallArchive(t, "example", "1.0.0", "new-library.so", []byte("new library")) + secondFile := writeInstallArchive(t, secondArchive, "second") + verifyErr := errors.New("signature rejected") + _, err = InstallPackage(cfg, "example", secondFile, installExpected("example", "source-two", secondArchive), InstallOptions{ + Verify: func(stagingDir string, manifest Manifest) error { + if got, readErr := os.ReadFile(oldPath); readErr != nil || string(got) != "old library" { + t.Fatalf("old library unavailable during verification: %q, %v", got, readErr) + } + if got, readErr := os.ReadFile(filepath.Join(root, "example.toml")); readErr != nil || !bytes.Equal(got, oldManifest) { + t.Fatalf("old runtime manifest changed before verification completed: %v", readErr) + } + if _, readErr := os.Stat(filepath.Join(stagingDir, manifest.Files.Driver)); readErr != nil { + t.Fatalf("verified library is not staged: %v", readErr) + } + return verifyErr + }, + }) + _ = secondFile.Close() + if !errors.Is(err, verifyErr) { + t.Fatalf("InstallPackage error = %v, want verifier error", err) + } + currentManifest, err := os.ReadFile(filepath.Join(root, "example.toml")) + if err != nil || !bytes.Equal(currentManifest, oldManifest) { + t.Fatalf("runtime manifest changed after verification failure: %v", err) + } + if got, err := os.ReadFile(oldPath); err != nil || string(got) != "old library" { + t.Fatalf("old library unavailable after verification failure: %q, %v", got, err) + } +} + +func TestInstallPackageMetadataFailuresPreserveOldGeneration(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + firstArchive := makeInstallArchive(t, "example", "1.0.0", "old-library.so", []byte("old library")) + firstFile := writeInstallArchive(t, firstArchive, "initial") + first, err := InstallPackage(cfg, "example", firstFile, installExpected("example", "source-one", firstArchive), InstallOptions{}) + _ = firstFile.Close() + if err != nil { + t.Fatal(err) + } + oldManifest, err := os.ReadFile(filepath.Join(root, "example.toml")) + if err != nil { + t.Fatal(err) + } + oldLibrary, err := os.ReadFile(first.Driver.Shared.Get(PlatformTuple())) + if err != nil { + t.Fatal(err) + } + + archive := makeInstallArchive(t, "example", "1.0.0", "replacement.so", []byte("new library")) + tests := []struct { + name string + mutate func(*ExpectedPackageMetadata) + }{ + {name: "archive digest mismatch", mutate: func(expected *ExpectedPackageMetadata) { + expected.ArchiveHash = "sha256:" + strings.Repeat("0", 64) + }}, + {name: "explicit zero archive size mismatch", mutate: func(expected *ExpectedPackageMetadata) { + expected.ArchiveSize = 0 + expected.ArchiveSizePresent = true + }}, + {name: "package version mismatch", mutate: func(expected *ExpectedPackageMetadata) { + expected.Version = "2.0.0" + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + expected := installExpected("example", "source-two", archive) + test.mutate(&expected) + file := writeInstallArchive(t, archive, test.name) + _, err := InstallPackage(cfg, "example", file, expected, InstallOptions{}) + _ = file.Close() + if err == nil { + t.Fatal("InstallPackage unexpectedly succeeded") + } + currentManifest, err := os.ReadFile(filepath.Join(root, "example.toml")) + if err != nil || !bytes.Equal(currentManifest, oldManifest) { + t.Fatalf("runtime manifest changed after metadata failure: %v", err) + } + currentLibrary, err := os.ReadFile(first.Driver.Shared.Get(PlatformTuple())) + if err != nil || !bytes.Equal(currentLibrary, oldLibrary) { + t.Fatalf("old library changed after metadata failure: %v", err) + } + }) + } +} + +func TestInstallPackageReceiptHashesLibraryAfterVerification(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + archive := makeInstallArchive(t, "example", "1.0.0", "driver.so", []byte("archive library")) + file := writeInstallArchive(t, archive, "rewrite") + rewritten := []byte("verified replacement library") + installed, err := InstallPackage(cfg, "example", file, installExpected("example", "rewrite-source", archive), InstallOptions{ + Verify: func(stagingDir string, manifest Manifest) error { + return os.WriteFile(filepath.Join(stagingDir, manifest.Files.Driver), rewritten, 0o644) + }, + }) + _ = file.Close() + if err != nil { + t.Fatal(err) + } + libraryPath := installed.Driver.Shared.Get(PlatformTuple()) + gotLibrary, err := os.ReadFile(libraryPath) + if err != nil || !bytes.Equal(gotLibrary, rewritten) { + t.Fatalf("installed library = %q, want verifier output %q: %v", gotLibrary, rewritten, err) + } + receiptData, err := os.ReadFile(filepath.Join(filepath.Dir(libraryPath), installReceiptName)) + if err != nil { + t.Fatal(err) + } + var receipt InstallReceipt + if err := json.Unmarshal(receiptData, &receipt); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(rewritten) + wantHash := "sha256:" + hex.EncodeToString(digest[:]) + if receipt.InstalledLibraryHash != wantHash { + t.Fatalf("receipt library hash = %q, want %q", receipt.InstalledLibraryHash, wantHash) + } +} + +func TestInstallPackageHashOnlyMetadataRetainsMeasuredSizeAndMatchesReceipt(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + archive := makeInstallArchive(t, "example", "1.0.0", "driver.so", []byte("hash-only archive")) + expected := installExpected("example", "hash-only-source", archive) + expected.ArchiveSize = 0 + file := writeInstallArchive(t, archive, "hash-only") + installed, err := InstallPackage(cfg, "example", file, expected, InstallOptions{}) + _ = file.Close() + if err != nil { + t.Fatal(err) + } + + receiptData, err := os.ReadFile(filepath.Join(filepath.Dir(installed.Driver.Shared.Get(PlatformTuple())), installReceiptName)) + if err != nil { + t.Fatal(err) + } + var receipt InstallReceipt + if err := json.Unmarshal(receiptData, &receipt); err != nil { + t.Fatal(err) + } + if receipt.ArchiveSize != int64(len(archive)) || receipt.ArchiveSize <= 0 { + t.Fatalf("receipt archive size = %d, want measured positive size %d", receipt.ArchiveSize, len(archive)) + } + if !InstallReceiptMatchesExpectedPackage(receipt, expected) { + t.Fatal("hash-only expected metadata did not match its installation receipt") + } + + wrongZero := expected + wrongZero.ArchiveSizePresent = true + if InstallReceiptMatchesExpectedPackage(receipt, wrongZero) { + t.Fatal("receipt matched an explicitly expected zero archive size") + } + wrongSize := expected + wrongSize.ArchiveSize = receipt.ArchiveSize + 1 + if InstallReceiptMatchesExpectedPackage(receipt, wrongSize) { + t.Fatal("receipt matched a mismatched expected archive size") + } +} + +func TestInspectInstallReceiptSeparatesMetadataFromLibraryIntegrity(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + archive := makeInstallArchive(t, "example", "1.0.0", "driver.so", []byte("library")) + file := writeInstallArchive(t, archive, "receipt-inspection") + installed, err := InstallPackage(cfg, "example", file, installExpected("example", "receipt-source", archive), InstallOptions{}) + _ = file.Close() + if err != nil { + t.Fatal(err) + } + libraryPath := installed.Driver.Shared.Get(PlatformTuple()) + receipt, managed, present, valid := InspectInstallReceipt(root, "example", libraryPath) + if !managed || !present || !valid { + t.Fatalf("receipt inspection = managed %v, present %v, valid %v; want all true", managed, present, valid) + } + if !VerifyInstallReceiptLibraryIntegrity(libraryPath, receipt) { + t.Fatal("installed library should match its receipt") + } + + if err := os.WriteFile(libraryPath, []byte("tampered library"), 0o644); err != nil { + t.Fatal(err) + } + _, managed, present, valid = InspectInstallReceipt(root, "example", libraryPath) + if !managed || !present || !valid { + t.Fatalf("metadata inspection should not hash current library: managed %v, present %v, valid %v", managed, present, valid) + } + if VerifyInstallReceiptLibraryIntegrity(libraryPath, receipt) { + t.Fatal("tampered library unexpectedly matched its receipt") + } + + if err := os.Remove(filepath.Join(filepath.Dir(libraryPath), installReceiptName)); err != nil { + t.Fatal(err) + } + _, managed, present, valid = InspectInstallReceipt(root, "example", libraryPath) + if !managed || present || valid { + t.Fatalf("missing receipt inspection = managed %v, present %v, valid %v; want true, false, false", managed, present, valid) + } + + legacyDirectory := filepath.Join(root, "example_"+PlatformTuple()+"_v1.0.0") + if err := os.Mkdir(legacyDirectory, 0o755); err != nil { + t.Fatal(err) + } + legacyLibrary := filepath.Join(legacyDirectory, "driver.so") + if err := os.WriteFile(legacyLibrary, []byte("legacy library"), 0o644); err != nil { + t.Fatal(err) + } + _, managed, present, valid = InspectInstallReceipt(root, "example", legacyLibrary) + if !managed || present || valid { + t.Fatalf("legacy receipt-less generation = managed %v, present %v, valid %v; want true, false, false", managed, present, valid) + } + externalDirectory := filepath.Join(root, "external") + if err := os.Mkdir(externalDirectory, 0o755); err != nil { + t.Fatal(err) + } + externalLibrary := filepath.Join(externalDirectory, "driver.so") + if err := os.WriteFile(externalLibrary, []byte("external library"), 0o644); err != nil { + t.Fatal(err) + } + _, managed, present, valid = InspectInstallReceipt(root, "example", externalLibrary) + if managed || present || valid { + t.Fatalf("external library = managed %v, present %v, valid %v; want all false", managed, present, valid) + } +} + +func TestInspectDriverInstallReceiptUsesSelectedEnvironmentPath(t *testing.T) { + root := t.TempDir() + first := filepath.Join(root, "first") + second := filepath.Join(root, "second") + if err := os.MkdirAll(first, 0o755); err != nil { + t.Fatal(err) + } + archive := makeInstallArchive(t, "example", "1.0.0", "driver.so", []byte("library")) + file := writeInstallArchive(t, archive, "multi-path-receipt") + installed, err := InstallPackage(Config{Level: ConfigEnv, Location: second}, "example", file, installExpected("example", "multi-path", archive), InstallOptions{}) + _ = file.Close() + if err != nil { + t.Fatal(err) + } + combined := Config{Level: ConfigEnv, Location: first + string(filepath.ListSeparator) + second} + registered, err := GetDriver(combined, "example") + if err != nil { + t.Fatal(err) + } + if filepath.Clean(registered.FilePath) != filepath.Clean(second) { + t.Fatalf("selected registration root = %q, want %q", registered.FilePath, second) + } + receipt, managed, present, valid, err := InspectDriverInstallReceipt(combined, registered) + if err != nil { + t.Fatal(err) + } + if !managed || !present || !valid || !VerifyInstallReceiptLibraryIntegrity(installed.Driver.Shared.Get(PlatformTuple()), receipt) { + t.Fatalf("multi-path receipt inspection = managed %v present %v valid %v", managed, present, valid) + } + registryMapped := registered + registryMapped.FilePath = "HKCU\\SOFTWARE\\ADBC\\Drivers" + receipt, managed, present, valid, err = InspectDriverInstallReceipt(Config{Level: ConfigUser, Location: second}, registryMapped) + if err != nil { + t.Fatal(err) + } + if !managed || !present || !valid { + t.Fatalf("registry-backed receipt inspection = managed %v present %v valid %v", managed, present, valid) + } +} + +func TestPreparePackageReturnsAndComparesRuntimeRegistration(t *testing.T) { + root := t.TempDir() + externalLibrary := filepath.Join(root, "external-driver.so") + if err := os.WriteFile(externalLibrary, []byte("external library"), 0o644); err != nil { + t.Fatal(err) + } + manifest := []byte(fmt.Sprintf(`manifest_version = 1 +name = "Manifest Only Driver" +version = "1.0.0" + +[Driver] +entrypoint = "DriverInit" +shared = %q +`, externalLibrary)) + archive := makeInstallArchiveWithEntries(t, installArchiveEntry{name: "MANIFEST", data: manifest}) + file := writeInstallArchive(t, archive, "external-registration") + cfg := Config{Level: ConfigEnv, Location: root} + validation, err := PreparePackage(cfg, "example", file, installExpected("example", "source", archive), InstallOptions{}) + _ = file.Close() + if err != nil { + t.Fatal(err) + } + if validation.Prepared == nil { + t.Fatal("package preparation returned no prepared payload") + } + t.Cleanup(func() { + if err := validation.Prepared.Close(); err != nil { + t.Errorf("close prepared package: %v", err) + } + }) + if validation.VerifiedLibraryHash != "" { + t.Fatalf("manifest-only package library hash = %q, want empty", validation.VerifiedLibraryHash) + } + candidate := validation.Registration + if candidate.ID != "example" || candidate.Driver.Shared.Get(PlatformTuple()) != externalLibrary || candidate.Driver.Entrypoint != "DriverInit" { + t.Fatalf("validated registration = %#v, want external path %q and entrypoint DriverInit", candidate, externalLibrary) + } + current := candidate + current.FilePath = filepath.Join(root, "registration") + if !SameRuntimeDriverRegistration(current, candidate, PlatformTuple()) { + t.Fatal("registration location alone should not change effective runtime registration") + } + changedPath := DriverInfo{ + ID: candidate.ID, Name: candidate.Name, Publisher: candidate.Publisher, License: candidate.License, + Version: candidate.Version, Source: candidate.Source, AdbcInfo: candidate.AdbcInfo, + } + changedPath.Driver.Entrypoint = candidate.Driver.Entrypoint + changedPath.Driver.Shared.Set(PlatformTuple(), filepath.Join(root, "other-driver.so")) + if SameRuntimeDriverRegistration(current, changedPath, PlatformTuple()) { + t.Fatal("different external library paths unexpectedly compare equal") + } + changedEntrypoint := candidate + changedEntrypoint.Driver.Entrypoint = "OtherInit" + if SameRuntimeDriverRegistration(current, changedEntrypoint, PlatformTuple()) { + t.Fatal("different entrypoints unexpectedly compare equal") + } + changedVersion := candidate + changedVersion.Version = semver.MustParse("1.0.1") + if SameRuntimeDriverRegistration(current, changedVersion, PlatformTuple()) { + t.Fatal("different manifest versions unexpectedly compare equal") + } +} + +func TestInstallPackageRejectsVerifierDeletedLibraryAndPreservesOldGeneration(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + firstArchive := makeInstallArchive(t, "example", "1.0.0", "old-library.so", []byte("old library")) + firstFile := writeInstallArchive(t, firstArchive, "initial") + first, err := InstallPackage(cfg, "example", firstFile, installExpected("example", "source-one", firstArchive), InstallOptions{}) + _ = firstFile.Close() + if err != nil { + t.Fatal(err) + } + oldPath := first.Driver.Shared.Get(PlatformTuple()) + oldManifest, err := os.ReadFile(filepath.Join(root, "example.toml")) + if err != nil { + t.Fatal(err) + } + secondArchive := makeInstallArchive(t, "example", "1.0.0", "new-library.so", []byte("new library")) + secondFile := writeInstallArchive(t, secondArchive, "delete") + _, err = InstallPackage(cfg, "example", secondFile, installExpected("example", "source-two", secondArchive), InstallOptions{ + Verify: func(stagingDir string, manifest Manifest) error { + return os.Remove(filepath.Join(stagingDir, manifest.Files.Driver)) + }, + }) + _ = secondFile.Close() + if err == nil { + t.Fatal("InstallPackage succeeded after verifier deleted the driver library") + } + currentManifest, err := os.ReadFile(filepath.Join(root, "example.toml")) + if err != nil || !bytes.Equal(currentManifest, oldManifest) { + t.Fatalf("runtime manifest changed after verifier deleted the staged library: %v", err) + } + if got, err := os.ReadFile(oldPath); err != nil || string(got) != "old library" { + t.Fatalf("old library unavailable after verifier deleted the staged library: %q, %v", got, err) + } +} + +func TestInstallPackageRegistrationFailurePreservesOldGeneration(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + firstArchive := makeInstallArchive(t, "example", "1.0.0", "old-library.so", []byte("old library")) + firstFile := writeInstallArchive(t, firstArchive, "initial") + first, err := InstallPackage(cfg, "example", firstFile, installExpected("example", "source-one", firstArchive), InstallOptions{}) + _ = firstFile.Close() + if err != nil { + t.Fatal(err) + } + oldPath := first.Driver.Shared.Get(PlatformTuple()) + oldDir := filepath.Dir(oldPath) + oldManifest, err := os.ReadFile(filepath.Join(root, "example.toml")) + if err != nil { + t.Fatal(err) + } + secondArchive := makeInstallArchive(t, "example", "1.0.0", "renamed-library.so", []byte("new library")) + secondFile := writeInstallArchive(t, secondArchive, "replacement") + registerErr := errors.New("runtime registration failed") + _, err = installPackage(cfg, "example", secondFile, installExpected("example", "source-two", secondArchive), InstallOptions{}, func(gotCfg Config, _ DriverInfo) error { + current, loadErr := loadDriverFromManifest(root, "example") + if loadErr != nil { + t.Fatalf("old runtime manifest unavailable before registration: %v", loadErr) + } + if current.Driver.Shared.Get(PlatformTuple()) != oldPath { + t.Fatalf("old runtime path changed before registration: %q", current.Driver.Shared.Get(PlatformTuple())) + } + if got, readErr := os.ReadFile(oldPath); readErr != nil || string(got) != "old library" { + t.Fatalf("old library unavailable before registration: %q, %v", got, readErr) + } + return registerErr + }) + _ = secondFile.Close() + if !errors.Is(err, registerErr) { + t.Fatalf("install error = %v, want registration error", err) + } + currentManifest, err := os.ReadFile(filepath.Join(root, "example.toml")) + if err != nil || !bytes.Equal(currentManifest, oldManifest) { + t.Fatalf("runtime manifest changed after failed registration: %v", err) + } + if got, err := os.ReadFile(oldPath); err != nil || string(got) != "old library" { + t.Fatalf("old library unavailable after failed registration: %q, %v", got, err) + } + if _, err := os.Stat(oldDir); err != nil { + t.Fatalf("old package generation was removed after failed registration: %v", err) + } + if got := countInstallGenerations(t, root, "example"); got != 1 { + t.Fatalf("generation count after failed registration = %d, want only old generation", got) + } +} + +func TestInstallPackagePreservesCandidateWhenRegistrationRollbackFails(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + firstArchive := makeInstallArchive(t, "example", "1.0.0", "old-library.so", []byte("old library")) + firstFile := writeInstallArchive(t, firstArchive, "initial") + first, err := InstallPackage(cfg, "example", firstFile, installExpected("example", "source-one", firstArchive), InstallOptions{}) + _ = firstFile.Close() + if err != nil { + t.Fatal(err) + } + secondArchive := makeInstallArchive(t, "example", "1.0.0", "new-library.so", []byte("new library")) + secondFile := writeInstallArchive(t, secondArchive, "replacement") + writeErr := errors.New("registration failed") + rollbackErr := errors.New("registration rollback failed") + _, err = installPackage(cfg, "example", secondFile, installExpected("example", "source-two", secondArchive), InstallOptions{}, func(Config, DriverInfo) error { + return &manifestRollbackError{writeErr: writeErr, rollbackErr: rollbackErr} + }) + _ = secondFile.Close() + if !errors.Is(err, writeErr) || !strings.Contains(err.Error(), rollbackErr.Error()) { + t.Fatalf("install error = %v, want registration and rollback errors", err) + } + if got := countInstallGenerations(t, root, "example"); got != 2 { + t.Fatalf("generation count after failed rollback = %d, want old and preserved candidate", got) + } + current, err := loadDriverFromManifest(root, "example") + if err != nil || current.Driver.Shared.Get(PlatformTuple()) != first.Driver.Shared.Get(PlatformTuple()) { + t.Fatalf("old runtime manifest changed after rollback failure: %v", err) + } +} + +func TestInstallPackageCleansOldGenerationOnlyAfterRegistration(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + firstArchive := makeInstallArchive(t, "example", "1.0.0", "old-library.so", []byte("old library")) + firstFile := writeInstallArchive(t, firstArchive, "initial") + first, err := InstallPackage(cfg, "example", firstFile, installExpected("example", "source-one", firstArchive), InstallOptions{}) + _ = firstFile.Close() + if err != nil { + t.Fatal(err) + } + oldDir := filepath.Dir(first.Driver.Shared.Get(PlatformTuple())) + secondArchive := makeInstallArchive(t, "example", "1.0.0", "new-library.so", []byte("new library")) + secondFile := writeInstallArchive(t, secondArchive, "replacement") + second, err := InstallPackage(cfg, "example", secondFile, installExpected("example", "source-two", secondArchive), InstallOptions{}) + _ = secondFile.Close() + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(oldDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("old package generation remains after successful registration: %v", err) + } + if got, err := os.ReadFile(second.Driver.Shared.Get(PlatformTuple())); err != nil || string(got) != "new library" { + t.Fatalf("new package generation unavailable: %q, %v", got, err) + } +} + +func TestInstallPackageIgnoresOldGenerationCleanupFailure(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + firstArchive := makeInstallArchive(t, "example", "1.0.0", "old-library.so", []byte("old library")) + firstFile := writeInstallArchive(t, firstArchive, "initial") + first, err := InstallPackage(cfg, "example", firstFile, installExpected("example", "source-one", firstArchive), InstallOptions{}) + _ = firstFile.Close() + if err != nil { + t.Fatal(err) + } + oldDir := filepath.Dir(first.Driver.Shared.Get(PlatformTuple())) + secondArchive := makeInstallArchive(t, "example", "1.0.0", "new-library.so", []byte("new library")) + secondFile := writeInstallArchive(t, secondArchive, "replacement") + cleanupErr := errors.New("package removal failed") + installed, err := installPackageWithCleanup(cfg, "example", secondFile, installExpected("example", "source-two", secondArchive), InstallOptions{}, CreateManifest, func(string, string, *DriverInfo, string, DriverInfo) error { + return cleanupErr + }) + _ = secondFile.Close() + if err != nil { + t.Fatalf("install returned stale-generation cleanup error: %v", err) + } + if _, err := os.Stat(oldDir); err != nil { + t.Fatalf("old generation missing after best-effort cleanup failure: %v", err) + } + current, err := GetDriver(cfg, "example") + if err != nil { + t.Fatal(err) + } + if current.Driver.Shared.Get(PlatformTuple()) != installed.Driver.Shared.Get(PlatformTuple()) { + t.Fatalf("runtime registration path = %q, want installed path %q", current.Driver.Shared.Get(PlatformTuple()), installed.Driver.Shared.Get(PlatformTuple())) + } +} + +func TestInstallPackageConcurrentGenerationsShareDriverLock(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + archives := [][]byte{ + makeInstallArchive(t, "example", "1.0.0", "first-library.so", []byte("first")), + makeInstallArchive(t, "example", "1.0.0", "second-library.so", []byte("second")), + } + results := make(chan error, len(archives)) + var wait sync.WaitGroup + for i, archive := range archives { + wait.Add(1) + go func(i int, archive []byte) { + defer wait.Done() + file, err := os.CreateTemp(t.TempDir(), fmt.Sprintf("source-%d-*.tar.gz", i)) + if err == nil { + _, err = file.Write(archive) + } + if err == nil { + _, err = file.Seek(0, io.SeekStart) + } + if err == nil { + _, err = InstallPackage(cfg, "example", file, installExpected("example", fmt.Sprintf("source-%d", i), archive), InstallOptions{}) + } + if file != nil { + _ = file.Close() + } + results <- err + }(i, archive) + } + wait.Wait() + close(results) + for err := range results { + if err != nil { + t.Fatal(err) + } + } + installed, err := loadDriverFromManifest(root, "example") + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(installed.Driver.Shared.Get(PlatformTuple())) + if err != nil { + t.Fatal(err) + } + if string(data) != "first" && string(data) != "second" { + t.Fatalf("installed library = %q", data) + } + var receipt InstallReceipt + receiptData, err := os.ReadFile(filepath.Join(filepath.Dir(installed.Driver.Shared.Get(PlatformTuple())), installReceiptName)) + if err != nil || json.Unmarshal(receiptData, &receipt) != nil { + t.Fatalf("could not read installed generation receipt: %v", err) + } + if receipt.InstalledLibrary != filepath.Base(installed.Driver.Shared.Get(PlatformTuple())) { + t.Fatalf("receipt library = %q, runtime path = %q", receipt.InstalledLibrary, installed.Driver.Shared.Get(PlatformTuple())) + } + actualLibraryHash, err := hashFile(installed.Driver.Shared.Get(PlatformTuple())) + if err != nil || receipt.InstalledLibraryHash != actualLibraryHash { + t.Fatalf("receipt library digest %q does not match installed library %q: %v", receipt.InstalledLibraryHash, actualLibraryHash, err) + } + if (receipt.SourceIdentity == "source-0" && string(data) != "first") || (receipt.SourceIdentity == "source-1" && string(data) != "second") { + t.Fatalf("receipt source %q does not match installed library %q", receipt.SourceIdentity, data) + } +} diff --git a/config/install_transaction_legacy_helpers_test.go b/config/install_transaction_legacy_helpers_test.go new file mode 100644 index 00000000..52a7d3fa --- /dev/null +++ b/config/install_transaction_legacy_helpers_test.go @@ -0,0 +1,76 @@ +// 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" + "strings" + "testing" + + "github.com/Masterminds/semver/v3" +) + +func countInstallGenerations(t *testing.T, location, runtimeID string) int { + t.Helper() + entries, err := os.ReadDir(location) + if err != nil { + t.Fatal(err) + } + count := 0 + for _, entry := range entries { + if entry.IsDir() && strings.HasPrefix(entry.Name(), ".dbc-package-"+runtimeID+"-") { + count++ + } + } + return count +} + +func createLegacyInstall(t *testing.T, location, runtimeID, version string, sharedPath string) string { + t.Helper() + directory := filepath.Join(location, runtimeID+"_"+PlatformTuple()+"_v"+version) + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + if sharedPath == "" { + sharedPath = filepath.Join(directory, "old-library.so") + } + if err := os.WriteFile(sharedPath, []byte("old library"), 0o644); err != nil { + t.Fatal(err) + } + driver := DriverInfo{ + ID: runtimeID, Name: "Example Driver", Version: semver.MustParse(version), Source: "dbc", + } + driver.Driver.Shared.defaultPath = sharedPath + if err := CreateManifest(Config{Level: ConfigEnv, Location: location}, driver); err != nil { + t.Fatal(err) + } + return directory +} + +func installVersionTwo(t *testing.T, cfg Config, runtimeID string, verify func(string, Manifest) error, register func(Config, DriverInfo) error) error { + t.Helper() + archive := makeInstallArchive(t, runtimeID, "2.0.0", "new-library.so", []byte("new library")) + file := writeInstallArchive(t, archive, "version-two") + defer file.Close() + expected := installExpected(runtimeID, "new-source", archive) + expected.Version = "2.0.0" + if register == nil { + _, err := InstallPackage(cfg, runtimeID, file, expected, InstallOptions{Verify: verify}) + return err + } + _, err := installPackage(cfg, runtimeID, file, expected, InstallOptions{Verify: verify}, register) + return err +} diff --git a/config/install_transaction_legacy_test.go b/config/install_transaction_legacy_test.go new file mode 100644 index 00000000..d74e8686 --- /dev/null +++ b/config/install_transaction_legacy_test.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 ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/Masterminds/semver/v3" +) + +func TestUninstallDriverUsesRegisteredEnvironmentPath(t *testing.T) { + root := t.TempDir() + missing := filepath.Join(root, "missing") + registered := filepath.Join(root, "registered") + if err := os.Mkdir(registered, 0o755); err != nil { + t.Fatal(err) + } + archive := makeInstallArchive(t, "example", "1.0.0", "library.so", []byte("installed library")) + file := writeInstallArchive(t, archive, "registered") + _, err := InstallPackage(Config{Level: ConfigEnv, Location: registered}, "example", file, installExpected("example", "registered", archive), InstallOptions{}) + _ = file.Close() + if err != nil { + t.Fatal(err) + } + info, err := loadDriverFromManifest(registered, "example") + if err != nil { + t.Fatal(err) + } + combined := Config{Level: ConfigEnv, Location: missing + string(os.PathListSeparator) + registered} + lockLocation, err := driverInstallLockLocation(combined, info) + if err != nil { + t.Fatal(err) + } + wantLockLocation, _ := filepath.Abs(registered) + if lockLocation != wantLockLocation { + t.Fatalf("uninstall lock location = %q, want actual registration location %q", lockLocation, wantLockLocation) + } + if err := UninstallDriver(combined, info); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(missing); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("uninstall created the missing first path: %v", err) + } + if _, err := os.Stat(filepath.Join(registered, "example.toml")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("registered manifest still exists: %v", err) + } +} + +func TestUninstallDriverEnvironmentPathSharesInstallLock(t *testing.T) { + root := t.TempDir() + first := filepath.Join(root, "first") + second := filepath.Join(root, "second") + if err := os.MkdirAll(second, 0o755); err != nil { + t.Fatal(err) + } + archive := makeInstallArchive(t, "example", "1.0.0", "library.so", []byte("installed library")) + file := writeInstallArchive(t, archive, "registered") + _, err := InstallPackage(Config{Level: ConfigEnv, Location: second}, "example", file, installExpected("example", "registered", archive), InstallOptions{}) + _ = file.Close() + if err != nil { + t.Fatal(err) + } + info, err := loadDriverFromManifest(second, "example") + if err != nil { + t.Fatal(err) + } + lockLocation, err := driverInstallLockLocation(Config{Level: ConfigEnv, Location: first + string(os.PathListSeparator) + second}, info) + if err != nil { + t.Fatal(err) + } + releaseLock, err := acquireDriverInstallLock(lockLocation, info.ID) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + done <- UninstallDriver(Config{Level: ConfigEnv, Location: first + string(os.PathListSeparator) + second}, info) + }() + select { + case err := <-done: + releaseLock() + t.Fatalf("uninstall did not wait on the install lock for the registered path: %v", err) + case <-time.After(150 * time.Millisecond): + } + releaseLock() + select { + case err := <-done: + if err != nil { + t.Fatalf("UninstallDriver returned an error after lock release: %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("UninstallDriver did not finish after lock release") + } +} + +func TestUninstallDriverRejectsStaleRegistrationAfterWaiting(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + oldDir := filepath.Join(root, "example_linux_amd64_v1.0.0") + if runtime.GOOS == "darwin" { + oldDir = filepath.Join(root, "example_macos_"+runtime.GOARCH+"_v1.0.0") + } else { + oldDir = filepath.Join(root, "example_"+PlatformTuple()+"_v1.0.0") + } + if err := os.MkdirAll(oldDir, 0o755); err != nil { + t.Fatal(err) + } + oldLibrary := filepath.Join(oldDir, "old.so") + if err := os.WriteFile(oldLibrary, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + old := DriverInfo{ID: "example", Name: "Old", Version: semver.MustParse("1.0.0"), Source: "dbc"} + old.Driver.Shared.defaultPath = oldLibrary + if err := CreateManifest(cfg, old); err != nil { + t.Fatal(err) + } + stale, err := GetDriver(cfg, "example") + if err != nil { + t.Fatal(err) + } + lockLocation, err := driverInstallLockLocation(cfg, stale) + if err != nil { + t.Fatal(err) + } + releaseLock, err := acquireDriverInstallLock(lockLocation, stale.ID) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- UninstallDriver(cfg, stale) }() + select { + case err := <-done: + releaseLock() + t.Fatalf("uninstall completed while the lock was held: %v", err) + case <-time.After(150 * time.Millisecond): + } + newLibrary := filepath.Join(root, "new-generation", "new.so") + if err := os.MkdirAll(filepath.Dir(newLibrary), 0o755); err != nil { + releaseLock() + t.Fatal(err) + } + if err := os.WriteFile(newLibrary, []byte("new"), 0o644); err != nil { + releaseLock() + t.Fatal(err) + } + updated := DriverInfo{ID: "example", Name: "New", Version: semver.MustParse("2.0.0"), Source: "dbc"} + updated.Driver.Shared.defaultPath = newLibrary + if err := CreateManifest(cfg, updated); err != nil { + releaseLock() + t.Fatal(err) + } + releaseLock() + select { + case err := <-done: + if err == nil || !strings.Contains(err.Error(), "registration changed") { + t.Fatalf("UninstallDriver error = %v, want stale-registration error", err) + } + case <-time.After(10 * time.Second): + t.Fatal("UninstallDriver did not finish after lock release") + } + current, err := GetDriver(cfg, "example") + if err != nil || current.Version.String() != "2.0.0" { + t.Fatalf("new registration was not preserved: %#v, %v", current, err) + } + if data, err := os.ReadFile(newLibrary); err != nil || string(data) != "new" { + t.Fatalf("new library was not preserved: %q, %v", data, err) + } +} + +func TestInstallPackageCleansLegacyGenerationOnlyAfterSuccessfulRegistration(t *testing.T) { + for _, test := range []struct { + name string + verify func(string, Manifest) error + registerError error + }{ + {name: "successful registration"}, + {name: "verification failure", verify: func(string, Manifest) error { return errors.New("verification failed") }}, + {name: "registration failure", registerError: errors.New("registration failed")}, + } { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + legacy := createLegacyInstall(t, root, "example", "1.0.0", "") + register := func(cfg Config, driver DriverInfo) error { + if test.registerError != nil { + return test.registerError + } + return CreateManifest(cfg, driver) + } + err := installVersionTwo(t, Config{Level: ConfigEnv, Location: root}, "example", test.verify, register) + if test.registerError != nil && !errors.Is(err, test.registerError) { + t.Fatalf("InstallPackage error = %v, want registration error", err) + } + if test.verify != nil && (err == nil || !strings.Contains(err.Error(), "verification failed")) { + t.Fatalf("InstallPackage error = %v, want verification error", err) + } + if test.verify == nil && test.registerError == nil && err != nil { + t.Fatal(err) + } + _, statErr := os.Stat(legacy) + if test.verify == nil && test.registerError == nil { + if !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("successful install retained legacy generation: %v", statErr) + } + } else if statErr != nil { + t.Fatalf("failed install removed legacy generation: %v", statErr) + } + }) + } +} + +func TestInstallPackageRetainsUnprovenLegacyCandidates(t *testing.T) { + for _, test := range []struct { + name string + setup func(t *testing.T, root, knownCandidate string) string + newManifest bool + }{ + {name: "external shared file", setup: func(t *testing.T, root, candidate string) string { + t.Helper() + if err := os.Mkdir(candidate, 0o755); err != nil { + t.Fatal(err) + } + external := filepath.Join(root, "external", "old.so") + if err := os.MkdirAll(filepath.Dir(external), 0o755); err != nil { + t.Fatal(err) + } + return createLegacyInstall(t, root, "example", "1.0.0", external) + }}, + {name: "symlink candidate", setup: func(t *testing.T, root, candidate string) string { + t.Helper() + target := filepath.Join(root, "outside") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatal(err) + } + shared := filepath.Join(target, "old.so") + if err := os.WriteFile(shared, []byte("old library"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, candidate); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + return createLegacyInstall(t, root, "example", "1.0.0", shared) + }}, + {name: "unknown directory name", setup: func(t *testing.T, root, candidate string) string { + t.Helper() + unknown := filepath.Join(root, "example-old-generation") + if err := os.Mkdir(unknown, 0o755); err != nil { + t.Fatal(err) + } + shared := filepath.Join(unknown, "old.so") + if err := os.WriteFile(shared, []byte("old library"), 0o644); err != nil { + t.Fatal(err) + } + return createLegacyInstall(t, root, "example", "1.0.0", shared) + }}, + {name: "receipt candidate", setup: func(t *testing.T, root, candidate string) string { + t.Helper() + legacy := createLegacyInstall(t, root, "example", "1.0.0", "") + if err := os.WriteFile(filepath.Join(legacy, installReceiptName), []byte("broken"), 0o600); err != nil { + t.Fatal(err) + } + return legacy + }}, + {name: "new manifest references legacy directory", setup: func(t *testing.T, root, candidate string) string { + t.Helper() + return createLegacyInstall(t, root, "example", "1.0.0", "") + }, newManifest: true}, + } { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + candidate := filepath.Join(root, "example_"+PlatformTuple()+"_v1.0.0") + legacy := test.setup(t, root, candidate) + if test.newManifest { + oldLibrary := filepath.Join(legacy, "old-library.so") + archiveManifest := []byte(fmt.Sprintf(`manifest_version = 1 +name = "Example Driver" +version = "2.0.0" + +[Driver] +shared = %q +`, oldLibrary)) + archive := makeInstallArchiveWithEntries(t, installArchiveEntry{name: "MANIFEST", data: archiveManifest}) + file := writeInstallArchive(t, archive, "references-legacy") + expected := installExpected("example", "new-source", archive) + expected.Version = "2.0.0" + _, err := InstallPackage(Config{Level: ConfigEnv, Location: root}, "example", file, expected, InstallOptions{}) + _ = file.Close() + if err != nil { + t.Fatal(err) + } + } else if err := installVersionTwo(t, Config{Level: ConfigEnv, Location: root}, "example", nil, nil); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(legacy); err != nil { + t.Fatalf("unproven legacy candidate was removed: %v", err) + } + }) + } +} + +func TestInstallIntoFirstEnvironmentPathPreservesLaterLegacyInstall(t *testing.T) { + root := t.TempDir() + first := filepath.Join(root, "first") + second := filepath.Join(root, "second") + if err := os.MkdirAll(first, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(second, 0o755); err != nil { + t.Fatal(err) + } + legacy := createLegacyInstall(t, second, "example", "1.0.0", "") + if err := installVersionTwo(t, Config{Level: ConfigEnv, Location: first + string(os.PathListSeparator) + second}, "example", nil, nil); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(legacy); err != nil { + t.Fatalf("install into first path removed later legacy generation: %v", err) + } + if _, err := os.Stat(filepath.Join(second, "example.toml")); err != nil { + t.Fatalf("later runtime registration was removed: %v", err) + } +} diff --git a/config/install_transaction_lock_test.go b/config/install_transaction_lock_test.go new file mode 100644 index 00000000..8b63b031 --- /dev/null +++ b/config/install_transaction_lock_test.go @@ -0,0 +1,207 @@ +// 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" + "testing" + "time" +) + +func TestUninstallDriverWaitsForInstallLock(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + archive := makeInstallArchive(t, "example", "1.0.0", "library.so", []byte("installed library")) + file := writeInstallArchive(t, archive, "uninstall-lock") + installed, err := InstallPackage(cfg, "example", file, installExpected("example", "uninstall-lock", archive), InstallOptions{}) + _ = file.Close() + if err != nil { + t.Fatal(err) + } + info, err := GetDriver(cfg, "example") + if err != nil { + t.Fatal(err) + } + libraryPath := installed.Driver.Shared.Get(PlatformTuple()) + if data, err := os.ReadFile(libraryPath); err != nil || string(data) != "installed library" { + t.Fatalf("installed library unavailable before uninstall: %q, %v", data, err) + } + location, err := driverInstallLockLocation(cfg, info) + if err != nil { + t.Fatal(err) + } + releaseLock, err := acquireDriverInstallLock(location, info.ID) + if err != nil { + t.Fatal(err) + } + lockHeld := true + defer func() { + if lockHeld { + releaseLock() + } + }() + + started := make(chan struct{}) + done := make(chan error, 1) + go func() { + close(started) + done <- UninstallDriver(cfg, info) + }() + <-started + select { + case err := <-done: + t.Fatalf("uninstall completed while the install lock was held: %v", err) + case <-time.After(150 * time.Millisecond): + } + _, manifestErr := os.Stat(filepath.Join(root, "example.toml")) + libraryData, libraryErr := os.ReadFile(libraryPath) + releaseLock() + lockHeld = false + select { + case err := <-done: + if err != nil { + t.Fatalf("UninstallDriver returned an error: %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("UninstallDriver did not complete after the install lock was released") + } + if manifestErr != nil { + t.Fatalf("runtime manifest changed while the install lock was held: %v", manifestErr) + } + if libraryErr != nil || string(libraryData) != "installed library" { + t.Fatalf("installed library changed while the install lock was held: %q, %v", libraryData, libraryErr) + } + if _, err := os.Stat(filepath.Join(root, "example.toml")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("runtime manifest still exists after uninstall: %v", err) + } + if _, err := os.Stat(libraryPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("installed library still exists after uninstall: %v", err) + } + if _, err := os.Stat(filepath.Dir(libraryPath)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("receipt-owned generation directory remains after uninstall: %v", err) + } +} + +func TestDriverInstallLockLocation(t *testing.T) { + root := t.TempDir() + first := filepath.Join(root, "first") + second := filepath.Join(root, "second") + registryPath := filepath.Join(root, "registry-fallback") + for _, location := range []string{first, second, registryPath} { + if err := os.MkdirAll(location, 0o755); err != nil { + t.Fatal(err) + } + } + tests := []struct { + name string + cfg Config + info DriverInfo + want string + }{ + { + name: "environment uses actual registration location", + cfg: Config{Level: ConfigEnv, Location: first + string(os.PathListSeparator) + second}, + info: DriverInfo{ID: "example", FilePath: second}, + want: second, + }, + { + name: "filesystem manifest uses its actual location", + cfg: Config{Level: ConfigUser, Location: first}, + info: DriverInfo{ID: "example", FilePath: second}, + want: second, + }, + { + name: "registry manifest uses configured filesystem root", + cfg: Config{Level: ConfigUser, Location: registryPath}, + info: DriverInfo{ID: "example", FilePath: `HKCU\\SOFTWARE\\ADBC\\Drivers`}, + want: registryPath, + }, + { + name: "registry manifest defaults to config filesystem root", + cfg: Config{Level: ConfigUser}, + info: DriverInfo{ID: "example", FilePath: `HKCU\\SOFTWARE\\ADBC\\Drivers`}, + want: ConfigUser.ConfigLocation(), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := driverInstallLockLocation(test.cfg, test.info) + if err != nil { + t.Fatal(err) + } + want, err := filepath.Abs(test.want) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("driverInstallLockLocation() = %q, want %q", got, want) + } + }) + } +} + +func TestUninstallPackageCleanupLocation(t *testing.T) { + root := t.TempDir() + first := filepath.Join(root, "first") + second := filepath.Join(root, "second") + registryPath := filepath.Join(root, "registry-fallback") + for _, location := range []string{first, second, registryPath} { + if err := os.MkdirAll(location, 0o755); err != nil { + t.Fatal(err) + } + } + tests := []struct { + name string + cfg Config + info DriverInfo + want string + }{ + { + name: "environment cleans the registered manifest directory", + cfg: Config{Level: ConfigEnv, Location: first + string(os.PathListSeparator) + second}, + info: DriverInfo{ID: "example", FilePath: second}, + want: second, + }, + { + name: "filesystem manifest cleans its actual directory", + cfg: Config{Level: ConfigUser, Location: first}, + info: DriverInfo{ID: "example", FilePath: second}, + want: second, + }, + { + name: "registry manifest falls back to configured filesystem root", + cfg: Config{Level: ConfigUser, Location: registryPath}, + info: DriverInfo{ID: "example", FilePath: `HKCU\\SOFTWARE\\ADBC\\Drivers`}, + want: registryPath, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := uninstallPackageCleanupLocation(test.cfg, test.info) + if err != nil { + t.Fatal(err) + } + want, err := filepath.Abs(test.want) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("uninstallPackageCleanupLocation() = %q, want %q", got, want) + } + }) + } +} diff --git a/config/install_transaction_uninstall_test.go b/config/install_transaction_uninstall_test.go new file mode 100644 index 00000000..c1e75bdd --- /dev/null +++ b/config/install_transaction_uninstall_test.go @@ -0,0 +1,593 @@ +// 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 ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/Masterminds/semver/v3" +) + +func TestUninstallDriverCleansOnlyRegisteredEnvironmentDirectory(t *testing.T) { + root := t.TempDir() + first := filepath.Join(root, "first") + second := filepath.Join(root, "second") + for _, location := range []string{first, second} { + if err := os.MkdirAll(location, 0o755); err != nil { + t.Fatal(err) + } + } + install := func(location, source string) Manifest { + t.Helper() + archive := makeInstallArchive(t, "example", "1.0.0", "library.so", []byte(source+" library")) + file := writeInstallArchive(t, archive, source) + manifest, err := InstallPackage(Config{Level: ConfigEnv, Location: location}, "example", file, installExpected("example", source, archive), InstallOptions{}) + _ = file.Close() + if err != nil { + t.Fatal(err) + } + return manifest + } + firstManifest := install(first, "first") + secondManifest := install(second, "second") + firstLibrary := firstManifest.Driver.Shared.Get(PlatformTuple()) + secondLibrary := secondManifest.Driver.Shared.Get(PlatformTuple()) + secondInfo, err := loadDriverFromManifest(second, "example") + if err != nil { + t.Fatal(err) + } + combinedConfig := Config{Level: ConfigEnv, Location: first + string(os.PathListSeparator) + second} + if err := UninstallDriver(combinedConfig, secondInfo); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(first, "example.toml")); err != nil { + t.Fatalf("first-path runtime manifest was removed: %v", err) + } + if data, err := os.ReadFile(firstLibrary); err != nil || string(data) != "first library" { + t.Fatalf("first-path managed generation was removed or changed: %q, %v", data, err) + } + if _, err := os.Stat(filepath.Join(second, "example.toml")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("second-path runtime manifest remains after uninstall: %v", err) + } + if _, err := os.Stat(secondLibrary); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("second-path managed generation remains after uninstall: %v", err) + } +} + +func TestInstallPackageManifestOnlyKeepsExternalLibrary(t *testing.T) { + root := t.TempDir() + externalDir := filepath.Join(root, "external") + if err := os.Mkdir(externalDir, 0o755); err != nil { + t.Fatal(err) + } + externalLibrary := filepath.Join(externalDir, "driver.so") + if err := os.WriteFile(externalLibrary, []byte("external library"), 0o644); err != nil { + t.Fatal(err) + } + externalSibling := filepath.Join(externalDir, "LICENSE") + if err := os.WriteFile(externalSibling, []byte("external license"), 0o644); err != nil { + t.Fatal(err) + } + legacyManifest := []byte(fmt.Sprintf(`manifest_version = 1 +name = "Manifest Only" +version = "1.0.0" + +[Driver] +shared = %q +`, externalLibrary)) + archive := makeInstallArchiveWithEntries(t, installArchiveEntry{name: "MANIFEST", data: legacyManifest}) + cfg := Config{Level: ConfigEnv, Location: root} + firstFile := writeInstallArchive(t, archive, "legacy-one") + first, err := InstallPackage(cfg, "manifest-only", firstFile, ExpectedPackageMetadata{ID: "manifest-only", SourceType: "local", SourceIdentity: "local"}, InstallOptions{}) + _ = firstFile.Close() + if err != nil { + t.Fatal(err) + } + if got := first.Driver.Shared.Get(PlatformTuple()); got != externalLibrary { + t.Fatalf("external shared path = %q, want %q", got, externalLibrary) + } + if got := countInstallGenerations(t, root, "manifest-only"); got != 1 { + t.Fatalf("manifest-only generation count after first install = %d, want 1", got) + } + secondFile := writeInstallArchive(t, archive, "legacy-two") + second, err := InstallPackage(cfg, "manifest-only", secondFile, ExpectedPackageMetadata{ID: "manifest-only", SourceType: "local", SourceIdentity: "local"}, InstallOptions{}) + _ = secondFile.Close() + if err != nil { + t.Fatal(err) + } + if got := second.Driver.Shared.Get(PlatformTuple()); got != externalLibrary { + t.Fatalf("external shared path changed: %q", got) + } + if got := countInstallGenerations(t, root, "manifest-only"); got != 1 { + t.Fatalf("manifest-only generation count after update = %d, want 1", got) + } + if got, err := os.ReadFile(externalLibrary); err != nil || string(got) != "external library" { + t.Fatalf("external library was altered: %q, %v", got, err) + } + info, err := GetDriver(cfg, "manifest-only") + if err != nil { + t.Fatal(err) + } + if err := UninstallDriver(cfg, info); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(externalLibrary); err != nil || string(got) != "external library" { + t.Fatalf("uninstall altered external library: %q, %v", got, err) + } + if got, err := os.ReadFile(externalSibling); err != nil || string(got) != "external license" { + t.Fatalf("uninstall altered external sibling: %q, %v", got, err) + } + if got := countInstallGenerations(t, root, "manifest-only"); got != 0 { + t.Fatalf("managed receipt generations after uninstall = %d, want 0", got) + } +} + +func TestUninstallDriverRemovesOnlyProvenLegacyPackageGeneration(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + legacy := createLegacyInstall(t, root, "example", "1.0.0", "") + info, err := GetDriver(cfg, "example") + if err != nil { + t.Fatal(err) + } + if err := UninstallDriver(cfg, info); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(legacy); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("proven legacy generation remains after uninstall: %v", err) + } +} + +func TestUninstallPackageCleanupReturnsProvenRemovalFailures(t *testing.T) { + for _, test := range []struct { + name string + setup func(t *testing.T, root string) (Config, DriverInfo, string) + }{ + {name: "managed generation", setup: func(t *testing.T, root string) (Config, DriverInfo, string) { + cfg := Config{Level: ConfigEnv, Location: root} + archive := makeInstallArchive(t, "example", "1.0.0", "driver.so", []byte("managed library")) + file := writeInstallArchive(t, archive, "managed") + if _, err := InstallPackage(cfg, "example", file, installExpected("example", "source", archive), InstallOptions{}); err != nil { + t.Fatal(err) + } + _ = file.Close() + info, err := GetDriver(cfg, "example") + if err != nil { + t.Fatal(err) + } + return cfg, info, filepath.Dir(info.Driver.Shared.Get(PlatformTuple())) + }}, + {name: "receipt-backed archive basename", setup: func(t *testing.T, root string) (Config, DriverInfo, string) { + cfg := Config{Level: ConfigEnv, Location: root} + _, directory := installArchiveBasenamePackage(t, root) + info, err := GetDriver(cfg, "example") + if err != nil { + t.Fatal(err) + } + return cfg, info, directory + }}, + {name: "legacy standard layout", setup: func(t *testing.T, root string) (Config, DriverInfo, string) { + cfg := Config{Level: ConfigEnv, Location: root} + directory := createLegacyInstall(t, root, "example", "1.0.0", "") + info, err := GetDriver(cfg, "example") + if err != nil { + t.Fatal(err) + } + return cfg, info, directory + }}, + } { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + cfg, info, directory := test.setup(t, root) + manifest := filepath.Join(info.FilePath, info.ID+".toml") + if err := os.Remove(manifest); err != nil { + t.Fatalf("could not simulate completed registration removal: %v", err) + } + removeErr := errors.New("simulated package removal failure") + var removed []string + err := cleanupUninstalledDriverPackagesAfterRegistrationRemovalWithRemoveAll(cfg, info, func(path string) error { + removed = append(removed, filepath.Clean(path)) + return removeErr + }) + if !errors.Is(err, removeErr) { + t.Fatalf("cleanup error = %v, want wrapped removal error", err) + } + if !slices.Contains(removed, filepath.Clean(directory)) { + t.Fatalf("cleanup did not attempt removal of proven directory %q: %v", directory, removed) + } + if !strings.Contains(err.Error(), "driver registration was removed") { + t.Fatalf("cleanup error = %v, want explicit registration-removed state", err) + } + if _, err := os.Stat(directory); err != nil { + t.Fatalf("injected failure unexpectedly removed package directory: %v", err) + } + if _, err := os.Stat(manifest); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("runtime registration unexpectedly remains after cleanup failure: %v", err) + } + }) + } +} + +func TestUninstallPackageCleanupSkipsNonDBCDrivers(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + archive := makeInstallArchive(t, "example", "1.0.0", "driver.so", []byte("managed library")) + file := writeInstallArchive(t, archive, "managed") + if _, err := InstallPackage(cfg, "example", file, installExpected("example", "source", archive), InstallOptions{}); err != nil { + t.Fatal(err) + } + _ = file.Close() + info, err := GetDriver(cfg, "example") + if err != nil { + t.Fatal(err) + } + managedDirectory := filepath.Dir(info.Driver.Shared.Get(PlatformTuple())) + info.Source = "external" + var removeCalls int + err = cleanupUninstalledDriverPackagesWithRemoveAll(cfg, info, func(string) error { + removeCalls++ + return nil + }) + if err != nil { + t.Fatalf("non-dbc cleanup returned an error: %v", err) + } + if removeCalls != 0 { + t.Fatalf("non-dbc cleanup called removeAll %d times, want 0", removeCalls) + } + if _, err := os.Stat(managedDirectory); err != nil { + t.Fatalf("non-dbc cleanup removed a same-ID managed generation: %v", err) + } + + filePath := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(filePath, []byte("file"), 0o600); err != nil { + t.Fatal(err) + } + info = DriverInfo{ID: "external", Source: "external"} + if err := cleanupUninstalledDriverPackagesWithRemoveAll(Config{Level: ConfigEnv, Location: filePath}, info, func(string) error { + removeCalls++ + return nil + }); err != nil { + t.Fatalf("non-dbc cleanup failed while config location was unreadable: %v", err) + } + if removeCalls != 0 { + t.Fatalf("non-dbc cleanup called removeAll %d times for unreadable location, want 0", removeCalls) + } +} + +func TestUninstallDriverRetainsUnprovenLegacyDirectories(t *testing.T) { + for _, test := range []struct { + name string + setup func(t *testing.T, root string) (DriverInfo, string) + }{ + {name: "unknown directory name", setup: func(t *testing.T, root string) (DriverInfo, string) { + directory := filepath.Join(root, "example-unrecognized") + if err := os.Mkdir(directory, 0o755); err != nil { + t.Fatal(err) + } + library := filepath.Join(directory, "old-library.so") + if err := os.WriteFile(library, []byte("old library"), 0o644); err != nil { + t.Fatal(err) + } + driver := DriverInfo{ID: "example", Name: "Example", Version: semver.MustParse("1.0.0"), Source: "dbc"} + driver.Driver.Shared.defaultPath = library + if err := CreateManifest(Config{Level: ConfigEnv, Location: root}, driver); err != nil { + t.Fatal(err) + } + info, err := GetDriver(Config{Level: ConfigEnv, Location: root}, "example") + if err != nil { + t.Fatal(err) + } + return info, library + }}, + {name: "corrupt receipt", setup: func(t *testing.T, root string) (DriverInfo, string) { + legacy := createLegacyInstall(t, root, "example", "1.0.0", "") + library := filepath.Join(legacy, "old-library.so") + if err := os.WriteFile(filepath.Join(legacy, installReceiptName), []byte("broken"), 0o600); err != nil { + t.Fatal(err) + } + info, err := GetDriver(Config{Level: ConfigEnv, Location: root}, "example") + if err != nil { + t.Fatal(err) + } + return info, library + }}, + } { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + info, library := test.setup(t, root) + if err := UninstallDriver(Config{Level: ConfigEnv, Location: root}, info); err != nil { + t.Fatal(err) + } + if data, err := os.ReadFile(library); err != nil || string(data) != "old library" { + t.Fatalf("uninstall removed or changed unproven library: %q, %v", data, err) + } + }) + } +} + +func TestRelativeNestedEnvironmentInstallUpdateUninstall(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + absRoot := filepath.Join(root, "nested", "drivers") + relativeRoot, err := filepath.Rel(root, absRoot) + if err != nil { + t.Fatal(err) + } + cfg := Config{Level: ConfigEnv, Location: relativeRoot} + + firstArchive := makeInstallArchive(t, "example", "1.0.0", "first.so", []byte("first library")) + firstFile := writeInstallArchive(t, firstArchive, "relative-first") + first, err := InstallPackage(cfg, "example", firstFile, installExpected("example", "relative-first", firstArchive), InstallOptions{}) + _ = firstFile.Close() + if err != nil { + t.Fatal(err) + } + firstGeneration := filepath.Dir(first.Driver.Shared.Get(PlatformTuple())) + if _, err := os.Stat(filepath.Join(absRoot, "example.toml")); err != nil { + t.Fatalf("runtime manifest missing after first install: %v", err) + } + link := filepath.Join(filepath.Dir(absRoot), "example.toml") + linkTarget, err := os.Readlink(link) + if err != nil { + t.Fatalf("manifest compatibility symlink missing after first install: %v", err) + } + if !filepath.IsAbs(linkTarget) { + linkTarget = filepath.Join(filepath.Dir(absRoot), linkTarget) + } + resolvedLinkTarget, err := filepath.Abs(linkTarget) + if err != nil { + t.Fatal(err) + } + manifestPath, err := filepath.Abs(filepath.Join(absRoot, "example.toml")) + if err != nil { + t.Fatal(err) + } + if filepath.Clean(resolvedLinkTarget) != filepath.Clean(manifestPath) { + t.Fatalf("manifest symlink resolves to %q, want %q", resolvedLinkTarget, manifestPath) + } + + if err := installVersionTwo(t, cfg, "example", nil, nil); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(firstGeneration); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("old owned generation remains after update: %v", err) + } + secondInfo, err := GetDriver(cfg, "example") + if err != nil { + t.Fatal(err) + } + secondGeneration := filepath.Dir(secondInfo.Driver.Shared.Get(PlatformTuple())) + if _, err := os.Stat(secondGeneration); err != nil { + t.Fatalf("new owned generation missing after update: %v", err) + } + if _, err := os.Lstat(link); err != nil { + t.Fatalf("manifest symlink missing after update: %v", err) + } + + if err := UninstallDriver(cfg, secondInfo); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(absRoot, "example.toml")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("runtime manifest remains after uninstall: %v", err) + } + if _, err := os.Lstat(link); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("manifest symlink remains after uninstall: %v", err) + } + if _, err := os.Stat(secondGeneration); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("owned generation remains after uninstall: %v", err) + } + if got := countInstallGenerations(t, absRoot, "example"); got != 0 { + t.Fatalf("owned package generation count after uninstall = %d, want 0", got) + } +} + +func installArchiveBasenamePackage(t *testing.T, root string) (Manifest, string) { + t.Helper() + cfg := Config{Level: ConfigEnv, Location: root} + archive := makeInstallArchive(t, "example", "1.0.0", "driver.so", []byte("installed library")) + file := writeInstallArchive(t, archive, "release-bundle") + installed, err := InstallDriver(cfg, "example", file) + if err != nil { + t.Fatal(err) + } + packageDirectory := filepath.Dir(installed.Driver.Shared.Get(PlatformTuple())) + if err := CreateManifest(cfg, installed.DriverInfo); err != nil { + t.Fatal(err) + } + return installed, packageDirectory +} + +func TestUninstallDriverUsesReceiptForArchiveBasenameDirectory(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + _, packageDirectory := installArchiveBasenamePackage(t, root) + base := filepath.Base(packageDirectory) + if strings.HasPrefix(base, ".dbc-package-example-") || base == "example_"+PlatformTuple()+"_v1.0.0" { + t.Fatalf("test package directory unexpectedly uses a managed name: %q", base) + } + info, err := GetDriver(cfg, "example") + if err != nil { + t.Fatal(err) + } + if got, ok := managedPackageDirectory(root, "example", info); !ok || filepath.Clean(got) != filepath.Clean(packageDirectory) { + t.Fatalf("registered shared path did not prove receipt-owned directory: %q, %v", got, ok) + } + if err := UninstallDriver(cfg, info); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(packageDirectory); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("receipt-owned archive basename directory remains after uninstall: %v", err) + } +} + +func TestUninstallDriverRetainsInvalidReceiptAtArchiveBasenameDirectory(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + installed, packageDirectory := installArchiveBasenamePackage(t, root) + if err := os.WriteFile(filepath.Join(packageDirectory, installReceiptName), []byte("broken receipt"), 0o600); err != nil { + t.Fatal(err) + } + info, err := GetDriver(cfg, "example") + if err != nil { + t.Fatal(err) + } + if _, ok := managedPackageDirectory(root, "example", info); ok { + t.Fatal("invalid receipt proved ownership of an archive basename directory") + } + if err := UninstallDriver(cfg, info); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(packageDirectory); err != nil { + t.Fatalf("invalid receipt directory was removed: %v", err) + } + if data, err := os.ReadFile(installed.Driver.Shared.Get(PlatformTuple())); err != nil || string(data) != "installed library" { + t.Fatalf("invalid receipt library was removed or changed: %q, %v", data, err) + } +} + +func TestManagedPackageDirectoryRequiresReceiptLibraryRelationship(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + archive := makeInstallArchive(t, "example", "1.0.0", "driver.so", []byte("driver")) + file := writeInstallArchive(t, archive, "initial") + installed, err := InstallPackage(cfg, "example", file, installExpected("example", "registry-source", archive), InstallOptions{}) + _ = file.Close() + if err != nil { + t.Fatal(err) + } + dir, ok := managedPackageDirectory(root, "example", installed.DriverInfo) + if !ok { + t.Fatal("valid receipt did not prove package ownership") + } + registryInfo := installed.DriverInfo + registryInfo.Driver.Shared = driverMap{defaultPath: installed.Driver.Shared.Get(PlatformTuple())} + if _, ok := managedPackageDirectory(root, "example", registryInfo); !ok { + t.Fatal("valid managed directory was not recognized through a default shared path") + } + wrongID := installed.DriverInfo + wrongID.ID = "another-driver" + if _, ok := managedPackageDirectory(root, "example", wrongID); ok { + t.Fatal("package receipt for another driver proved ownership") + } + linkDir := filepath.Join(root, ".dbc-package-example-link") + if err := os.Symlink(dir, linkDir); err == nil { + linkedInfo := installed.DriverInfo + linkedInfo.Driver.Shared.platformMap = map[string]string{ + PlatformTuple(): filepath.Join(linkDir, filepath.Base(installed.Driver.Shared.Get(PlatformTuple()))), + } + if _, ok := managedPackageDirectory(root, "example", linkedInfo); ok { + t.Fatal("symlinked package directory proved ownership") + } + } + receiptPath := filepath.Join(dir, installReceiptName) + data, err := os.ReadFile(receiptPath) + if err != nil { + t.Fatal(err) + } + var receipt InstallReceipt + if err := json.Unmarshal(data, &receipt); err != nil { + t.Fatal(err) + } + receipt.InstalledLibraryHash = "sha256:" + strings.Repeat("0", 64) + data, err = json.Marshal(receipt) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(receiptPath, data, 0o600); err != nil { + t.Fatal(err) + } + if _, ok := managedPackageDirectory(root, "example", installed.DriverInfo); ok { + t.Fatal("receipt with a mismatched library digest proved ownership") + } +} + +func TestCleanupManagedPackageDirectoriesSkipsForeignOrTamperedGenerations(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + exampleArchive := makeInstallArchive(t, "example", "1.0.0", "driver.so", []byte("example")) + exampleFile := writeInstallArchive(t, exampleArchive, "example") + example, err := InstallPackage(cfg, "example", exampleFile, installExpected("example", "example-source", exampleArchive), InstallOptions{}) + _ = exampleFile.Close() + if err != nil { + t.Fatal(err) + } + otherArchive := makeInstallArchive(t, "other", "1.0.0", "driver.so", []byte("other")) + otherFile := writeInstallArchive(t, otherArchive, "other") + other, err := InstallPackage(cfg, "other", otherFile, installExpected("other", "other-source", otherArchive), InstallOptions{}) + _ = otherFile.Close() + if err != nil { + t.Fatal(err) + } + exampleDir := filepath.Dir(example.Driver.Shared.Get(PlatformTuple())) + otherDir := filepath.Dir(other.Driver.Shared.Get(PlatformTuple())) + + tamperedDir := filepath.Join(root, ".dbc-package-example-tampered") + if err := os.Mkdir(tamperedDir, 0o755); err != nil { + t.Fatal(err) + } + receiptBytes, err := os.ReadFile(filepath.Join(exampleDir, installReceiptName)) + if err != nil { + t.Fatal(err) + } + var tamperedReceipt InstallReceipt + if err := json.Unmarshal(receiptBytes, &tamperedReceipt); err != nil { + t.Fatal(err) + } + tamperedReceipt.SourceIdentity = "" + receiptBytes, err = json.Marshal(tamperedReceipt) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tamperedDir, installReceiptName), receiptBytes, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tamperedDir, "driver.so"), []byte("example"), 0o644); err != nil { + t.Fatal(err) + } + symlinkDir := filepath.Join(root, ".dbc-package-example-symlink") + symlinkCreated := os.Symlink(exampleDir, symlinkDir) == nil + + newDir := filepath.Join(root, ".dbc-package-example-current") + referenced := DriverInfo{ID: "example", Source: "dbc", Version: example.Version} + referenced.Driver.Shared.defaultPath = example.Driver.Shared.Get(PlatformTuple()) + cleanupManagedPackageDirectories(root, "example", newDir, referenced) + if _, err := os.Stat(exampleDir); err != nil { + t.Fatalf("generation referenced by current runtime manifest was removed: %v", err) + } + current := DriverInfo{ID: "example", Source: "dbc", Version: example.Version} + current.Driver.Shared.defaultPath = filepath.Join(root, "external.so") + cleanupManagedPackageDirectories(root, "example", newDir, current) + if _, err := os.Stat(exampleDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("valid old generation was not cleaned: %v", err) + } + if _, err := os.Stat(tamperedDir); err != nil { + t.Fatalf("generation with tampered archive metadata was removed: %v", err) + } + if symlinkCreated { + if _, err := os.Lstat(symlinkDir); err != nil { + t.Fatalf("symlink generation was removed: %v", err) + } + } + if _, err := os.Stat(otherDir); err != nil { + t.Fatalf("another driver's generation was removed: %v", err) + } +} diff --git a/config/loaddir_js.go b/config/loaddir_js.go index 06b69c82..15911b5a 100644 --- a/config/loaddir_js.go +++ b/config/loaddir_js.go @@ -22,17 +22,19 @@ import ( "strings" ) -// loadDir lists installed drivers from a directory. Under GOOS=js, os.ReadDir -// and os.DirFS fail on Windows hosts because Go's js/wasm syscall layer rejects -// the O_DIRECTORY flag (Node.js does not expose it on Windows). We work around -// this by using os.Open (which omits O_DIRECTORY) followed by File.ReadDir. -func loadDir(dir string) (map[string]DriverInfo, error) { +// readDirEntries avoids os.ReadDir's O_DIRECTORY open flag, which the js/wasm +// filesystem layer does not support on Windows hosts. +func readDirEntries(dir string) ([]os.DirEntry, error) { f, err := os.Open(dir) if err != nil { return nil, err } - entries, err := f.ReadDir(-1) - f.Close() + defer f.Close() + return f.ReadDir(-1) +} + +func loadDir(dir string) (map[string]DriverInfo, error) { + entries, err := readDirEntries(dir) if err != nil { return nil, err } diff --git a/config/loaddir_other.go b/config/loaddir_other.go index e0f809a0..a9120a14 100644 --- a/config/loaddir_other.go +++ b/config/loaddir_other.go @@ -22,6 +22,10 @@ import ( "path/filepath" ) +func readDirEntries(dir string) ([]os.DirEntry, error) { + return os.ReadDir(dir) +} + func loadDir(dir string) (map[string]DriverInfo, error) { if _, err := os.Stat(dir); err != nil { return nil, err diff --git a/config/package_archive.go b/config/package_archive.go new file mode 100644 index 00000000..9cd3cf11 --- /dev/null +++ b/config/package_archive.go @@ -0,0 +1,590 @@ +// 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" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// InspectPackageMetadata decodes the archive's package metadata without +// installing files. The installer still validates +// the full archive before publication. The archive remains open for the caller. +func InspectPackageMetadata(downloaded *os.File) (Manifest, error) { + if downloaded == nil { + return Manifest{}, errors.New("package archive is nil") + } + workDir, err := os.MkdirTemp("", "dbc-package-inspect-") + if err != nil { + return Manifest{}, fmt.Errorf("could not create package inspection directory: %w", err) + } + defer os.RemoveAll(workDir) + archivePath := filepath.Join(workDir, "archive.tgz") + if _, _, err := snapshotArchive(downloaded, archivePath); err != nil { + return Manifest{}, fmt.Errorf("could not snapshot package archive: %w", err) + } + archive, err := os.Open(archivePath) + if err != nil { + return Manifest{}, err + } + defer archive.Close() + gz, err := gzip.NewReader(archive) + if err != nil { + return Manifest{}, fmt.Errorf("could not create gzip reader: %w", err) + } + defer gz.Close() + reader := tar.NewReader(gz) + seen := make(map[string]string) + metadata := make(map[string][]byte) + for { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return Manifest{}, fmt.Errorf("error reading package archive: %w", err) + } + if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeRegA { + if header.Typeflag == tar.TypeDir { + return Manifest{}, fmt.Errorf("found a directory entry %q; driver archives must be flat", header.Name) + } + return Manifest{}, fmt.Errorf("archive entry %q is not a regular file", header.Name) + } + if err := validateFlatName(header.Name); err != nil { + return Manifest{}, fmt.Errorf("invalid archive entry %q: %w", header.Name, err) + } + folded := strings.ToLower(header.Name) + if previous, ok := seen[folded]; ok { + return Manifest{}, fmt.Errorf("archive entries %q and %q collide by name", previous, header.Name) + } + seen[folded] = header.Name + metadataName, isMetadata, err := classifyPackageMetadataName(header.Name) + if err != nil { + return Manifest{}, err + } + if !isMetadata { + if _, err := io.Copy(io.Discard, reader); err != nil { + return Manifest{}, fmt.Errorf("could not skip package file %q: %w", header.Name, err) + } + continue + } + if header.Size < 0 || header.Size > maxPackageMetadataSize { + return Manifest{}, errors.New("package metadata size is invalid or exceeds 1 MiB") + } + data, err := io.ReadAll(io.LimitReader(reader, maxPackageMetadataSize+1)) + if err != nil { + return Manifest{}, fmt.Errorf("could not read package metadata: %w", err) + } + if int64(len(data)) != header.Size { + return Manifest{}, errors.New("package metadata size does not match its tar header") + } + metadata[metadataName] = data + } + metadataName, data, err := selectPackageMetadata(metadata) + if err != nil { + return Manifest{}, err + } + manifest, err := decodePackageManifest(metadataName, data) + if err != nil { + return Manifest{}, err + } + return manifest, nil +} + +func installPackageArchive(cfg Config, targetName, runtimeID string, downloaded *os.File, expected ExpectedPackageMetadata) (Manifest, error) { + var result Manifest + if downloaded == nil { + return result, errors.New("package archive is nil") + } + if err := validateFlatName(targetName); err != nil { + return result, fmt.Errorf("invalid installation directory name: %w", err) + } + if err := validateFlatName(runtimeID); err != nil { + return result, fmt.Errorf("invalid runtime driver id: %w", err) + } + loc, err := EnsureLocation(cfg) + if err != nil { + return result, fmt.Errorf("could not ensure config location: %w", err) + } + finalDir := filepath.Join(loc, targetName) + lockTarget, err := filepath.Abs(finalDir) + if err != nil { + return result, fmt.Errorf("could not resolve installation target: %w", err) + } + lockHash := sha256.Sum256([]byte(strings.ToLower(filepath.Clean(lockTarget)))) + lockPath := filepath.Join(loc, ".dbc-package-install-"+hex.EncodeToString(lockHash[:])+".lock") + releaseLock, err := acquirePackageInstallLock(lockPath) + if err != nil { + return result, fmt.Errorf("could not lock package installation target: %w", err) + } + defer releaseLock() + workDir, err := os.MkdirTemp(loc, ".dbc-install-") + if err != nil { + return result, fmt.Errorf("could not create private installation staging directory: %w", err) + } + defer os.RemoveAll(workDir) + manifest, payloadDir, _, err := stagePackageArchive(loc, runtimeID, finalDir, downloaded, expected, nil, workDir) + if err != nil { + return result, err + } + if err := publishDirectory(payloadDir, finalDir); err != nil { + return result, fmt.Errorf("could not publish package directory: %w", err) + } + return manifest, nil +} + +func stagePackageArchive(location, runtimeID, finalDir string, downloaded *os.File, expected ExpectedPackageMetadata, verify func(string, Manifest) error, workDir string) (Manifest, string, registrationSharedIdentity, error) { + var result Manifest + var sharedIdentity registrationSharedIdentity + archivePath := filepath.Join(workDir, "archive.tgz") + archiveHash, archiveSize, err := snapshotArchive(downloaded, archivePath) + if err != nil { + return result, "", sharedIdentity, fmt.Errorf("could not snapshot package archive: %w", err) + } + if expected.ArchiveHash != "" && expected.ArchiveHash != archiveHash { + return result, "", sharedIdentity, fmt.Errorf("package archive hash mismatch: got %s, expected %s", archiveHash, expected.ArchiveHash) + } + if expected.hasExpectedArchiveSize() && expected.ArchiveSize != archiveSize { + return result, "", sharedIdentity, fmt.Errorf("package archive size mismatch: got %d, expected %d", archiveSize, expected.ArchiveSize) + } + + payloadDir := filepath.Join(workDir, "payload") + if err := os.Mkdir(payloadDir, 0o700); err != nil { + return result, "", sharedIdentity, fmt.Errorf("could not create private package staging directory: %w", err) + } + manifest, files, err := extractPackageArchive(archivePath, payloadDir) + if err != nil { + return result, "", sharedIdentity, fmt.Errorf("failed to extract package archive: %w", err) + } + if expected.Version != "" && manifest.Version.String() != expected.Version { + return result, "", sharedIdentity, fmt.Errorf("package version mismatch: archive declares %q, expected %q", manifest.Version, expected.Version) + } + platform := expected.Platform + if platform == "" { + platform = PlatformTuple() + } + if err := validatePackageFileReferences(manifest, files); err != nil { + return result, "", sharedIdentity, err + } + if _, exists := files[strings.ToLower(installReceiptName)]; exists { + return result, "", sharedIdentity, fmt.Errorf("package archive uses reserved file name %q", installReceiptName) + } + if manifest.Files.Driver != "" { + if err := validateFlatName(manifest.Files.Driver); err != nil { + return result, "", sharedIdentity, fmt.Errorf("invalid package driver file name: %w", err) + } + sharedIdentity = registrationSharedIdentity{Kind: "package_file", PackageFile: manifest.Files.Driver} + } else { + sharedIdentity = registrationSharedIdentity{Kind: "external", ExternalReference: manifest.DriverInfo.Driver.Shared.Get(platform)} + } + + manifest.DriverInfo.ID = runtimeID + manifest.DriverInfo.Source = "dbc" + installedLibrary := "" + if manifest.Files.Driver != "" { + manifest.DriverInfo.Driver.Shared.Set(platform, filepath.Join(finalDir, manifest.Files.Driver)) + installedLibrary = manifest.Files.Driver + } else if !hasRuntimeSharedPath(manifest.DriverInfo.Driver.Shared) { + manifest.DriverInfo.Driver.Shared.Set(platform, finalDir) + } + if err := os.Chmod(payloadDir, 0o755); err != nil { + return result, "", sharedIdentity, fmt.Errorf("could not prepare package directory for publication: %w", err) + } + if verify != nil { + if err := verify(payloadDir, manifest); err != nil { + return result, "", sharedIdentity, fmt.Errorf("package verification failed: %w", err) + } + } + installedHash := "" + if installedLibrary != "" { + libraryPath := filepath.Join(payloadDir, installedLibrary) + libraryInfo, err := os.Lstat(libraryPath) + if err != nil { + return result, "", sharedIdentity, fmt.Errorf("could not inspect verified driver file: %w", err) + } + if !libraryInfo.Mode().IsRegular() { + return result, "", sharedIdentity, errors.New("verified driver file is not a regular file") + } + installedHash, err = hashFile(libraryPath) + if err != nil { + return result, "", sharedIdentity, fmt.Errorf("could not hash verified driver file: %w", err) + } + } + registrationFingerprint, err := runtimeRegistrationFingerprint(manifest.DriverInfo, platform, sharedIdentity) + if err != nil { + return result, "", sharedIdentity, fmt.Errorf("could not fingerprint package registration: %w", err) + } + receipt := InstallReceipt{ + SourceType: expected.SourceType, SourceIdentity: expected.SourceIdentity, + DriverID: runtimeID, DriverVersion: manifest.Version.String(), Platform: platform, + ArchiveHash: archiveHash, ArchiveSize: archiveSize, InstalledLibrary: installedLibrary, + InstalledLibraryHash: installedHash, + RegistrationFingerprintAlgorithm: registrationFingerprintAlgorithm, + RegistrationFingerprintVersion: registrationFingerprintVersion, + RegistrationFingerprint: registrationFingerprint, + } + if err := writeInstallReceipt(payloadDir, receipt); err != nil { + return result, "", sharedIdentity, fmt.Errorf("could not write installation receipt: %w", err) + } + return manifest, payloadDir, sharedIdentity, nil +} + +func hasRuntimeSharedPath(shared driverMap) bool { + if shared.defaultPath != "" { + return true + } + for _, path := range shared.platformMap { + if path != "" { + return true + } + } + return false +} + +func snapshotArchive(source *os.File, target string) (string, int64, error) { + if _, err := source.Seek(0, io.SeekStart); err != nil { + return "", 0, fmt.Errorf("could not seek to archive start: %w", err) + } + output, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return "", 0, err + } + h := sha256.New() + n, copyErr := io.Copy(io.MultiWriter(output, h), source) + syncErr := output.Sync() + closeErr := output.Close() + if copyErr != nil { + return "", n, copyErr + } + if syncErr != nil { + return "", n, syncErr + } + if closeErr != nil { + return "", n, closeErr + } + return "sha256:" + hex.EncodeToString(h.Sum(nil)), n, nil +} + +func extractPackageArchive(archivePath, payloadDir string) (Manifest, map[string]string, error) { + var empty Manifest + archive, err := os.Open(archivePath) + if err != nil { + return empty, nil, err + } + defer archive.Close() + gz, err := gzip.NewReader(archive) + if err != nil { + return empty, nil, fmt.Errorf("could not create gzip reader: %w", err) + } + defer gz.Close() + reader := tar.NewReader(gz) + seen := make(map[string]string) + metadata := make(map[string][]byte) + files := make(map[string]string) + for { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return empty, nil, fmt.Errorf("error reading tar archive: %w", err) + } + if header.Typeflag == tar.TypeDir { + return empty, nil, fmt.Errorf("found a directory entry %q; driver archives must be flat", header.Name) + } + if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeRegA { + return empty, nil, fmt.Errorf("archive entry %q is not a regular file", header.Name) + } + for key := range header.PAXRecords { + if strings.HasPrefix(key, "GNU.sparse") { + return empty, nil, fmt.Errorf("archive entry %q uses an unsupported sparse-file extension", header.Name) + } + } + if err := validateFlatName(header.Name); err != nil { + return empty, nil, fmt.Errorf("invalid archive entry %q: %w", header.Name, err) + } + if header.Size < 0 { + return empty, nil, fmt.Errorf("archive entry %q has a negative size", header.Name) + } + folded := strings.ToLower(header.Name) + if previous, ok := seen[folded]; ok { + return empty, nil, fmt.Errorf("archive entries %q and %q collide by name", previous, header.Name) + } + seen[folded] = header.Name + metadataName, isMetadata, err := classifyPackageMetadataName(header.Name) + if err != nil { + return empty, nil, err + } + if isMetadata { + if header.Size > maxPackageMetadataSize { + return empty, nil, errors.New("package metadata exceeds 1 MiB") + } + data, err := io.ReadAll(io.LimitReader(reader, maxPackageMetadataSize+1)) + if err != nil { + return empty, nil, fmt.Errorf("could not read package metadata: %w", err) + } + if int64(len(data)) != header.Size { + return empty, nil, errors.New("package metadata size does not match its tar header") + } + metadata[metadataName] = data + continue + } + filePath := filepath.Join(payloadDir, header.Name) + mode := os.FileMode(header.Mode & 0o777) + if mode == 0 { + mode = 0o644 + } + file, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) + if err != nil { + return empty, nil, fmt.Errorf("could not create staged package file %q: %w", header.Name, err) + } + written, writeErr := io.Copy(file, reader) + if writeErr == nil && written != header.Size { + writeErr = fmt.Errorf("archive entry %q size mismatch: read %d, header says %d", header.Name, written, header.Size) + } + if writeErr == nil { + writeErr = file.Sync() + } + closeErr := file.Close() + if writeErr != nil { + return empty, nil, fmt.Errorf("could not write staged package file %q: %w", header.Name, writeErr) + } + if closeErr != nil { + return empty, nil, fmt.Errorf("could not close staged package file %q: %w", header.Name, closeErr) + } + files[folded] = header.Name + } + metadataName, data, err := selectPackageMetadata(metadata) + if err != nil { + return empty, nil, err + } + manifest, err := decodePackageManifest(metadataName, data) + if err != nil { + return empty, nil, err + } + if _, err := io.Copy(io.Discard, gz); err != nil { + return empty, nil, fmt.Errorf("could not verify gzip stream: %w", err) + } + if err := validatePackageFileReferences(manifest, files); err != nil { + return empty, nil, err + } + return manifest, files, nil +} + +func validatePackageFileReferences(manifest Manifest, files map[string]string) error { + driver := manifest.Files.Driver + if driver != "" { + if err := validateFlatName(driver); err != nil { + return fmt.Errorf("%w: invalid Files.driver: %v", ErrInvalidManifest, err) + } + if archived, exists := files[strings.ToLower(driver)]; !exists || archived != driver { + return fmt.Errorf("%w: driver file %q is missing from archive", ErrInvalidManifest, driver) + } + } + if signature := manifest.Files.Signature; signature != "" { + if err := validateFlatName(signature); err != nil { + return fmt.Errorf("%w: invalid Files.signature: %v", ErrInvalidManifest, err) + } + if archived, exists := files[strings.ToLower(signature)]; !exists || archived != signature { + return fmt.Errorf("%w: signature file %q is missing from archive", ErrInvalidManifest, signature) + } + } + return nil +} + +func validateFlatName(name string) error { + if name == "" || name == "." || name == ".." { + return errors.New("name must be a non-empty flat file name") + } + if strings.ContainsAny(name, "/\\\x00:") || filepath.IsAbs(name) { + return errors.New("absolute paths and path separators are not allowed") + } + if strings.TrimSpace(name) != name || strings.HasSuffix(name, ".") { + return errors.New("trailing spaces and dots are not allowed") + } + for _, char := range name { + if char < 0x20 || strings.ContainsRune(`<>"|?*`, char) { + return errors.New("name contains a character unsupported by Windows") + } + } + deviceName := strings.ToUpper(strings.TrimRight(strings.SplitN(name, ".", 2)[0], " .")) + switch deviceName { + case "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$": + return errors.New("name is reserved by Windows") + } + for _, prefix := range []string{"COM", "LPT"} { + for _, suffix := range []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "¹", "²", "³"} { + if strings.EqualFold(deviceName, prefix+suffix) { + return errors.New("name is reserved by Windows") + } + } + } + return nil +} + +func validatePlatformIdentifier(platform string) error { + if platform == "" || strings.TrimSpace(platform) != platform { + return errors.New("platform is required and must not have surrounding whitespace") + } + for _, char := range platform { + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || + (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' { + continue + } + return errors.New("platform may contain only letters, digits, dot, underscore, and hyphen") + } + return nil +} + +func parseSHA256(value string) ([]byte, error) { + if !strings.HasPrefix(value, "sha256:") { + return nil, errors.New("hash must use sha256:") + } + hexValue := strings.TrimPrefix(value, "sha256:") + if len(hexValue) != sha256.Size*2 || strings.ToLower(hexValue) != hexValue { + return nil, errors.New("sha256 digest must be 64 lowercase hexadecimal characters") + } + decoded, err := hex.DecodeString(hexValue) + if err != nil { + return nil, err + } + return decoded, nil +} + +func hashFile(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", err + } + defer file.Close() + h := sha256.New() + if _, err := io.Copy(h, file); err != nil { + return "", err + } + return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil +} + +func writeInstallReceipt(directory string, receipt InstallReceipt) error { + data, err := json.MarshalIndent(receipt, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + temp, err := os.CreateTemp(directory, ".receipt-*.tmp") + if err != nil { + return err + } + tempName := temp.Name() + defer os.Remove(tempName) + if err := temp.Chmod(0o600); err != nil { + _ = temp.Close() + return err + } + if _, err := temp.Write(data); err != nil { + _ = temp.Close() + return err + } + if err := temp.Sync(); err != nil { + _ = temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + return os.Rename(tempName, filepath.Join(directory, installReceiptName)) +} + +func publishDirectory(staged, target string) error { + // This protects the package directory against ordinary rename failures. It + // does not make publication of this directory and the caller's separate + // runtime manifest update one transaction. + parent := filepath.Dir(target) + info, err := os.Lstat(target) + if errors.Is(err, os.ErrNotExist) { + return os.Rename(staged, target) + } + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("existing installation target %q is not a directory", target) + } + backup, err := os.MkdirTemp(parent, ".dbc-backup-") + if err != nil { + return fmt.Errorf("could not reserve installation backup path: %w", err) + } + if err := os.Remove(backup); err != nil { + return fmt.Errorf("could not prepare installation backup path: %w", err) + } + if err := os.Rename(target, backup); err != nil { + return fmt.Errorf("could not preserve existing installation: %w", err) + } + if err := os.Rename(staged, target); err != nil { + restoreErr := os.Rename(backup, target) + if restoreErr != nil { + return fmt.Errorf("could not publish new installation: %w; restoring previous installation also failed: %v (preserved at %s)", err, restoreErr, backup) + } + return fmt.Errorf("could not publish new installation: %w", err) + } + // The new package is complete and visible. A leftover backup is safe if the + // filesystem refuses cleanup, so cleanup failure does not invalidate install. + _ = os.RemoveAll(backup) + return nil +} + +func publishExtractedFiles(staged string, outDir string, names []string) error { + created := make([]string, 0, len(names)) + rollback := func() { + for _, name := range created { + _ = os.Remove(filepath.Join(outDir, name)) + } + } + for _, name := range names { + destination := filepath.Join(outDir, name) + file, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + rollback() + return fmt.Errorf("could not create extracted file %q: %w", name, err) + } + created = append(created, name) + source, err := os.Open(filepath.Join(staged, name)) + if err != nil { + _ = file.Close() + rollback() + return fmt.Errorf("could not open staged file %q: %w", name, err) + } + _, copyErr := io.Copy(file, source) + stat, statErr := source.Stat() + closeSourceErr := source.Close() + closeDestErr := file.Close() + if copyErr == nil && statErr == nil { + copyErr = os.Chmod(destination, stat.Mode().Perm()) + } + if copyErr != nil || statErr != nil || closeSourceErr != nil || closeDestErr != nil { + rollback() + return fmt.Errorf("could not publish extracted file %q: %w", name, errors.Join(copyErr, statErr, closeSourceErr, closeDestErr)) + } + } + return nil +} diff --git a/config/package_archive_test.go b/config/package_archive_test.go new file mode 100644 index 00000000..34fcf96e --- /dev/null +++ b/config/package_archive_test.go @@ -0,0 +1,693 @@ +// 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_test + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/columnar-tech/dbc/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type archiveEntry struct { + name string + data []byte + typeflag byte + linkname string +} + +func legacyPackageManifest(version, driverFile string) []byte { + return []byte(fmt.Sprintf(`name = "Example Driver" +version = %q + +[Driver] +entrypoint = "AdbcDriverExampleInit" + +[Files] +driver = %q +`, version, driverFile)) +} + +func makePackageArchive(t *testing.T, entries ...archiveEntry) []byte { + t.Helper() + var out bytes.Buffer + gz := gzip.NewWriter(&out) + tarWriter := tar.NewWriter(gz) + for _, entry := range entries { + typeflag := entry.typeflag + if typeflag == 0 { + typeflag = tar.TypeReg + } + header := &tar.Header{ + Name: entry.name, Mode: 0o644, Size: int64(len(entry.data)), + Typeflag: typeflag, Linkname: entry.linkname, Format: tar.FormatPAX, + } + if typeflag == tar.TypeDir { + header.Name = strings.TrimSuffix(header.Name, "/") + "/" + header.Size = 0 + } + if typeflag == tar.TypeSymlink || typeflag == tar.TypeLink { + header.Size = 0 + } + require.NoError(t, tarWriter.WriteHeader(header)) + if len(entry.data) > 0 { + _, err := tarWriter.Write(entry.data) + require.NoError(t, err) + } + } + require.NoError(t, tarWriter.Close()) + require.NoError(t, gz.Close()) + return out.Bytes() +} + +func openPackageArchive(t *testing.T, data []byte) *os.File { + t.Helper() + file, err := os.CreateTemp(t.TempDir(), "package-*.tar.gz") + require.NoError(t, err) + t.Cleanup(func() { _ = file.Close() }) + _, err = file.Write(data) + require.NoError(t, err) + _, err = file.Seek(0, 0) + require.NoError(t, err) + return file +} + +func expectedPackage(id, version, platform, source string, archive []byte) config.ExpectedPackageMetadata { + digest := sha256.Sum256(archive) + return config.ExpectedPackageMetadata{ + ID: id, Version: version, Platform: platform, + SourceType: "packslip", SourceIdentity: source, + ArchiveHash: "sha256:" + hex.EncodeToString(digest[:]), ArchiveSize: int64(len(archive)), + } +} + +func validLegacyArchive(t *testing.T, contents []byte) []byte { + t.Helper() + manifest := legacyPackageManifest("1.2.3", "libexample.so") + return makePackageArchive(t, + archiveEntry{name: "MANIFEST", data: manifest}, + archiveEntry{name: "libexample.so", data: contents}, + archiveEntry{name: "LICENSE", data: []byte("license")}, + ) +} + +func TestPackageArchiveMetadataVersions(t *testing.T) { + t.Run("legacy package manifest is adapted", func(t *testing.T) { + archive, err := os.Open(filepath.Join("..", "cmd", "dbc", "testdata", "test-driver-1.tar.gz")) + require.NoError(t, err) + outDir := t.TempDir() + manifest, err := config.InflateTarball(archive, outDir) + require.NoError(t, err) + assert.Equal(t, "Test Driver 1", manifest.Name) + assert.Equal(t, "1.0.0", manifest.Version.String()) + assert.FileExists(t, filepath.Join(outDir, manifest.Files.Driver)) + }) + + t.Run("legacy metadata is inspected", func(t *testing.T) { + archivePath := filepath.Join("..", "cmd", "dbc", "testdata", "test-driver-1.tar.gz") + f, err := os.Open(archivePath) + require.NoError(t, err) + defer f.Close() + manifest, err := config.InspectPackageMetadata(f) + require.NoError(t, err) + assert.Equal(t, "Test Driver 1", manifest.Name) + }) + + t.Run("legacy manifest-only package keeps its runtime shared value", func(t *testing.T) { + archivePath := filepath.Join("..", "cmd", "dbc", "testdata", "test-driver-manifest-only.tar.gz") + f, err := os.Open(archivePath) + require.NoError(t, err) + root := t.TempDir() + cfg := config.Config{Level: config.ConfigEnv, Location: root} + manifest, err := config.InstallDriver(cfg, "test-driver-manifest-only", f) + require.NoError(t, err) + assert.Empty(t, manifest.Files.Driver) + assert.Equal(t, "test_driver", manifest.Driver.Shared.Get(config.PlatformTuple())) + + receipt, err := os.ReadFile(filepath.Join(root, "test-driver-manifest-only", "dbc-install-receipt.json")) + require.NoError(t, err) + assert.NotContains(t, string(receipt), "installed_library_hash") + + require.NoError(t, config.CreateManifest(cfg, manifest.DriverInfo)) + runtimeManifest, err := os.ReadFile(filepath.Join(root, "test-driver-manifest-only.toml")) + require.NoError(t, err) + assert.Contains(t, string(runtimeManifest), "shared = 'test_driver'") + assert.Contains(t, string(runtimeManifest), "source = 'dbc'") + }) + + t.Run("legacy manifest-only platform shared table keeps its runtime library path", func(t *testing.T) { + platform := config.PlatformTuple() + libraryPath := "/opt/external/libexample.so" + packageManifest := []byte(fmt.Sprintf(`name = "Legacy Table Driver" +version = "1.0.0" + +[Driver] +entrypoint = "AdbcDriverLegacyTableInit" + +[Driver.shared] +%q = %q +`, platform, libraryPath)) + archive := makePackageArchive(t, archiveEntry{name: "MANIFEST", data: packageManifest}) + f := openPackageArchive(t, archive) + root := t.TempDir() + cfg := config.Config{Level: config.ConfigEnv, Location: root} + manifest, err := config.InstallDriver(cfg, "legacy-table", f) + require.NoError(t, err) + assert.Empty(t, manifest.Files.Driver) + assert.Equal(t, libraryPath, manifest.Driver.Shared.Get(platform)) + + require.NoError(t, config.CreateManifest(cfg, manifest.DriverInfo)) + runtimeManifest, err := os.ReadFile(filepath.Join(root, "legacy-table.toml")) + require.NoError(t, err) + assert.Contains(t, string(runtimeManifest), libraryPath) + loaded, err := config.GetDriver(cfg, "legacy-table") + require.NoError(t, err) + assert.Equal(t, libraryPath, loaded.Driver.Shared.Get(platform)) + }) +} + +func TestLegacyPackageManifestVersionParsing(t *testing.T) { + t.Run("legacy manifest keeps permissive version parsing", func(t *testing.T) { + archive := makePackageArchive(t, archiveEntry{name: "MANIFEST", data: []byte(`name = "Legacy Driver" +version = "1.2" + +[Driver] +shared = "legacy_driver" +`)}) + manifest, err := config.InspectPackageMetadata(openPackageArchive(t, archive)) + require.NoError(t, err) + require.NotNil(t, manifest.Version) + assert.Equal(t, "1.2.0", manifest.Version.String()) + }) +} + +func TestPackageArchiveMetadataFilenames(t *testing.T) { + legacy := []byte("name = 'Legacy Driver'\nversion = '1.0.0'\n") + tests := []struct { + name string + entries []archiveEntry + wantMessage string + }{ + { + name: "missing metadata", + entries: []archiveEntry{{name: "driver.so", data: []byte("library")}}, + wantMessage: "must contain MANIFEST metadata", + }, + { + name: "both formats", + entries: []archiveEntry{ + {name: "MANIFEST", data: legacy}, + {name: "dbc-package.toml", data: []byte("not valid TOML =")}, + {name: "driver.so", data: []byte("library")}, + }, + wantMessage: "must not contain both", + }, + { + name: "both formats in reverse order", + entries: []archiveEntry{ + {name: "dbc-package.toml", data: []byte("not valid TOML =")}, + {name: "MANIFEST", data: legacy}, + }, + wantMessage: "must not contain both", + }, + { + name: "reserved filename is recognized without parsing or extraction", + entries: []archiveEntry{{name: "dbc-package.toml", data: []byte("not valid TOML =")}, {name: "payload.bin", data: []byte("payload")}}, + wantMessage: "reserved for future use and is not supported yet", + }, + { + name: "legacy filename case mismatch", + entries: []archiveEntry{{name: "manifest", data: legacy}}, + wantMessage: `must be named exactly "MANIFEST"`, + }, + { + name: "reserved filename case mismatch", + entries: []archiveEntry{{name: "dbc-Package.toml", data: []byte("reserved")}}, + wantMessage: `must be named exactly "dbc-package.toml"`, + }, + { + name: "legacy case collision", + entries: []archiveEntry{ + {name: "MANIFEST", data: legacy}, + {name: "manifest", data: legacy}, + }, + wantMessage: "collide by name", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + archive := makePackageArchive(t, tt.entries...) + for name, inspect := range map[string]func(*os.File, string) error{ + "inspect": func(file *os.File, _ string) error { + _, err := config.InspectPackageMetadata(file) + return err + }, + "extract": func(file *os.File, outDir string) error { + _, err := config.InflateTarball(file, outDir) + return err + }, + } { + t.Run(name, func(t *testing.T) { + outDir := t.TempDir() + err := inspect(openPackageArchive(t, archive), outDir) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantMessage) + if tt.name == "reserved filename is recognized without parsing or extraction" && name == "extract" { + entries, readErr := os.ReadDir(outDir) + require.NoError(t, readErr) + assert.Empty(t, entries) + } + }) + } + }) + } +} + +func TestInspectPackageMetadataScansPastMetadata(t *testing.T) { + archive := makePackageArchive(t, + archiveEntry{name: "MANIFEST", data: legacyPackageManifest("1.2.3", "driver.so")}, + archiveEntry{name: "driver.so", data: []byte("library")}, + archiveEntry{name: "nested/file", data: []byte("unsafe")}, + ) + _, err := config.InspectPackageMetadata(openPackageArchive(t, archive)) + require.Error(t, err) + assert.Contains(t, err.Error(), "path separators") +} + +func TestPackageMetadataSizeLimitAppliesToMetadataFilenames(t *testing.T) { + oversized := bytes.Repeat([]byte("x"), (1<<20)+1) + for _, name := range []string{"MANIFEST", "dbc-package.toml"} { + archive := makePackageArchive(t, archiveEntry{name: name, data: oversized}) + for _, inspect := range []func(*os.File) error{ + func(file *os.File) error { + _, err := config.InspectPackageMetadata(file) + return err + }, + func(file *os.File) error { + _, err := config.InflateTarball(file, t.TempDir()) + return err + }, + } { + err := inspect(openPackageArchive(t, archive)) + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds 1 MiB") + } + } +} + +func TestInstallDriverRejectsNilArchive(t *testing.T) { + _, err := config.InstallDriver(config.Config{}, "example", nil) + require.Error(t, err) +} + +func TestInflateTarballRejectsArchiveAttacks(t *testing.T) { + manifest := []byte("name = 'Legacy Driver'\nversion = '1.0.0'\n\n[Files]\ndriver = 'driver.so'\n") + tests := []struct { + name string + entries []archiveEntry + }{ + {name: "absolute unix path", entries: []archiveEntry{{name: "MANIFEST", data: manifest}, {name: "/tmp/escape", data: []byte("bad")}}}, + {name: "absolute windows path", entries: []archiveEntry{{name: "MANIFEST", data: manifest}, {name: `C:\escape`, data: []byte("bad")}}}, + {name: "parent path", entries: []archiveEntry{{name: "MANIFEST", data: manifest}, {name: "../escape", data: []byte("bad")}}}, + {name: "nested path", entries: []archiveEntry{{name: "MANIFEST", data: manifest}, {name: "nested/file", data: []byte("bad")}}}, + {name: "windows reserved name", entries: []archiveEntry{{name: "MANIFEST", data: manifest}, {name: "CON.txt", data: []byte("bad")}}}, + {name: "duplicate case-folded names", entries: []archiveEntry{{name: "MANIFEST", data: manifest}, {name: "driver.so", data: []byte("one")}, {name: "DRIVER.SO", data: []byte("two")}}}, + {name: "duplicate manifest", entries: []archiveEntry{{name: "MANIFEST", data: manifest}, {name: "MANIFEST", data: manifest}}}, + {name: "directory", entries: []archiveEntry{{name: "MANIFEST", data: manifest}, {name: "nested", typeflag: tar.TypeDir}}}, + {name: "symlink", entries: []archiveEntry{{name: "MANIFEST", data: manifest}, {name: "link", typeflag: tar.TypeSymlink, linkname: "driver.so"}}}, + {name: "hardlink", entries: []archiveEntry{{name: "MANIFEST", data: manifest}, {name: "link", typeflag: tar.TypeLink, linkname: "driver.so"}}}, + {name: "manifest missing", entries: []archiveEntry{{name: "driver.so", data: []byte("library")}}}, + {name: "manifest case mismatch", entries: []archiveEntry{{name: "manifest", data: manifest}, {name: "driver.so", data: []byte("library")}}}, + {name: "driver missing", entries: []archiveEntry{{name: "MANIFEST", data: manifest}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := makePackageArchive(t, tt.entries...) + outDir := t.TempDir() + f := openPackageArchive(t, data) + _, err := config.InflateTarball(f, outDir) + require.Error(t, err) + entries, readErr := os.ReadDir(outDir) + require.NoError(t, readErr) + assert.Empty(t, entries, "failed extraction must not publish partial files") + }) + } +} + +func TestInstallPackageChecksMetadataAndDigests(t *testing.T) { + archive := validLegacyArchive(t, []byte("library")) + tests := []struct { + name string + mutate func(*config.ExpectedPackageMetadata) + }{ + {name: "id mismatch", mutate: func(e *config.ExpectedPackageMetadata) { e.ID = "other" }}, + {name: "version mismatch", mutate: func(e *config.ExpectedPackageMetadata) { e.Version = "1.2.4" }}, + {name: "invalid platform", mutate: func(e *config.ExpectedPackageMetadata) { e.Platform = "invalid platform" }}, + {name: "archive hash mismatch", mutate: func(e *config.ExpectedPackageMetadata) { e.ArchiveHash = "sha256:" + strings.Repeat("0", 63) + "1" }}, + {name: "archive size mismatch", mutate: func(e *config.ExpectedPackageMetadata) { e.ArchiveSize++ }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + cfg := config.Config{Level: config.ConfigEnv, Location: root} + expected := expectedPackage("example", "1.2.3", config.PlatformTuple(), "github.com/example/project", archive) + tt.mutate(&expected) + f := openPackageArchive(t, archive) + _, err := config.InstallPackage(cfg, "example", f, expected, config.InstallOptions{}) + _ = f.Close() + require.Error(t, err) + assert.NoDirExists(t, filepath.Join(root, "example")) + }) + } +} + +func TestPackslipPackageInstallsLegacyArchive(t *testing.T) { + legacy := []byte(`name = "Legacy Driver" +version = "1.2.3" + +[Driver] +shared = "external-driver" +`) + archive := makePackageArchive(t, archiveEntry{name: "MANIFEST", data: legacy}) + root := t.TempDir() + cfg := config.Config{Level: config.ConfigEnv, Location: root} + candidate := expectedPackage("example", "1.2.3", config.PlatformTuple(), "packslip-project", archive) + candidateFile := openPackageArchive(t, archive) + installed, err := config.InstallPackage(cfg, "example", candidateFile, candidate, config.InstallOptions{}) + require.NoError(t, err) + require.NoError(t, candidateFile.Close()) + assert.Equal(t, "dbc", installed.Source) + assert.Empty(t, installed.Files.Driver) + assert.Equal(t, "external-driver", installed.Driver.Shared.Get(config.PlatformTuple())) + receipts, err := filepath.Glob(filepath.Join(root, "*", "dbc-install-receipt.json")) + require.NoError(t, err) + assert.Len(t, receipts, 1) +} + +func TestPreparePackageIsNonMutatingAndPreparedPackageCanBeInstalled(t *testing.T) { + root := t.TempDir() + cfg := config.Config{Level: config.ConfigEnv, Location: root} + oldArchive := validLegacyArchive(t, []byte("existing library")) + oldFile := openPackageArchive(t, oldArchive) + oldManifest, err := config.InstallPackage(cfg, "example", oldFile, + expectedPackage("example", "1.2.3", config.PlatformTuple(), "old-source", oldArchive), config.InstallOptions{}) + require.NoError(t, err) + require.NoError(t, oldFile.Close()) + oldRegistration, err := os.ReadFile(filepath.Join(root, "example.toml")) + require.NoError(t, err) + oldLibrary, err := os.ReadFile(oldManifest.Driver.Shared.Get(config.PlatformTuple())) + require.NoError(t, err) + entriesBefore, err := os.ReadDir(root) + require.NoError(t, err) + + candidateLibrary := []byte("candidate verified library") + candidateArchive := validLegacyArchive(t, candidateLibrary) + candidate := expectedPackage("example", "1.2.3", config.PlatformTuple(), "candidate-source", candidateArchive) + candidateFile := openPackageArchive(t, candidateArchive) + defer candidateFile.Close() + verifyCalls := 0 + var validationStagingDir string + options := config.InstallOptions{Verify: func(stagingDir string, manifest config.Manifest) error { + verifyCalls++ + validationStagingDir = stagingDir + got, readErr := os.ReadFile(filepath.Join(stagingDir, manifest.Files.Driver)) + if readErr != nil { + return readErr + } + if !bytes.Equal(got, candidateLibrary) { + return fmt.Errorf("staged library = %q, want %q", got, candidateLibrary) + } + return nil + }} + validation, err := config.PreparePackage(cfg, "example", candidateFile, candidate, options) + require.NoError(t, err) + require.NotNil(t, validation.Prepared) + t.Cleanup(func() { require.NoError(t, validation.Prepared.Close()) }) + wantLibraryHash := sha256.Sum256(candidateLibrary) + assert.Equal(t, "sha256:"+hex.EncodeToString(wantLibraryHash[:]), validation.VerifiedLibraryHash) + assert.NotEqual(t, candidate.ArchiveHash, validation.VerifiedLibraryHash) + assert.Equal(t, 1, verifyCalls) + _, err = os.Stat(validationStagingDir) + assert.NoError(t, err, "prepared payload remains staged until it is installed or closed") + + registrationAfterValidation, err := os.ReadFile(filepath.Join(root, "example.toml")) + require.NoError(t, err) + assert.Equal(t, oldRegistration, registrationAfterValidation) + currentLibrary, err := os.ReadFile(oldManifest.Driver.Shared.Get(config.PlatformTuple())) + require.NoError(t, err) + assert.Equal(t, oldLibrary, currentLibrary) + entriesAfter, err := os.ReadDir(root) + require.NoError(t, err) + assert.Len(t, entriesAfter, len(entriesBefore)+1, "preparation adds one private workspace without changing the registered package") + assert.Contains(t, entryNames(entriesAfter), filepath.Base(filepath.Dir(validationStagingDir))) + + ensure, err := config.EnsurePackage(context.Background(), cfg, "example", candidate, config.EnsurePackageCallbacks{ + CurrentMatches: func(*config.DriverInfo) (bool, error) { return false, nil }, + Prepare: func(context.Context) (*config.PreparedPackage, error) { return validation.Prepared, nil }, + }) + require.NoError(t, err, "the prepared payload should remain reusable for EnsurePackage") + require.NotNil(t, ensure.Manifest) + assert.Equal(t, 1, verifyCalls, "prepared package verification is reused for installation") + _, err = os.Stat(validationStagingDir) + assert.ErrorIs(t, err, os.ErrNotExist, "EnsurePackage closes the consumed prepared workspace") + installedLibrary, err := os.ReadFile(ensure.Manifest.Driver.Shared.Get(config.PlatformTuple())) + require.NoError(t, err) + assert.Equal(t, candidateLibrary, installedLibrary) +} + +func TestPreparePackageRejectsMetadataAndVerifierFailures(t *testing.T) { + archive := validLegacyArchive(t, []byte("library")) + mutations := []struct { + name string + mutate func(*config.ExpectedPackageMetadata) + }{ + {name: "id", mutate: func(expected *config.ExpectedPackageMetadata) { expected.ID = "other" }}, + {name: "version", mutate: func(expected *config.ExpectedPackageMetadata) { expected.Version = "1.2.4" }}, + {name: "platform", mutate: func(expected *config.ExpectedPackageMetadata) { expected.Platform = "invalid platform" }}, + {name: "archive hash", mutate: func(expected *config.ExpectedPackageMetadata) { + expected.ArchiveHash = "sha256:" + strings.Repeat("0", 64) + }}, + {name: "archive size", mutate: func(expected *config.ExpectedPackageMetadata) { expected.ArchiveSize++ }}, + } + for _, tt := range mutations { + t.Run(tt.name, func(t *testing.T) { + expected := expectedPackage("example", "1.2.3", config.PlatformTuple(), "source", archive) + tt.mutate(&expected) + file := openPackageArchive(t, archive) + defer file.Close() + cfg := config.Config{Level: config.ConfigEnv, Location: t.TempDir()} + validation, err := config.PreparePackage(cfg, "example", file, expected, config.InstallOptions{}) + if validation.Prepared != nil { + require.NoError(t, validation.Prepared.Close()) + } + require.Error(t, err) + }) + } + + t.Run("signature verifier error", func(t *testing.T) { + verifyErr := errors.New("signature rejected") + var stagingDir string + file := openPackageArchive(t, archive) + defer file.Close() + cfg := config.Config{Level: config.ConfigEnv, Location: t.TempDir()} + validation, err := config.PreparePackage(cfg, "example", file, + expectedPackage("example", "1.2.3", config.PlatformTuple(), "source", archive), + config.InstallOptions{Verify: func(dir string, _ config.Manifest) error { + stagingDir = dir + return verifyErr + }}) + if validation.Prepared != nil { + require.NoError(t, validation.Prepared.Close()) + } + require.ErrorIs(t, err, verifyErr) + _, statErr := os.Stat(stagingDir) + assert.ErrorIs(t, statErr, os.ErrNotExist) + }) +} + +func entryNames(entries []os.DirEntry) []string { + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + return names +} + +func TestInstallPackageReceiptReplacementAndRuntimeManifest(t *testing.T) { + root := t.TempDir() + cfg := config.Config{Level: config.ConfigEnv, Location: root} + firstArchive := validLegacyArchive(t, []byte("first library")) + first := expectedPackage("example", "1.2.3", config.PlatformTuple(), "github.com/first/source", firstArchive) + f := openPackageArchive(t, firstArchive) + manifest, err := config.InstallPackage(cfg, "example", f, first, config.InstallOptions{}) + require.NoError(t, err) + _ = f.Close() + assert.Equal(t, "dbc", manifest.Source) + firstLibraryPath := manifest.Driver.Shared.Get(config.PlatformTuple()) + assert.FileExists(t, firstLibraryPath) + + firstReceiptPath := filepath.Join(filepath.Dir(firstLibraryPath), "dbc-install-receipt.json") + firstReceiptBytes, err := os.ReadFile(firstReceiptPath) + require.NoError(t, err) + var firstReceipt config.InstallReceipt + require.NoError(t, json.Unmarshal(firstReceiptBytes, &firstReceipt)) + assert.Equal(t, "github.com/first/source", firstReceipt.SourceIdentity) + assert.Equal(t, first.ArchiveHash, firstReceipt.ArchiveHash) + assert.True(t, config.InstallReceiptMatchesExpectedPackage(firstReceipt, first), "the receipt proves the selected source and archive") + assert.NotEqual(t, firstReceipt.ArchiveHash, firstReceipt.InstalledLibraryHash) + installedDigest := sha256.Sum256([]byte("first library")) + assert.Equal(t, "sha256:"+hex.EncodeToString(installedDigest[:]), firstReceipt.InstalledLibraryHash) + + secondArchive := validLegacyArchive(t, []byte("second library")) + second := expectedPackage("example", "1.2.3", config.PlatformTuple(), "github.com/second/source", secondArchive) + f = openPackageArchive(t, secondArchive) + manifest, err = config.InstallPackage(cfg, "example", f, second, config.InstallOptions{}) + require.NoError(t, err) + _ = f.Close() + assert.Equal(t, "dbc", manifest.Source, "runtime source retains dbc uninstall semantics") + secondLibraryPath := manifest.Driver.Shared.Get(config.PlatformTuple()) + contents, err := os.ReadFile(secondLibraryPath) + require.NoError(t, err) + assert.Equal(t, "second library", string(contents)) + + secondReceiptPath := filepath.Join(filepath.Dir(secondLibraryPath), "dbc-install-receipt.json") + secondReceiptBytes, err := os.ReadFile(secondReceiptPath) + require.NoError(t, err) + var secondReceipt config.InstallReceipt + require.NoError(t, json.Unmarshal(secondReceiptBytes, &secondReceipt)) + assert.Equal(t, "github.com/second/source", secondReceipt.SourceIdentity) + assert.Equal(t, second.ArchiveHash, secondReceipt.ArchiveHash) + assert.NotEqual(t, firstReceipt.ArchiveHash, secondReceipt.ArchiveHash) + + require.NoError(t, config.CreateManifest(cfg, manifest.DriverInfo)) + runtimeManifestPath := filepath.Join(root, "example.toml") + runtimeBytes, err := os.ReadFile(runtimeManifestPath) + require.NoError(t, err) + runtimeText := string(runtimeBytes) + assert.Contains(t, runtimeText, "manifest_version = 1") + assert.Contains(t, runtimeText, "source = 'dbc'") + assert.Contains(t, runtimeText, "[Driver.shared]") + assert.NotContains(t, runtimeText, "[Files]") + + loaded, err := config.GetDriver(cfg, "example") + require.NoError(t, err) + assert.Equal(t, "dbc", loaded.Source) + assert.Equal(t, secondLibraryPath, loaded.Driver.Shared.Get(config.PlatformTuple())) +} + +func TestInstallReceiptProofIncludesSourceAndArchive(t *testing.T) { + expected := config.ExpectedPackageMetadata{ + ID: "example", Version: "1.2.3", Platform: config.PlatformTuple(), + SourceType: "packslip", SourceIdentity: "github.com/example/driver", + ArchiveHash: "sha256:" + strings.Repeat("a", 64), ArchiveSize: 10, + } + receipt := config.InstallReceipt{ + DriverID: "example", DriverVersion: "1.2.3", Platform: config.PlatformTuple(), + SourceType: "packslip", SourceIdentity: "github.com/example/driver", + ArchiveHash: expected.ArchiveHash, ArchiveSize: expected.ArchiveSize, + } + assert.True(t, config.InstallReceiptMatchesExpectedPackage(receipt, expected)) + receipt.SourceIdentity = "another/source" + assert.False(t, config.InstallReceiptMatchesExpectedPackage(receipt, expected)) +} + +func TestFailedReplacementPreservesExistingPackage(t *testing.T) { + root := t.TempDir() + cfg := config.Config{Level: config.ConfigEnv, Location: root} + archive := validLegacyArchive(t, []byte("installed library")) + expected := expectedPackage("example", "1.2.3", config.PlatformTuple(), "github.com/example/source", archive) + f := openPackageArchive(t, archive) + installed, err := config.InstallPackage(cfg, "example", f, expected, config.InstallOptions{}) + require.NoError(t, err) + _ = f.Close() + originalLibraryPath := installed.Driver.Shared.Get(config.PlatformTuple()) + originalReceiptPath := filepath.Join(filepath.Dir(originalLibraryPath), "dbc-install-receipt.json") + originalLibrary, err := os.ReadFile(originalLibraryPath) + require.NoError(t, err) + originalReceipt, err := os.ReadFile(originalReceiptPath) + require.NoError(t, err) + + badExpected := expected + badExpected.ArchiveHash = "sha256:" + strings.Repeat("0", 64) + f = openPackageArchive(t, validLegacyArchive(t, []byte("replacement library"))) + _, err = config.InstallPackage(cfg, "example", f, badExpected, config.InstallOptions{}) + _ = f.Close() + require.Error(t, err) + currentLibrary, err := os.ReadFile(originalLibraryPath) + require.NoError(t, err) + currentReceipt, err := os.ReadFile(originalReceiptPath) + require.NoError(t, err) + assert.Equal(t, originalLibrary, currentLibrary) + assert.Equal(t, originalReceipt, currentReceipt) +} + +func TestConcurrentInstallPackageSerializesSameTarget(t *testing.T) { + root := t.TempDir() + cfg := config.Config{Level: config.ConfigEnv, Location: root} + firstArchive := validLegacyArchive(t, []byte("first library")) + secondArchive := validLegacyArchive(t, []byte("second library")) + firstExpected := expectedPackage("example", "1.2.3", config.PlatformTuple(), "github.com/first/source", firstArchive) + secondExpected := expectedPackage("example", "1.2.3", config.PlatformTuple(), "github.com/second/source", secondArchive) + firstFile := openPackageArchive(t, firstArchive) + secondFile := openPackageArchive(t, secondArchive) + + results := make(chan error, 2) + go func() { + _, err := config.InstallPackage(cfg, "example", firstFile, firstExpected, config.InstallOptions{}) + _ = firstFile.Close() + results <- err + }() + go func() { + _, err := config.InstallPackage(cfg, "example", secondFile, secondExpected, config.InstallOptions{}) + _ = secondFile.Close() + results <- err + }() + for range 2 { + require.NoError(t, <-results) + } + + installed, err := config.GetDriver(cfg, "example") + require.NoError(t, err) + libraryPath := installed.Driver.Shared.Get(config.PlatformTuple()) + var receipt config.InstallReceipt + receiptBytes, err := os.ReadFile(filepath.Join(filepath.Dir(libraryPath), "dbc-install-receipt.json")) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(receiptBytes, &receipt)) + library, err := os.ReadFile(libraryPath) + require.NoError(t, err) + switch receipt.SourceIdentity { + case firstExpected.SourceIdentity: + assert.Equal(t, "first library", string(library)) + assert.Equal(t, firstExpected.ArchiveHash, receipt.ArchiveHash) + case secondExpected.SourceIdentity: + assert.Equal(t, "second library", string(library)) + assert.Equal(t, secondExpected.ArchiveHash, receipt.ArchiveHash) + default: + t.Fatalf("unexpected source identity in receipt: %q", receipt.SourceIdentity) + } +} diff --git a/config/package_archive_types.go b/config/package_archive_types.go new file mode 100644 index 00000000..250e64a3 --- /dev/null +++ b/config/package_archive_types.go @@ -0,0 +1,221 @@ +// 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" + "github.com/Masterminds/semver/v3" + "os" + "sync" +) + +const ( + installReceiptName = "dbc-install-receipt.json" + legacyPackageManifestName = "MANIFEST" + reservedPackageMetadataName = "dbc-package.toml" + maxPackageMetadataSize = 1 << 20 + registrationFingerprintAlgorithm = "sha256" + registrationFingerprintVersion = 1 +) + +// ExpectedPackageMetadata describes the resolution that selected an archive. +// Hashes use the canonical form "sha256:". ArchiveSize is the +// optional size of the compressed archive, not the extracted library. A +// nonzero size is treated as present for compatibility with existing callers; +// ArchiveSizePresent allows callers to explicitly require a zero-byte size. +type ExpectedPackageMetadata struct { + ID string + Version string + Platform string + SourceType string + SourceIdentity string + ArchiveHash string + ArchiveSize int64 + ArchiveSizePresent bool +} + +func (expected ExpectedPackageMetadata) hasExpectedArchiveSize() bool { + return expected.ArchiveSizePresent || expected.ArchiveSize != 0 +} + +// InstallReceipt records the resolved source and the two distinct artifact +// digests. ArchiveHash covers the compressed package; InstalledLibraryHash +// covers the extracted file referenced by [Files].driver. +type InstallReceipt struct { + SourceType string `json:"source_type"` + SourceIdentity string `json:"source_identity"` + DriverID string `json:"driver_id"` + DriverVersion string `json:"driver_version"` + Platform string `json:"platform"` + ArchiveHash string `json:"archive_hash"` + ArchiveSize int64 `json:"archive_size"` + InstalledLibrary string `json:"installed_library,omitempty"` + InstalledLibraryHash string `json:"installed_library_hash,omitempty"` + RegistrationFingerprintAlgorithm string `json:"registration_fingerprint_algorithm,omitempty"` + RegistrationFingerprintVersion int `json:"registration_fingerprint_version,omitempty"` + RegistrationFingerprint string `json:"registration_fingerprint,omitempty"` +} + +// InstallOptions supplies verification that must finish before an installation +// is made visible to the runtime driver manager. +type InstallOptions struct { + // Verify is an optional additional check selected by the caller's trust + // policy. A nil callback does not disable expected archive hash/size checks + // and does not establish publisher authenticity. + Verify func(stagingDir string, manifest Manifest) error +} + +// PackageValidation contains values measured while validating a package +// archive. VerifiedLibraryHash is empty when the package has no separate +// driver library file. +type PackageValidation struct { + VerifiedLibraryHash string + ArchiveHash string + ArchiveSize int64 + Registration DriverInfo + RegistrationFingerprintAlgorithm string + RegistrationFingerprintVersion int + RegistrationFingerprint string + registrationSharedIdentity registrationSharedIdentity + Prepared *PreparedPackage +} + +// PreparedPackage owns a validated package payload that has not yet been +// published to the runtime. Its fields are intentionally private: callers may +// only pass it back to EnsurePackage or close it. +type PreparedPackage struct { + mu sync.Mutex + root string + runtimeID string + requested ExpectedPackageMetadata + expected ExpectedPackageMetadata + workDir string + payloadDir string + finalDir string + manifest Manifest + used bool + closed bool + closeErr error +} + +// Close removes the private prepared-package workspace. It is safe to call +// more than once. +func (p *PreparedPackage) Close() error { + if p == nil { + return nil + } + p.mu.Lock() + defer p.mu.Unlock() + if p.closed { + return p.closeErr + } + p.closed = true + if err := os.RemoveAll(p.workDir); err != nil { + p.closeErr = fmt.Errorf("could not remove prepared package workspace: %w", err) + } + return p.closeErr +} + +// MatchesExpected reports whether this unconsumed prepared payload was +// validated for the supplied expected package metadata. It also accepts the +// finalized metadata whose archive hash and size were measured while preparing +// the package. +func (p *PreparedPackage) MatchesExpected(expected ExpectedPackageMetadata) bool { + if p == nil { + return false + } + p.mu.Lock() + defer p.mu.Unlock() + if p.closed || p.used { + return false + } + normalized, err := normalizePackageInstallMetadata(p.runtimeID, expected) + if err != nil { + return false + } + return p.requested == normalized || p.expected == normalized +} + +func (p *PreparedPackage) claim(root, runtimeID string, expected ExpectedPackageMetadata) error { + if p == nil { + return errors.New("prepared package is nil") + } + p.mu.Lock() + defer p.mu.Unlock() + if p.closed { + return errors.New("prepared package is closed") + } + if p.used { + return errors.New("prepared package has already been used") + } + if p.root != root || p.runtimeID != runtimeID || (p.requested != expected && p.expected != expected) { + return errors.New("prepared package does not match the requested installation") + } + p.used = true + return nil +} + +type registrationSharedIdentity struct { + Kind string `json:"kind"` + PackageFile string `json:"package_file,omitempty"` + ExternalReference string `json:"external_reference,omitempty"` +} + +type runtimeRegistrationFingerprintV1 struct { + SchemaVersion int `json:"schema_version"` + ID string `json:"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"` + Version *string `json:"version"` + ADBCVersion *string `json:"adbc_version"` + Supported []string `json:"supported_features"` + Unsupported []string `json:"unsupported_features"` + Shared registrationSharedIdentity `json:"shared"` +} + +// This is the legacy package wire format. It deliberately remains separate +// from runtimeManifestWire: Driver.shared in a runtime ADBC manifest is a load +// path, while Files.driver in a package manifest is an archive member name. +type legacyPackageManifestWire struct { + ManifestVersion int32 `toml:"manifest_version"` + Name string `toml:"name"` + Publisher string `toml:"publisher"` + License string `toml:"license"` + Version *semver.Version `toml:"version"` + Source string `toml:"source"` + AdbcInfo struct { + Version *semver.Version `toml:"version"` + Features struct { + Supported []string `toml:"supported,omitempty"` + Unsupported []string `toml:"unsupported,omitempty"` + } `toml:"features,omitempty"` + } `toml:"ADBC"` + Driver struct { + Entrypoint string `toml:"entrypoint,omitempty"` + Shared any `toml:"shared"` + } `toml:"Driver"` + Files struct { + Driver string `toml:"driver,omitempty"` + Signature string `toml:"signature,omitempty"` + } `toml:"Files,omitempty"` + PostInstall struct { + Messages []string `toml:"messages,inline,omitempty"` + } `toml:"PostInstall,omitempty"` +} diff --git a/config/package_install.go b/config/package_install.go new file mode 100644 index 00000000..70740949 --- /dev/null +++ b/config/package_install.go @@ -0,0 +1,842 @@ +// 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" + "github.com/Masterminds/semver/v3" + "io/fs" + "maps" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "sync" +) + +// InstallPackage prepares a package in a private generation directory, verifies +// it, registers its runtime manifest, and then removes a previous managed +// generation when its receipt proves ownership. The downloaded archive remains +// open for the caller. +func InstallPackage(cfg Config, runtimeID string, downloaded *os.File, expected ExpectedPackageMetadata, options InstallOptions) (Manifest, error) { + return installPackage(cfg, runtimeID, downloaded, expected, options, CreateManifest) +} + +// EnsurePackageCallbacks contains the narrow decisions and resources needed +// to atomically reuse or install a selected package. CurrentMatches receives +// the latest registration (nil when absent) once before and, if needed, once +// after preparing a package. Prepare runs at most once and outside the +// driver's install locks. ValidateResult runs before the final lock release; +// if it fails after installation, the installed generation remains committed +// and is described by the returned result. CurrentMatches and ValidateResult +// run synchronously while the driver's install locks are held. They must not +// call InstallPackage, EnsurePackage, UninstallDriver, or another API that +// reacquires a lock for the same driver, because that would deadlock. Prepare +// receives the operation context and should honor its cancellation. +// CurrentMatches may be called twice and must be pure and side-effect-free. +type EnsurePackageCallbacks struct { + CurrentMatches func(current *DriverInfo) (bool, error) + // Prepare is called outside install locks only if the initial registration + // does not match. The returned payload is owned and closed by EnsurePackage. + Prepare func(context.Context) (*PreparedPackage, error) + ValidateResult func(result EnsurePackageResult) error +} + +// EnsurePackageResult separates the latest registration observed by a locked +// check (Current), the registration present afterwards or reused on a skip +// (Installed), any registration physically replaced at the primary install +// root (Previous), and an install manifest (nil on a skip). +type EnsurePackageResult struct { + Skipped bool + Current *DriverInfo + Installed *DriverInfo + Previous *DriverInfo + Manifest *Manifest +} + +// EnsurePackage checks the effective runtime registration under all +// configured driver install locks. If it does not match, EnsurePackage +// releases those locks, prepares one package, reacquires all locks, and checks +// the latest registration again before deciding to skip or install. This +// prevents package preparation from blocking unrelated driver operations while +// ensuring no stale registration snapshot is used for the final decision. +// EnsurePackage owns and closes the prepared payload, including when phase +// two finds that it is no longer needed. CurrentMatches runs up to twice and +// ValidateResult runs under the driver install locks. Neither callback may +// call InstallPackage, EnsurePackage, UninstallDriver, or another API that +// reacquires a lock for the same driver, because that would deadlock. Prepare +// runs once outside the locks and receives ctx so it can cancel its work. +// CurrentMatches must be pure and side-effect-free because it may run once +// before preparation and again against the latest registration after. +// Result.Current is the most recently inspected registration: it remains the +// phase-one snapshot on preparation or phase-two lock acquisition failure. +// TODO: Review after the prototype whether this callback transaction should remain public or move behind a higher-level/internal API. +func EnsurePackage(ctx context.Context, cfg Config, runtimeID string, expected ExpectedPackageMetadata, callbacks EnsurePackageCallbacks) (EnsurePackageResult, error) { + return ensurePackageWithLockObserver(ctx, cfg, runtimeID, expected, callbacks, nil) +} + +// ensurePackageWithLockObserver is the internal lock-acquisition seam used by +// tests to synchronize with each phase's lock acquisition. The observer is +// called after each successful root acquisition, synchronously while that +// root lock remains held. +func ensurePackageWithLockObserver(ctx context.Context, cfg Config, runtimeID string, expected ExpectedPackageMetadata, callbacks EnsurePackageCallbacks, lockObserver func(root string)) (result EnsurePackageResult, returnErr error) { + var prepared *PreparedPackage + defer func() { + if prepared != nil { + returnErr = errors.Join(returnErr, prepared.Close()) + } + }() + if ctx == nil { + return result, errors.New("package ensure context is nil") + } + if err := ctx.Err(); err != nil { + return result, err + } + if callbacks.CurrentMatches == nil { + return result, errors.New("package current-registration predicate is nil") + } + expected, err := normalizePackageInstallMetadata(runtimeID, expected) + if err != nil { + return result, err + } + if err := ctx.Err(); err != nil { + return result, err + } + primary, precedenceRoots, err := resolvePackageInstallRoots(cfg) + if err != nil { + return result, err + } + if err := ctx.Err(); err != nil { + return result, err + } + releaseLocks, err := acquireDriverInstallLocksWithObserver(ctx, precedenceRoots, runtimeID, lockObserver) + if err != nil { + return result, fmt.Errorf("could not lock driver installation: %w", err) + } + defer releaseLocks() + current, matches, err := inspectEnsureCurrent(ctx, cfg, precedenceRoots, runtimeID, callbacks.CurrentMatches) + result.Current = cloneDriverInfo(current) + if err != nil { + return result, err + } + if matches { + return finishEnsureSkip(ctx, result, current, callbacks.ValidateResult) + } + releaseLocks() + if err := ctx.Err(); err != nil { + return result, err + } + if callbacks.Prepare == nil { + return result, errors.New("package preparer is nil") + } + prepared, err = callbacks.Prepare(ctx) + if err != nil { + return result, fmt.Errorf("could not prepare package: %w", err) + } + if prepared == nil { + return result, errors.New("package preparer returned nil") + } + if err := prepared.claim(primary, runtimeID, expected); err != nil { + return result, err + } + + phaseTwoRelease, err := acquireDriverInstallLocksWithObserver(ctx, precedenceRoots, runtimeID, lockObserver) + if err != nil { + return result, fmt.Errorf("could not relock driver installation: %w", err) + } + defer phaseTwoRelease() + if err := ctx.Err(); err != nil { + return result, err + } + current, matches, err = inspectEnsureCurrent(ctx, cfg, precedenceRoots, runtimeID, callbacks.CurrentMatches) + result.Current = cloneDriverInfo(current) + if err != nil { + return result, err + } + if matches { + return finishEnsureSkip(ctx, result, current, callbacks.ValidateResult) + } + if err := ctx.Err(); err != nil { + return result, err + } + manifest, previous, err := installPreparedPackageLocked(cfg, runtimeID, primary, expected, prepared, CreateManifest, cleanupOwnedPackageDirectories) + if err != nil { + return result, err + } + if previous != nil { + result.Previous = cloneDriverInfo(previous) + } + manifestCopy := manifest + result.Manifest = &manifestCopy + installed, err := loadEffectiveInstalledDriver(cfg, precedenceRoots, runtimeID) + if err != nil { + return result, fmt.Errorf("package was installed but its runtime registration could not be reloaded: %w", err) + } + if installed == nil { + return result, errors.New("package was installed but no runtime registration was found") + } + result.Installed = cloneDriverInfo(installed) + if callbacks.ValidateResult != nil { + if err := callbacks.ValidateResult(result); err != nil { + return result, fmt.Errorf("package was installed but result validation failed: %w", err) + } + } + if err := ctx.Err(); err != nil { + return result, err + } + return result, nil +} + +func inspectEnsureCurrent(ctx context.Context, cfg Config, roots []string, runtimeID string, currentMatches func(*DriverInfo) (bool, error)) (*DriverInfo, bool, error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + current, err := loadEffectiveInstalledDriver(cfg, roots, runtimeID) + if err != nil { + return nil, false, fmt.Errorf("could not inspect current driver registration: %w", err) + } + if err := ctx.Err(); err != nil { + return current, false, err + } + matches, err := currentMatches(cloneDriverInfo(current)) + if err != nil { + return current, false, fmt.Errorf("could not compare current driver registration: %w", err) + } + if err := ctx.Err(); err != nil { + return current, false, err + } + if matches && current == nil { + return current, false, errors.New("current-registration predicate accepted a missing registration") + } + return current, matches, nil +} + +func finishEnsureSkip(ctx context.Context, result EnsurePackageResult, current *DriverInfo, validateResult func(EnsurePackageResult) error) (EnsurePackageResult, error) { + result.Skipped = true + result.Installed = cloneDriverInfo(current) + if validateResult != nil { + if err := validateResult(result); err != nil { + return result, fmt.Errorf("could not validate ensured driver registration: %w", err) + } + } + if err := ctx.Err(); err != nil { + return result, err + } + return result, nil +} + +func resolvePackageInstallRoots(cfg Config) (string, []string, error) { + var configured []string + if cfg.Level == ConfigEnv { + configured = splitConfigList(cfg.Location) + if len(configured) == 0 { + return "", nil, errors.New("ADBC_DRIVER_PATH is empty, must be set to valid path to use") + } + for _, root := range configured { + if root == "" { + return "", nil, errors.New("ADBC_DRIVER_PATH contains an empty config root") + } + } + } else { + configured = []string{cfg.Location} + } + primary, err := EnsureLocation(cfg) + if err != nil { + return "", nil, err + } + primary, err = absoluteCleanLocation(primary) + if err != nil { + return "", nil, fmt.Errorf("could not resolve primary config root: %w", err) + } + + roots := make([]string, 0, len(configured)) + seen := make(map[string]struct{}, len(configured)) + for _, configuredRoot := range configured { + root, err := absoluteCleanLocation(configuredRoot) + if err != nil { + return "", nil, fmt.Errorf("could not resolve config root %q: %w", configuredRoot, err) + } + identity := packageInstallRootKey(root, runtime.GOOS == "windows") + if _, exists := seen[identity]; exists { + continue + } + seen[identity] = struct{}{} + info, err := os.Stat(root) + if err != nil { + return "", nil, fmt.Errorf("configured driver config root %s is unavailable: %w", root, err) + } + if !info.IsDir() { + return "", nil, fmt.Errorf("configured driver config root %s is not a directory", root) + } + roots = append(roots, root) + } + if len(roots) == 0 || roots[0] != primary { + return "", nil, errors.New("primary driver config root is not present in configured roots") + } + return primary, roots, nil +} + +func packageInstallRootKey(root string, caseInsensitive bool) string { + root = filepath.Clean(root) + if caseInsensitive { + return strings.ToLower(root) + } + return root +} + +func absoluteCleanLocation(location string) (string, error) { + if location == "" { + return "", errors.New("location is empty") + } + abs, err := filepath.Abs(location) + if err != nil { + return "", err + } + return filepath.Clean(abs), nil +} + +func acquireDriverInstallLocks(ctx context.Context, roots []string, runtimeID string) (func(), error) { + return acquireDriverInstallLocksWithObserver(ctx, roots, runtimeID, nil) +} + +func acquireDriverInstallLocksWithObserver(ctx context.Context, roots []string, runtimeID string, observer func(root string)) (func(), error) { + lockRoots := slices.Clone(roots) + // TODO: Use the case-insensitive root identity here to keep Windows lock ordering consistent across path spellings. + slices.Sort(lockRoots) + releases := make([]func(), 0, len(lockRoots)) + var releaseOnce sync.Once + releaseAll := func() { + releaseOnce.Do(func() { + for i := len(releases) - 1; i >= 0; i-- { + releases[i]() + } + }) + } + for _, root := range lockRoots { + if err := ctx.Err(); err != nil { + releaseAll() + return nil, err + } + release, err := acquireDriverInstallLockContext(ctx, root, runtimeID) + if err != nil { + releaseAll() + return nil, err + } + releases = append(releases, release) + if observer != nil { + observer(root) + } + } + return releaseAll, nil +} + +func loadEffectiveInstalledDriver(cfg Config, roots []string, runtimeID string) (*DriverInfo, error) { + if cfg.Level == ConfigEnv { + for _, root := range roots { + info, err := loadDriverFromManifest(root, runtimeID) + if err == nil { + return cloneDriverInfo(&info), nil + } + if errors.Is(err, fs.ErrNotExist) || errors.Is(err, os.ErrNotExist) { + continue + } + return nil, err + } + return nil, nil + } + info, err := loadInstalledDriver(cfg, roots[0], runtimeID) + if err != nil { + return nil, err + } + return cloneDriverInfo(info), nil +} + +func cloneDriverInfo(info *DriverInfo) *DriverInfo { + if info == nil { + return nil + } + clone := *info + if info.Version != nil { + version := *info.Version + clone.Version = &version + } + if info.AdbcInfo.Version != nil { + version := *info.AdbcInfo.Version + clone.AdbcInfo.Version = &version + } + clone.AdbcInfo.Features.Supported = slices.Clone(info.AdbcInfo.Features.Supported) + clone.AdbcInfo.Features.Unsupported = slices.Clone(info.AdbcInfo.Features.Unsupported) + if info.Driver.Shared.platformMap != nil { + clone.Driver.Shared.platformMap = make(map[string]string, len(info.Driver.Shared.platformMap)) + for platform, path := range info.Driver.Shared.platformMap { + clone.Driver.Shared.platformMap[platform] = path + } + } + return &clone +} + +// PreparePackage verifies and stages an already-downloaded package under its +// selected install root without registering it or changing runtime +// configuration. The returned prepared payload can later be atomically +// published by EnsurePackage; callers must close it if they do not pass it to +// EnsurePackage. The downloaded archive remains open for the caller. +func PreparePackage(cfg Config, runtimeID string, downloaded *os.File, expected ExpectedPackageMetadata, options InstallOptions) (validation PackageValidation, err error) { + if downloaded == nil { + return PackageValidation{}, errors.New("package archive is nil") + } + requested, err := normalizePackageInstallMetadata(runtimeID, expected) + if err != nil { + return PackageValidation{}, err + } + root, _, err := resolvePackageInstallRoots(cfg) + if err != nil { + return PackageValidation{}, fmt.Errorf("could not resolve package installation root: %w", err) + } + root, err = filepath.Abs(root) + if err != nil { + return PackageValidation{}, fmt.Errorf("could not resolve package installation root: %w", err) + } + workDir, err := os.MkdirTemp(root, ".dbc-install-") + if err != nil { + return PackageValidation{}, fmt.Errorf("could not create private package preparation directory: %w", err) + } + keepWorkspace := false + defer func() { + if !keepWorkspace { + if cleanupErr := os.RemoveAll(workDir); cleanupErr != nil { + validation = PackageValidation{} + err = errors.Join(err, fmt.Errorf("could not remove package preparation directory: %w", cleanupErr)) + } + } + }() + reservedDir, err := os.MkdirTemp(root, ".dbc-package-"+runtimeID+"-") + if err != nil { + return PackageValidation{}, fmt.Errorf("could not reserve package generation directory: %w", err) + } + if err := os.Remove(reservedDir); err != nil { + return PackageValidation{}, fmt.Errorf("could not prepare package generation directory: %w", err) + } + finalDir := reservedDir + manifest, payloadDir, sharedIdentity, err := stagePackageArchive(root, runtimeID, finalDir, downloaded, requested, options.Verify, workDir) + if err != nil { + return PackageValidation{}, err + } + receipt, ok := readPackageReceiptEvidence(workDir, runtimeID, payloadDir) + if !ok { + return PackageValidation{}, errors.New("could not read prepared package receipt") + } + finalExpected := requested + finalExpected.ArchiveHash = receipt.ArchiveHash + finalExpected.ArchiveSize = receipt.ArchiveSize + finalExpected.ArchiveSizePresent = true + prepared := &PreparedPackage{ + root: root, runtimeID: runtimeID, requested: requested, expected: finalExpected, + workDir: workDir, payloadDir: payloadDir, finalDir: finalDir, manifest: manifest, + } + keepWorkspace = true + return PackageValidation{ + VerifiedLibraryHash: receipt.InstalledLibraryHash, + ArchiveHash: receipt.ArchiveHash, ArchiveSize: receipt.ArchiveSize, + Registration: manifest.DriverInfo, + RegistrationFingerprintAlgorithm: receipt.RegistrationFingerprintAlgorithm, + RegistrationFingerprintVersion: receipt.RegistrationFingerprintVersion, + RegistrationFingerprint: receipt.RegistrationFingerprint, + registrationSharedIdentity: sharedIdentity, + Prepared: prepared, + }, nil +} + +// SameRuntimeDriverRegistration reports whether two driver registrations have +// the same effective runtime metadata for platform. FilePath is intentionally +// ignored because it identifies where a registration is stored rather than +// what it registers. Versions are compared as canonical semantic strings, so +// original spelling differences normalize while build metadata remains +// significant. Feature lists are compared as sets; the effective shared +// reference remains exact. +func SameRuntimeDriverRegistration(current, candidate DriverInfo, platform string) bool { + if current.ID != candidate.ID || current.Name != candidate.Name || current.Publisher != candidate.Publisher || + current.License != candidate.License || current.Source != candidate.Source || + current.Driver.Entrypoint != candidate.Driver.Entrypoint || + current.Driver.Shared.Get(platform) != candidate.Driver.Shared.Get(platform) || + !semverEquivalent(current.Version, candidate.Version) || + !semverEquivalent(current.AdbcInfo.Version, candidate.AdbcInfo.Version) || + !slices.Equal(normalizedFeatureSet(current.AdbcInfo.Features.Supported), normalizedFeatureSet(candidate.AdbcInfo.Features.Supported)) || + !slices.Equal(normalizedFeatureSet(current.AdbcInfo.Features.Unsupported), normalizedFeatureSet(candidate.AdbcInfo.Features.Unsupported)) { + return false + } + return true +} + +// InstallReceiptMatchesRuntimeRegistration verifies that a receipt previously +// obtained through InspectInstallReceipt or InspectDriverInstallReceipt proves +// the current registration's canonical runtime metadata. It does not verify +// library bytes; callers must use VerifyInstallReceiptLibraryIntegrity for +// that separate proof. For package-owned libraries it also requires the +// registered shared basename to match the receipt's installed filename. +func InstallReceiptMatchesRuntimeRegistration(receipt InstallReceipt, current DriverInfo, platform string) bool { + if !validRegistrationFingerprint(receipt.RegistrationFingerprintAlgorithm, receipt.RegistrationFingerprintVersion, receipt.RegistrationFingerprint) || + receipt.DriverID != current.ID || receipt.Platform != platform || current.Version == nil || receipt.DriverVersion != current.Version.String() { + return false + } + sharedPath := current.Driver.Shared.Get(platform) + var identity registrationSharedIdentity + if receipt.InstalledLibrary != "" { + if validateFlatName(receipt.InstalledLibrary) != nil || sharedPath == "" || filepath.Base(filepath.Clean(sharedPath)) != receipt.InstalledLibrary { + return false + } + identity = registrationSharedIdentity{Kind: "package_file", PackageFile: receipt.InstalledLibrary} + } else { + identity = registrationSharedIdentity{Kind: "external", ExternalReference: sharedPath} + } + fingerprint, err := runtimeRegistrationFingerprint(current, platform, identity) + if err == nil && fingerprint == receipt.RegistrationFingerprint { + return true + } + // Packages without Files.driver and without an external shared reference + // retain the historical package-directory runtime fallback. Its generated + // generation directory is not registration identity. + if receipt.InstalledLibrary == "" && sharedPath != "" && isGeneratedPackageDirectoryRegistration(current, sharedPath) { + fingerprint, err = runtimeRegistrationFingerprint(current, platform, registrationSharedIdentity{Kind: "external"}) + return err == nil && fingerprint == receipt.RegistrationFingerprint + } + return false +} + +// InstallReceiptMatchesExpectedPackage checks the resolved package identity +// and source evidence recorded by a receipt. +func InstallReceiptMatchesExpectedPackage(receipt InstallReceipt, expected ExpectedPackageMetadata) bool { + return expected.ID != "" && expected.Version != "" && expected.Platform != "" && + expected.SourceType != "" && expected.SourceIdentity != "" && expected.ArchiveHash != "" && + receipt.DriverID == expected.ID && receipt.DriverVersion == expected.Version && + receipt.Platform == expected.Platform && receipt.SourceType == expected.SourceType && + receipt.SourceIdentity == expected.SourceIdentity && receipt.ArchiveHash == expected.ArchiveHash && + (!expected.hasExpectedArchiveSize() || receipt.ArchiveSize == expected.ArchiveSize) +} + +// PackageValidationMatchesRuntimeRegistration compares a current registration +// with evidence returned by PreparePackage. The candidate's shared identity +// is private validation output, so callers cannot label an arbitrary path as +// a package-owned file. Library bytes and receipt identity remain separate +// checks for callers that require them. +func PackageValidationMatchesRuntimeRegistration(current DriverInfo, candidate PackageValidation, platform string) bool { + if candidate.RegistrationFingerprintAlgorithm != registrationFingerprintAlgorithm || + candidate.RegistrationFingerprintVersion != registrationFingerprintVersion || + !validRegistrationFingerprint(candidate.RegistrationFingerprintAlgorithm, candidate.RegistrationFingerprintVersion, candidate.RegistrationFingerprint) { + return false + } + if !registrationSharedIdentityValid(candidate.registrationSharedIdentity) || + !sharedIdentityMatchesRegistration(candidate.registrationSharedIdentity, current, platform) { + return false + } + candidateFingerprint, err := runtimeRegistrationFingerprint(candidate.Registration, platform, candidate.registrationSharedIdentity) + if err != nil || candidateFingerprint != candidate.RegistrationFingerprint { + return false + } + currentFingerprint, err := runtimeRegistrationFingerprint(current, platform, candidate.registrationSharedIdentity) + return err == nil && currentFingerprint == candidate.RegistrationFingerprint +} + +func installPackage(cfg Config, runtimeID string, downloaded *os.File, expected ExpectedPackageMetadata, options InstallOptions, registerManifest func(Config, DriverInfo) error) (Manifest, error) { + return installPackageWithCleanup(cfg, runtimeID, downloaded, expected, options, registerManifest, cleanupOwnedPackageDirectories) +} + +func installPackageWithCleanup(cfg Config, runtimeID string, downloaded *os.File, expected ExpectedPackageMetadata, options InstallOptions, registerManifest func(Config, DriverInfo) error, cleanup func(string, string, *DriverInfo, string, DriverInfo) error) (Manifest, error) { + if downloaded == nil { + return Manifest{}, errors.New("package archive is nil") + } + expected, err := normalizePackageInstallMetadata(runtimeID, expected) + if err != nil { + return Manifest{}, err + } + + loc, err := EnsureLocation(cfg) + if err != nil { + return Manifest{}, fmt.Errorf("could not ensure config location: %w", err) + } + loc, err = filepath.Abs(loc) + if err != nil { + return Manifest{}, fmt.Errorf("could not resolve config location: %w", err) + } + releaseLock, err := acquireDriverInstallLock(loc, runtimeID) + if err != nil { + return Manifest{}, fmt.Errorf("could not lock driver installation: %w", err) + } + defer releaseLock() + manifest, _, err := installPackageLocked(cfg, runtimeID, loc, downloaded, expected, options, registerManifest, cleanup) + return manifest, err +} + +// installPackageLocked performs the package transaction while the caller owns +// the driver install lock for location/runtimeID. It returns the registration +// that was stored at the target location before this transaction. +func installPackageLocked(cfg Config, runtimeID, loc string, downloaded *os.File, expected ExpectedPackageMetadata, options InstallOptions, registerManifest func(Config, DriverInfo) error, cleanup func(string, string, *DriverInfo, string, DriverInfo) error) (Manifest, *DriverInfo, error) { + if downloaded == nil { + return Manifest{}, nil, errors.New("package archive is nil") + } + if registerManifest == nil { + return Manifest{}, nil, errors.New("package manifest registrar is nil") + } + if cleanup == nil { + return Manifest{}, nil, errors.New("package cleanup function is nil") + } + var err error + expected, err = normalizePackageInstallMetadata(runtimeID, expected) + if err != nil { + return Manifest{}, nil, err + } + loc, err = filepath.Abs(loc) + if err != nil { + return Manifest{}, nil, fmt.Errorf("could not resolve config location: %w", err) + } + previous, err := loadInstalledDriver(cfg, loc, runtimeID) + if err != nil { + return Manifest{}, nil, fmt.Errorf("could not inspect existing driver registration: %w", err) + } + workDir, err := os.MkdirTemp(loc, ".dbc-install-") + if err != nil { + return Manifest{}, previous, fmt.Errorf("could not create private installation staging directory: %w", err) + } + defer os.RemoveAll(workDir) + + reservedDir, err := os.MkdirTemp(loc, ".dbc-package-"+runtimeID+"-") + if err != nil { + return Manifest{}, previous, fmt.Errorf("could not reserve package generation directory: %w", err) + } + if err := os.Remove(reservedDir); err != nil { + return Manifest{}, previous, fmt.Errorf("could not prepare package generation directory: %w", err) + } + finalDir := reservedDir + + manifest, payloadDir, _, err := stagePackageArchive(loc, runtimeID, finalDir, downloaded, expected, options.Verify, workDir) + if err != nil { + return Manifest{}, previous, err + } + return publishStagedPackageLocked(cfg, runtimeID, loc, manifest, payloadDir, finalDir, previous, registerManifest, cleanup) +} + +func installPreparedPackageLocked(cfg Config, runtimeID, loc string, expected ExpectedPackageMetadata, prepared *PreparedPackage, registerManifest func(Config, DriverInfo) error, cleanup func(string, string, *DriverInfo, string, DriverInfo) error) (Manifest, *DriverInfo, error) { + if prepared == nil { + return Manifest{}, nil, errors.New("prepared package is nil") + } + if registerManifest == nil { + return Manifest{}, nil, errors.New("package manifest registrar is nil") + } + if cleanup == nil { + return Manifest{}, nil, errors.New("package cleanup function is nil") + } + var err error + expected, err = normalizePackageInstallMetadata(runtimeID, expected) + if err != nil { + return Manifest{}, nil, err + } + loc, err = filepath.Abs(loc) + if err != nil { + return Manifest{}, nil, fmt.Errorf("could not resolve config location: %w", err) + } + prepared.mu.Lock() + preparedValid := prepared.used && !prepared.closed && prepared.root == loc && prepared.runtimeID == runtimeID && + (prepared.requested == expected || prepared.expected == expected) + manifest := prepared.manifest + payloadDir := prepared.payloadDir + finalDir := prepared.finalDir + prepared.mu.Unlock() + if !preparedValid { + return Manifest{}, nil, errors.New("prepared package does not match the requested installation") + } + if _, err := os.Lstat(finalDir); err == nil { + return Manifest{}, nil, fmt.Errorf("package generation path already exists: %s", finalDir) + } else if !errors.Is(err, fs.ErrNotExist) { + return Manifest{}, nil, fmt.Errorf("could not inspect package generation path: %w", err) + } + previous, err := loadInstalledDriver(cfg, loc, runtimeID) + if err != nil { + return Manifest{}, nil, fmt.Errorf("could not inspect existing driver registration: %w", err) + } + return publishStagedPackageLocked(cfg, runtimeID, loc, manifest, payloadDir, finalDir, previous, registerManifest, cleanup) +} + +func publishStagedPackageLocked(cfg Config, runtimeID, loc string, manifest Manifest, payloadDir, finalDir string, previous *DriverInfo, registerManifest func(Config, DriverInfo) error, cleanup func(string, string, *DriverInfo, string, DriverInfo) error) (Manifest, *DriverInfo, error) { + if err := os.Rename(payloadDir, finalDir); err != nil { + return Manifest{}, previous, fmt.Errorf("could not publish verified package generation: %w", err) + } + if err := registerManifest(cfg, manifest.DriverInfo); err != nil { + var rollbackErr *manifestRollbackError + if errors.As(err, &rollbackErr) { + return Manifest{}, previous, fmt.Errorf("could not register driver manifest; preserving verified package at %s: %w", finalDir, err) + } + if removeErr := os.RemoveAll(finalDir); removeErr != nil { + return Manifest{}, previous, fmt.Errorf("could not register driver manifest: %w; could not remove unregistered package at %s: %v", err, finalDir, removeErr) + } + return Manifest{}, previous, fmt.Errorf("could not register driver manifest: %w", err) + } + + // Replacing a registration is already complete. Removing an old generation + // is garbage collection, so its failure must not turn a successful install + // or update into an error. + _ = cleanup(loc, runtimeID, previous, finalDir, manifest.DriverInfo) + return manifest, previous, nil +} + +func normalizePackageInstallMetadata(runtimeID string, expected ExpectedPackageMetadata) (ExpectedPackageMetadata, error) { + if err := validateFlatName(runtimeID); err != nil { + return ExpectedPackageMetadata{}, fmt.Errorf("invalid runtime driver id: %w", err) + } + if expected.ID == "" { + expected.ID = runtimeID + } + if expected.ID != runtimeID { + return ExpectedPackageMetadata{}, fmt.Errorf("expected package id %q does not match runtime driver id %q", expected.ID, runtimeID) + } + if expected.ArchiveHash != "" || expected.hasExpectedArchiveSize() { + if err := validateExpectedPackage(expected); err != nil { + return ExpectedPackageMetadata{}, err + } + } else { + if expected.Version != "" { + if _, err := semver.NewVersion(expected.Version); err != nil { + return ExpectedPackageMetadata{}, fmt.Errorf("invalid expected package version %q: %w", expected.Version, err) + } + } + if expected.Platform != "" { + if err := validatePlatformIdentifier(expected.Platform); err != nil { + return ExpectedPackageMetadata{}, fmt.Errorf("invalid expected package platform: %w", err) + } + } + } + if expected.SourceType == "" { + expected.SourceType = "local" + } + if expected.SourceIdentity == "" { + expected.SourceIdentity = "local" + } + return expected, nil +} + +func loadInstalledDriver(cfg Config, loc, runtimeID string) (*DriverInfo, error) { + var info DriverInfo + var err error + if cfg.Level == ConfigEnv { + info, err = loadDriverFromManifest(loc, runtimeID) + } else { + info, err = GetDriver(cfg, runtimeID) + } + if errors.Is(err, fs.ErrNotExist) || errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + return &info, nil +} + +func acquireDriverInstallLock(location, runtimeID string) (func(), error) { + return acquireDriverInstallLockWith(location, runtimeID, acquirePackageInstallLock) +} + +func acquireDriverInstallLockContext(ctx context.Context, location, runtimeID string) (func(), error) { + return acquireDriverInstallLockWith(location, runtimeID, func(path string) (func(), error) { + return acquirePackageInstallLockContext(ctx, path) + }) +} + +func acquireDriverInstallLockWith(location, runtimeID string, acquire func(string) (func(), error)) (func(), error) { + lockTarget := filepath.Join(location, runtimeID) + lockHash := sha256.Sum256([]byte(strings.ToLower(filepath.Clean(lockTarget)))) + return acquire(filepath.Join(location, ".dbc-package-install-"+hex.EncodeToString(lockHash[:])+".lock")) +} + +func driverInstallLockLocation(cfg Config, info DriverInfo) (string, error) { + location := info.FilePath + registryPath := strings.HasPrefix(strings.ToUpper(location), "HKCU\\") || strings.HasPrefix(strings.ToUpper(location), "HKLM\\") + if cfg.Level == ConfigEnv && location == "" { + return "", errors.New("driver registration location is empty") + } + if cfg.Level != ConfigEnv && (registryPath || location == "") { + location = cfg.Location + if location == "" { + location = cfg.Level.ConfigLocation() + } + } + if location == "" { + return "", errors.New("driver registration location is empty") + } + return filepath.Abs(location) +} + +func uninstallPackageCleanupLocation(cfg Config, info DriverInfo) (string, error) { + location := info.FilePath + registryPath := strings.HasPrefix(strings.ToUpper(location), "HKCU\\") || strings.HasPrefix(strings.ToUpper(location), "HKLM\\") + if registryPath || location == "" { + location = cfg.Location + if location == "" { + location = cfg.Level.ConfigLocation() + } + if cfg.Level == ConfigEnv { + paths := splitConfigList(location) + if len(paths) == 0 { + return "", errors.New("ADBC_DRIVER_PATH is empty, must be set to valid path to use") + } + location = paths[0] + } + } + if location == "" { + return "", errors.New("driver package cleanup location is empty") + } + return filepath.Abs(location) +} + +func uninstallDriverWithInstallLock(cfg Config, info DriverInfo, uninstall func() error) error { + location, err := driverInstallLockLocation(cfg, info) + if err != nil { + return fmt.Errorf("could not resolve driver registration location: %w", err) + } + releaseLock, err := acquireDriverInstallLock(location, info.ID) + if err != nil { + return fmt.Errorf("could not lock driver installation: %w", err) + } + defer releaseLock() + + var current DriverInfo + if cfg.Level == ConfigEnv { + current, err = loadDriverFromManifest(info.FilePath, info.ID) + } else { + current, err = GetDriver(cfg, info.ID) + } + if err != nil { + return fmt.Errorf("driver registration changed before uninstall: could not reload current registration: %w", err) + } + if !sameDriverRegistration(info, current) { + return errors.New("driver registration changed before uninstall; refusing to remove files from a stale registration") + } + return uninstall() +} + +func sameDriverRegistration(first, second DriverInfo) bool { + return normalizedRegistrationPath(first.FilePath) == normalizedRegistrationPath(second.FilePath) && + first.ID == second.ID && first.Name == second.Name && first.Publisher == second.Publisher && + first.License == second.License && first.Source == second.Source && + semverEquivalent(first.Version, second.Version) && + semverEquivalent(first.AdbcInfo.Version, second.AdbcInfo.Version) && + slices.Equal(normalizedFeatureSet(first.AdbcInfo.Features.Supported), normalizedFeatureSet(second.AdbcInfo.Features.Supported)) && + slices.Equal(normalizedFeatureSet(first.AdbcInfo.Features.Unsupported), normalizedFeatureSet(second.AdbcInfo.Features.Unsupported)) && + first.Driver.Entrypoint == second.Driver.Entrypoint && + first.Driver.Shared.defaultPath == second.Driver.Shared.defaultPath && + maps.Equal(first.Driver.Shared.platformMap, second.Driver.Shared.platformMap) +} diff --git a/config/package_manifest.go b/config/package_manifest.go new file mode 100644 index 00000000..0a29fc23 --- /dev/null +++ b/config/package_manifest.go @@ -0,0 +1,123 @@ +// 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 ( + "fmt" + "github.com/pelletier/go-toml/v2" + "strings" +) + +func decodePackageManifest(name string, data []byte) (Manifest, error) { + switch name { + case legacyPackageManifestName: + return decodeLegacyPackageManifest(data) + case reservedPackageMetadataName: + return Manifest{}, fmt.Errorf("package metadata format %q is reserved for future use and is not supported yet", name) + default: + return Manifest{}, fmt.Errorf("unsupported package metadata filename %q", name) + } +} + +func decodeLegacyPackageManifest(data []byte) (Manifest, error) { + var wire legacyPackageManifestWire + if err := toml.Unmarshal(data, &wire); err != nil { + return Manifest{}, fmt.Errorf("%w: error decoding legacy package manifest: %v", ErrInvalidManifest, err) + } + if wire.ManifestVersion > currentManifestVersion { + return Manifest{}, fmt.Errorf("manifest version %d is unsupported, only %d and lower are supported by this version of dbc", wire.ManifestVersion, currentManifestVersion) + } + if strings.TrimSpace(wire.Name) == "" { + return Manifest{}, fmt.Errorf("%w: name is required", ErrInvalidManifest) + } + if wire.Version == nil { + return Manifest{}, fmt.Errorf("%w: version is required", ErrInvalidManifest) + } + if wire.Files.Driver != "" { + if err := validateFlatName(wire.Files.Driver); err != nil { + return Manifest{}, fmt.Errorf("%w: invalid Files.driver: %v", ErrInvalidManifest, err) + } + } + shared, err := decodeDriverShared(wire.Driver.Shared, false) + if err != nil { + return Manifest{}, err + } + return Manifest{ + DriverInfo: DriverInfo{ + Name: wire.Name, + Publisher: wire.Publisher, + License: wire.License, + Version: wire.Version, + Source: wire.Source, + AdbcInfo: wire.AdbcInfo, + Driver: struct { + Entrypoint string + Shared driverMap + }{Entrypoint: wire.Driver.Entrypoint, Shared: shared}, + }, + Files: wire.Files, + PostInstall: wire.PostInstall, + }, nil +} + +func classifyPackageMetadataName(name string) (string, bool, error) { + for _, expected := range []string{legacyPackageManifestName, reservedPackageMetadataName} { + if !strings.EqualFold(name, expected) { + continue + } + if name != expected { + return "", false, fmt.Errorf("package metadata file must be named exactly %q", expected) + } + return expected, true, nil + } + return "", false, nil +} + +func selectPackageMetadata(metadata map[string][]byte) (string, []byte, error) { + legacy, hasLegacy := metadata[legacyPackageManifestName] + reserved, hasReserved := metadata[reservedPackageMetadataName] + if hasLegacy && hasReserved { + return "", nil, fmt.Errorf("package archive must not contain both %s and %s", legacyPackageManifestName, reservedPackageMetadataName) + } + if hasLegacy { + return legacyPackageManifestName, legacy, nil + } + if hasReserved { + return reservedPackageMetadataName, reserved, nil + } + return "", nil, fmt.Errorf("package archive must contain %s metadata", legacyPackageManifestName) +} + +func decodeDriverShared(value any, required bool) (driverMap, error) { + var result driverMap + switch shared := value.(type) { + case string: + result.defaultPath = shared + case map[string]any: + result.platformMap = make(map[string]string, len(shared)) + for platform, rawPath := range shared { + path, ok := rawPath.(string) + if !ok { + return driverMap{}, fmt.Errorf("%w: invalid type for platform %s, expected string", ErrInvalidManifest, platform) + } + result.platformMap[platform] = path + } + default: + if required { + return driverMap{}, fmt.Errorf("%w: invalid type for 'Driver.shared' in manifest, expected string or table", ErrInvalidManifest) + } + } + return result, nil +} diff --git a/config/package_receipts.go b/config/package_receipts.go new file mode 100644 index 00000000..f22194d3 --- /dev/null +++ b/config/package_receipts.go @@ -0,0 +1,577 @@ +// 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" + "encoding/json" + "errors" + "fmt" + "github.com/Masterminds/semver/v3" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" +) + +func runtimeRegistrationFingerprint(info DriverInfo, platform string, shared registrationSharedIdentity) (string, error) { + if err := validatePlatformIdentifier(platform); err != nil { + return "", fmt.Errorf("invalid registration fingerprint platform: %w", err) + } + if !registrationSharedIdentityValid(shared) { + return "", errors.New("invalid registration fingerprint shared identity") + } + wire := runtimeRegistrationFingerprintV1{ + SchemaVersion: registrationFingerprintVersion, + ID: info.ID, + Platform: platform, + Source: info.Source, + Name: info.Name, + Publisher: info.Publisher, + License: info.License, + Entrypoint: info.Driver.Entrypoint, + Version: semverString(info.Version), + ADBCVersion: semverString(info.AdbcInfo.Version), + Supported: normalizedFeatureSet(info.AdbcInfo.Features.Supported), + Unsupported: normalizedFeatureSet(info.AdbcInfo.Features.Unsupported), + Shared: shared, + } + data, err := json.Marshal(wire) + if err != nil { + return "", fmt.Errorf("could not encode canonical runtime registration: %w", err) + } + digest := sha256.Sum256(data) + return "sha256:" + hex.EncodeToString(digest[:]), nil +} + +func registrationSharedIdentityValid(identity registrationSharedIdentity) bool { + switch identity.Kind { + case "package_file": + return validateFlatName(identity.PackageFile) == nil && identity.ExternalReference == "" + case "external": + return identity.PackageFile == "" + default: + return false + } +} + +func semverString(version *semver.Version) *string { + if version == nil { + return nil + } + value := version.String() + return &value +} + +func semverEquivalent(first, second *semver.Version) bool { + if first == nil || second == nil { + return first == nil && second == nil + } + return first.String() == second.String() +} + +func normalizedFeatureSet(features []string) []string { + normalized := slices.Clone(features) + slices.Sort(normalized) + normalized = slices.Compact(normalized) + if normalized == nil { + return []string{} + } + return normalized +} + +func validRegistrationFingerprint(algorithm string, version int, fingerprint string) bool { + if algorithm != registrationFingerprintAlgorithm || version != registrationFingerprintVersion { + return false + } + _, err := parseSHA256(fingerprint) + return err == nil +} + +func sharedIdentityMatchesRegistration(identity registrationSharedIdentity, current DriverInfo, platform string) bool { + sharedPath := current.Driver.Shared.Get(platform) + switch identity.Kind { + case "package_file": + return validateFlatName(identity.PackageFile) == nil && sharedPath != "" && filepath.Base(filepath.Clean(sharedPath)) == identity.PackageFile + case "external": + if sharedPath == identity.ExternalReference { + return true + } + return identity.ExternalReference == "" && sharedPath != "" && isGeneratedPackageDirectoryRegistration(current, sharedPath) + default: + return false + } +} + +func isGeneratedPackageDirectoryRegistration(current DriverInfo, sharedPath string) bool { + if current.ID == "" || current.FilePath == "" { + return false + } + sharedDirectory := filepath.Clean(sharedPath) + registrationDirectory := filepath.Clean(current.FilePath) + if filepath.Dir(sharedDirectory) != registrationDirectory { + return false + } + return isManagedPackageGenerationDirectory(filepath.Base(sharedDirectory), current.ID) +} + +func normalizedRegistrationPath(path string) string { + if strings.HasPrefix(strings.ToUpper(path), "HKCU\\") || strings.HasPrefix(strings.ToUpper(path), "HKLM\\") { + return strings.ToUpper(filepath.Clean(path)) + } + if path == "" { + return "" + } + abs, err := filepath.Abs(path) + if err != nil { + return filepath.Clean(path) + } + return filepath.Clean(abs) +} + +func managedPackageDirectory(location, runtimeID string, info DriverInfo) (string, bool) { + directories := managedPackageDirectories(location, runtimeID, info) + if len(directories) == 0 { + return "", false + } + return directories[0], true +} + +func managedPackageDirectories(location, runtimeID string, info DriverInfo) []string { + if info.Source != "dbc" || info.ID != runtimeID || info.Version == nil { + return nil + } + directories := make([]string, 0, 1) + seen := make(map[string]struct{}) + for sharedPath := range info.Driver.Shared.Paths() { + if sharedPath == "" { + continue + } + if !filepath.IsAbs(sharedPath) { + sharedPath = filepath.Join(location, sharedPath) + } + dir, err := filepath.Abs(filepath.Dir(sharedPath)) + if err != nil { + continue + } + receipt, ok := readPackageReceipt(location, runtimeID, dir) + if !ok || receipt.InstalledLibrary == "" || receipt.DriverVersion != info.Version.String() { + continue + } + registeredPath, found := info.Driver.Shared.platformMap[receipt.Platform] + if info.Driver.Shared.defaultPath != "" { + registeredPath, found = info.Driver.Shared.defaultPath, true + } + if !found { + continue + } + if !filepath.IsAbs(registeredPath) { + registeredPath = filepath.Join(location, registeredPath) + } + if filepath.Clean(registeredPath) != filepath.Clean(sharedPath) || filepath.Base(sharedPath) != receipt.InstalledLibrary { + continue + } + dir = filepath.Clean(dir) + if _, ok := seen[dir]; ok { + continue + } + seen[dir] = struct{}{} + directories = append(directories, dir) + } + return directories +} + +func cleanupManagedPackageDirectories(location, runtimeID, currentDir string, current DriverInfo) { + _ = cleanupOwnedPackageDirectories(location, runtimeID, nil, currentDir, current) +} + +func cleanupOwnedPackageDirectories(location, runtimeID string, previous *DriverInfo, currentDir string, current DriverInfo) error { + return cleanupOwnedPackageDirectoriesWithRemoveAll(location, runtimeID, previous, currentDir, current, os.RemoveAll) +} + +func cleanupOwnedPackageDirectoriesWithRemoveAll(location, runtimeID string, previous *DriverInfo, currentDir string, current DriverInfo, removeAll func(string) error) error { + if validateFlatName(runtimeID) != nil { + return nil + } + var cleanupErr error + remove := func(directory string) { + if err := removeAll(directory); err != nil { + cleanupErr = errors.Join(cleanupErr, fmt.Errorf("could not remove owned package directory %s: %w", directory, err)) + } + } + candidates := make(map[string]struct{}) + if previous != nil { + for _, directory := range managedPackageDirectories(location, runtimeID, *previous) { + if !packageDirectoryIsCurrent(location, currentDir, current, directory) { + directory = filepath.Clean(directory) + candidates[directory] = struct{}{} + } + } + } + entries, err := readDirEntries(location) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + cleanupErr = errors.Join(cleanupErr, fmt.Errorf("could not inspect package directory %s: %w", location, err)) + } + } else { + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), ".dbc-package-"+runtimeID+"-") { + continue + } + dir := filepath.Join(location, entry.Name()) + absDir, err := filepath.Abs(dir) + if err != nil { + continue + } + absDir = filepath.Clean(absDir) + if packageDirectoryIsCurrent(location, currentDir, current, absDir) { + continue + } + if _, ok := readManagedPackageReceipt(location, runtimeID, dir); !ok { + continue + } + candidates[absDir] = struct{}{} + } + } + for directory := range candidates { + remove(directory) + } + cleanupErr = errors.Join(cleanupErr, cleanupLegacyPackageDirectoryWithRemoveAll(location, runtimeID, previous, current, removeAll)) + return cleanupErr +} + +func packageDirectoryIsCurrent(location, currentDir string, current DriverInfo, directory string) bool { + if currentDir != "" { + absCurrent, currentErr := filepath.Abs(currentDir) + absDirectory, directoryErr := filepath.Abs(directory) + if currentErr == nil && directoryErr == nil && filepath.Clean(absCurrent) == filepath.Clean(absDirectory) { + return true + } + } + return runtimeReferencesPackageDirectory(current, location, directory) +} + +func cleanupLegacyPackageDirectoryWithRemoveAll(location, runtimeID string, previous *DriverInfo, current DriverInfo, removeAll func(string) error) error { + if previous == nil || previous.Source != "dbc" || previous.ID != runtimeID || previous.Version == nil { + return nil + } + if validateFlatName(runtimeID) != nil { + return nil + } + root, err := filepath.Abs(location) + if err != nil { + return nil + } + rootInfo, err := os.Stat(root) + if err != nil || !rootInfo.IsDir() { + return nil + } + + platformPaths := make(map[string]string) + if previous.Driver.Shared.defaultPath != "" { + platformPaths[PlatformTuple()] = previous.Driver.Shared.defaultPath + } else { + for platform, path := range previous.Driver.Shared.platformMap { + platformPaths[platform] = path + } + } + + removed := make(map[string]struct{}) + var cleanupErr error + for platform, sharedPath := range platformPaths { + if sharedPath == "" || validatePlatformIdentifier(platform) != nil { + continue + } + name := runtimeID + "_" + platform + "_v" + previous.Version.String() + if validateFlatName(name) != nil { + continue + } + candidate := filepath.Join(root, name) + candidateAbs, err := filepath.Abs(candidate) + if err != nil { + continue + } + rel, err := filepath.Rel(root, candidateAbs) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || strings.Contains(rel, string(filepath.Separator)) { + continue + } + if _, alreadyRemoved := removed[candidateAbs]; alreadyRemoved { + continue + } + candidateInfo, err := os.Lstat(candidateAbs) + if err != nil || !candidateInfo.IsDir() || candidateInfo.Mode()&os.ModeSymlink != 0 { + continue + } + // Any receipt means this directory follows the newer ownership protocol, + // including malformed receipts that cannot be used to prove ownership. + if _, err := os.Lstat(filepath.Join(candidateAbs, installReceiptName)); !errors.Is(err, fs.ErrNotExist) { + continue + } + if runtimeReferencesPackageDirectory(current, root, candidateAbs) { + continue + } + if !legacySharedFileIsOwned(root, candidateAbs, sharedPath) { + continue + } + if err := removeAll(candidateAbs); err == nil { + removed[candidateAbs] = struct{}{} + } else { + cleanupErr = errors.Join(cleanupErr, fmt.Errorf("could not remove owned legacy package directory %s: %w", candidateAbs, err)) + } + } + return cleanupErr +} + +func legacySharedFileIsOwned(location, candidate, sharedPath string) bool { + if !filepath.IsAbs(sharedPath) { + sharedPath = filepath.Join(location, sharedPath) + } + sharedPath, err := filepath.Abs(sharedPath) + if err != nil { + return false + } + rel, err := filepath.Rel(candidate, sharedPath) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || strings.Contains(rel, string(filepath.Separator)) { + return false + } + info, err := os.Lstat(sharedPath) + return err == nil && info.Mode().IsRegular() +} + +func runtimeReferencesPackageDirectory(info DriverInfo, location, directory string) bool { + for sharedPath := range info.Driver.Shared.Paths() { + if sharedPath == "" { + continue + } + if !filepath.IsAbs(sharedPath) { + sharedPath = filepath.Join(location, sharedPath) + } + sharedPath = filepath.Clean(sharedPath) + if sharedPath == filepath.Clean(directory) || filepath.Dir(sharedPath) == filepath.Clean(directory) { + return true + } + } + return false +} + +func readManagedPackageReceipt(location, runtimeID, directory string) (InstallReceipt, bool) { + if validateFlatName(runtimeID) != nil || !strings.HasPrefix(filepath.Base(filepath.Clean(directory)), ".dbc-package-"+runtimeID+"-") { + return InstallReceipt{}, false + } + return readPackageReceipt(location, runtimeID, directory) +} + +func readPackageReceipt(location, runtimeID, directory string) (InstallReceipt, bool) { + receipt, ok := readPackageReceiptEvidence(location, runtimeID, directory) + if !ok { + return InstallReceipt{}, false + } + if receipt.InstalledLibrary == "" { + return receipt, true + } + libraryPath := filepath.Join(directory, receipt.InstalledLibrary) + if !VerifyInstallReceiptLibraryIntegrity(libraryPath, receipt) { + return InstallReceipt{}, false + } + return receipt, true +} + +// InspectInstallReceipt reads the receipt associated with a registered library +// path without checking the library bytes. The managed result identifies paths +// in a dbc-owned package generation even when its receipt is missing or invalid; +// present reports whether a receipt file exists, and valid reports whether its +// metadata and library/path relationship are structurally valid. Call +// VerifyInstallReceiptLibraryIntegrity separately to verify the current bytes. +func InspectInstallReceipt(location, runtimeID, libraryPath string) (receipt InstallReceipt, managed, present, valid bool) { + if validateFlatName(runtimeID) != nil || libraryPath == "" { + return InstallReceipt{}, false, false, false + } + absLocation, err := filepath.Abs(location) + if err != nil { + return InstallReceipt{}, false, false, false + } + absLibrary, err := filepath.Abs(libraryPath) + if err != nil { + return InstallReceipt{}, false, false, false + } + directory := filepath.Dir(absLibrary) + rel, err := filepath.Rel(absLocation, directory) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || strings.Contains(rel, string(filepath.Separator)) { + return InstallReceipt{}, false, false, false + } + if !isManagedPackageGenerationDirectory(filepath.Base(directory), runtimeID) { + return InstallReceipt{}, false, false, false + } + managed = true + _, err = os.Lstat(filepath.Join(directory, installReceiptName)) + present = err == nil || !errors.Is(err, fs.ErrNotExist) + if !present { + return InstallReceipt{}, managed, false, false + } + receipt, valid = readPackageReceiptEvidence(absLocation, runtimeID, directory) + if !valid { + return InstallReceipt{}, managed, present, false + } + if receipt.InstalledLibrary == "" || filepath.Base(absLibrary) != receipt.InstalledLibrary { + return InstallReceipt{}, managed, present, false + } + return receipt, managed, present, true +} + +// InspectDriverInstallReceipt resolves the filesystem root for the driver's +// actual registration before inspecting its managed package receipt. This is +// important for ConfigEnv, where Config.Location may be a path list while the +// selected DriverInfo.FilePath names the directory that supplied the active +// manifest. Registry-backed Windows registrations fall back to the configured +// filesystem installation root. +func InspectDriverInstallReceipt(cfg Config, info DriverInfo) (receipt InstallReceipt, managed, present, valid bool, err error) { + location, err := uninstallPackageCleanupLocation(cfg, info) + if err != nil { + return InstallReceipt{}, false, false, false, err + } + receipt, managed, present, valid = InspectInstallReceipt(location, info.ID, info.Driver.Shared.Get(PlatformTuple())) + return receipt, managed, present, valid, nil +} + +func isManagedPackageGenerationDirectory(directory, runtimeID string) bool { + if strings.HasPrefix(directory, ".dbc-package-"+runtimeID+"-") { + return true + } + legacyPrefix := runtimeID + "_" + if !strings.HasPrefix(directory, legacyPrefix) { + return false + } + remainder := strings.TrimPrefix(directory, legacyPrefix) + versionSeparator := strings.LastIndex(remainder, "_v") + if versionSeparator <= 0 { + return false + } + platform, version := remainder[:versionSeparator], remainder[versionSeparator+2:] + if validatePlatformIdentifier(platform) != nil { + return false + } + _, err := semver.NewVersion(version) + return err == nil +} + +// VerifyInstallReceiptLibraryIntegrity checks only that the current registered +// library is a regular file whose digest matches the receipt. Receipt identity +// and artifact metadata are intentionally checked by the caller separately. +func VerifyInstallReceiptLibraryIntegrity(libraryPath string, receipt InstallReceipt) bool { + if libraryPath == "" || validateFlatName(receipt.InstalledLibrary) != nil { + return false + } + if filepath.Base(filepath.Clean(libraryPath)) != receipt.InstalledLibrary { + return false + } + if _, err := parseSHA256(receipt.InstalledLibraryHash); err != nil { + return false + } + libraryInfo, err := os.Lstat(libraryPath) + if err != nil || !libraryInfo.Mode().IsRegular() { + return false + } + actualHash, err := hashFile(libraryPath) + return err == nil && actualHash == receipt.InstalledLibraryHash +} + +func readPackageReceiptEvidence(location, runtimeID, directory string) (InstallReceipt, bool) { + var receipt InstallReceipt + absLocation, err := filepath.Abs(location) + if err != nil { + return receipt, false + } + absDirectory, err := filepath.Abs(directory) + if err != nil { + return receipt, false + } + rel, err := filepath.Rel(absLocation, absDirectory) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || strings.Contains(rel, string(filepath.Separator)) { + return receipt, false + } + dirInfo, err := os.Lstat(absDirectory) + if err != nil || !dirInfo.IsDir() || dirInfo.Mode()&os.ModeSymlink != 0 { + return receipt, false + } + receiptPath := filepath.Join(absDirectory, installReceiptName) + receiptInfo, err := os.Lstat(receiptPath) + if err != nil || !receiptInfo.Mode().IsRegular() { + return receipt, false + } + data, err := os.ReadFile(receiptPath) + if err != nil || json.Unmarshal(data, &receipt) != nil { + return InstallReceipt{}, false + } + if receipt.DriverID != runtimeID || validateFlatName(receipt.DriverID) != nil || + strings.TrimSpace(receipt.SourceType) == "" || strings.TrimSpace(receipt.SourceIdentity) == "" || + receipt.ArchiveSize <= 0 || parseReceiptMetadata(receipt) != nil { + return InstallReceipt{}, false + } + hasLibrary := receipt.InstalledLibrary != "" || receipt.InstalledLibraryHash != "" + if !hasLibrary { + return receipt, true + } + if validateFlatName(receipt.InstalledLibrary) != nil { + return InstallReceipt{}, false + } + if _, err := parseSHA256(receipt.InstalledLibraryHash); err != nil { + return InstallReceipt{}, false + } + return receipt, true +} + +func parseReceiptMetadata(receipt InstallReceipt) error { + if _, err := semver.NewVersion(receipt.DriverVersion); err != nil { + return fmt.Errorf("invalid receipt driver version: %w", err) + } + if err := validatePlatformIdentifier(receipt.Platform); err != nil { + return fmt.Errorf("invalid receipt platform: %w", err) + } + if _, err := parseSHA256(receipt.ArchiveHash); err != nil { + return fmt.Errorf("invalid receipt archive hash: %w", err) + } + return nil +} + +func validateExpectedPackage(expected ExpectedPackageMetadata) error { + if err := validateFlatName(expected.ID); err != nil { + return fmt.Errorf("invalid expected package id: %w", err) + } + for name, value := range map[string]string{ + "version": expected.Version, + "platform": expected.Platform, + "source type": expected.SourceType, + "source identity": expected.SourceIdentity, + } { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("expected package %s is required", name) + } + } + if _, err := semver.NewVersion(expected.Version); err != nil { + return fmt.Errorf("invalid expected package version %q: %w", expected.Version, err) + } + if err := validatePlatformIdentifier(expected.Platform); err != nil { + return fmt.Errorf("invalid expected package platform: %w", err) + } + if expected.ArchiveSize < 0 { + return errors.New("expected archive size must not be negative") + } + if _, err := parseSHA256(expected.ArchiveHash); err != nil { + return fmt.Errorf("invalid expected archive hash: %w", err) + } + return nil +} diff --git a/config/registration_fingerprint_test.go b/config/registration_fingerprint_test.go new file mode 100644 index 00000000..19a6642c --- /dev/null +++ b/config/registration_fingerprint_test.go @@ -0,0 +1,319 @@ +// 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 ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Masterminds/semver/v3" +) + +func fingerprintFixture() (DriverInfo, registrationSharedIdentity) { + info := DriverInfo{ + ID: "example", Name: "Example Driver", Publisher: "Example Publisher", + License: "Apache-2.0", Source: "dbc", + } + info.Version = semver.MustParse("1.2.3+driver.7") + info.AdbcInfo.Version = semver.MustParse("0.8.1+abi.2") + info.AdbcInfo.Features.Supported = []string{"feature-b", "feature-a", "feature-a"} + info.AdbcInfo.Features.Unsupported = nil + info.Driver.Entrypoint = "AdbcDriverExampleInit" + info.Driver.Shared.Set(PlatformTuple(), "/tmp/random-generation/driver.so") + return info, registrationSharedIdentity{Kind: "package_file", PackageFile: "driver.so"} +} + +func fingerprintForTest(t *testing.T, info DriverInfo, identity registrationSharedIdentity) string { + t.Helper() + fingerprint, err := runtimeRegistrationFingerprint(info, PlatformTuple(), identity) + if err != nil { + t.Fatal(err) + } + return fingerprint +} + +func TestRuntimeRegistrationFingerprintCanonicalizesOnlyDeclaredFields(t *testing.T) { + base, identity := fingerprintFixture() + baseFingerprint := fingerprintForTest(t, base, identity) + mutations := []struct { + name string + mutate func(*DriverInfo) + }{ + {name: "id", mutate: func(info *DriverInfo) { info.ID = "other" }}, + {name: "source", mutate: func(info *DriverInfo) { info.Source = "external" }}, + {name: "name", mutate: func(info *DriverInfo) { info.Name = "Other Driver" }}, + {name: "publisher", mutate: func(info *DriverInfo) { info.Publisher = "Other Publisher" }}, + {name: "license", mutate: func(info *DriverInfo) { info.License = "MIT" }}, + {name: "entrypoint", mutate: func(info *DriverInfo) { info.Driver.Entrypoint = "OtherInit" }}, + {name: "driver version", mutate: func(info *DriverInfo) { info.Version = semver.MustParse("1.2.4+driver.7") }}, + {name: "adbc version", mutate: func(info *DriverInfo) { info.AdbcInfo.Version = semver.MustParse("0.8.2+abi.2") }}, + {name: "supported features", mutate: func(info *DriverInfo) { info.AdbcInfo.Features.Supported = []string{"feature-a"} }}, + {name: "unsupported features", mutate: func(info *DriverInfo) { info.AdbcInfo.Features.Unsupported = []string{"feature-d"} }}, + } + for _, mutation := range mutations { + t.Run(mutation.name, func(t *testing.T) { + changed := base + mutation.mutate(&changed) + if fingerprintForTest(t, changed, identity) == baseFingerprint { + t.Fatal("runtime metadata change retained the same registration fingerprint") + } + }) + } + + otherGeneration := base + otherGeneration.FilePath = "/other/config/root" + otherGeneration.Driver.Shared.Set(PlatformTuple(), "/other/config/root/.dbc-package-example-random/driver.so") + if got := fingerprintForTest(t, otherGeneration, identity); got != baseFingerprint { + t.Fatalf("generation directory changed fingerprint: got %s, want %s", got, baseFingerprint) + } + + spelling, err := semver.NewVersion("v1.2.3+driver.7") + if err != nil { + t.Fatal(err) + } + spellingInfo := base + spellingInfo.Version = spelling + if spelling.String() != base.Version.String() { + t.Fatalf("test semver spellings are not equivalent under String: %q != %q", spelling.String(), base.Version.String()) + } + spellingInfo.AdbcInfo.Features.Supported = []string{"feature-a", "feature-b"} + spellingInfo.AdbcInfo.Features.Unsupported = []string{} + if got := fingerprintForTest(t, spellingInfo, identity); got != baseFingerprint { + t.Fatalf("semver spelling/features order and duplicates changed fingerprint: got %s, want %s", got, baseFingerprint) + } + + buildMetadata := base + buildMetadata.Version = semver.MustParse("1.2.3+driver.8") + if got := fingerprintForTest(t, buildMetadata, identity); got == baseFingerprint { + t.Fatal("semver build metadata was omitted from the registration fingerprint") + } + missingADBC := base + missingADBC.AdbcInfo.Version = nil + if got := fingerprintForTest(t, missingADBC, identity); got == baseFingerprint { + t.Fatal("nil ADBC version did not differ from an explicit version") + } +} + +func TestRuntimeRegistrationFingerprintTagsSharedIdentity(t *testing.T) { + info, owned := fingerprintFixture() + ownedFingerprint := fingerprintForTest(t, info, owned) + external := registrationSharedIdentity{Kind: "external", ExternalReference: "/opt/example/driver.so"} + externalFingerprint := fingerprintForTest(t, info, external) + if externalFingerprint == ownedFingerprint { + t.Fatal("owned package file and external shared reference had the same fingerprint") + } + otherExternal := registrationSharedIdentity{Kind: "external", ExternalReference: "/opt/example/other.so"} + if fingerprintForTest(t, info, otherExternal) == externalFingerprint { + t.Fatal("different external shared references had the same fingerprint") + } + caseChanged := registrationSharedIdentity{Kind: "external", ExternalReference: "/opt/example/Driver.so"} + if fingerprintForTest(t, info, caseChanged) == externalFingerprint { + t.Fatal("external reference case was unexpectedly normalized") + } +} + +func TestPackageValidationFingerprintMatchesInstalledReceipt(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + archive := makeInstallArchive(t, "example", "1.2.3", "driver.so", []byte("library")) + expected := installExpected("example", "candidate-source", archive) + expected.Version = "1.2.3" + validationFile := writeInstallArchive(t, archive, "fingerprint-validation") + defer validationFile.Close() + validation, err := PreparePackage(cfg, "example", validationFile, expected, InstallOptions{}) + if err != nil { + t.Fatal(err) + } + if validation.Prepared == nil { + t.Fatal("package preparation returned no prepared payload") + } + t.Cleanup(func() { + if err := validation.Prepared.Close(); err != nil { + t.Errorf("close prepared package: %v", err) + } + }) + if validation.RegistrationFingerprintAlgorithm != registrationFingerprintAlgorithm || validation.RegistrationFingerprintVersion != registrationFingerprintVersion { + t.Fatalf("validation fingerprint algorithm/version = %q/%d", validation.RegistrationFingerprintAlgorithm, validation.RegistrationFingerprintVersion) + } + installedFile := writeInstallArchive(t, archive, "fingerprint-install") + installed, err := InstallPackage(cfg, "example", installedFile, expected, InstallOptions{}) + _ = installedFile.Close() + if err != nil { + t.Fatal(err) + } + current, err := GetDriver(cfg, "example") + if err != nil { + t.Fatal(err) + } + if current.Driver.Shared.Get(PlatformTuple()) == validation.Registration.Driver.Shared.Get(PlatformTuple()) { + t.Fatal("validation and install unexpectedly used the same generation path") + } + receipt, managed, present, valid, err := InspectDriverInstallReceipt(cfg, current) + if err != nil || !managed || !present || !valid { + t.Fatalf("installed receipt inspection managed=%t present=%t valid=%t error=%v", managed, present, valid, err) + } + if receipt.RegistrationFingerprint != validation.RegistrationFingerprint { + t.Fatalf("validation fingerprint %q differs from installed receipt %q", validation.RegistrationFingerprint, receipt.RegistrationFingerprint) + } + if !PackageValidationMatchesRuntimeRegistration(current, validation, PlatformTuple()) { + t.Fatal("validated package fingerprint did not match the installed runtime registration") + } + if !InstallReceiptMatchesRuntimeRegistration(receipt, current, PlatformTuple()) { + t.Fatal("receipt fingerprint did not match the installed runtime registration") + } + if !VerifyInstallReceiptLibraryIntegrity(installed.Driver.Shared.Get(PlatformTuple()), receipt) { + t.Fatal("installed library did not match its receipt") + } + + tampered := current + tampered.Name = "Tampered Runtime Name" + if err := CreateManifest(cfg, tampered); err != nil { + t.Fatal(err) + } + tamperedCurrent, err := GetDriver(cfg, "example") + if err != nil { + t.Fatal(err) + } + if InstallReceiptMatchesRuntimeRegistration(receipt, tamperedCurrent, PlatformTuple()) { + t.Fatal("receipt fingerprint accepted tampered runtime metadata") + } + if PackageValidationMatchesRuntimeRegistration(tamperedCurrent, validation, PlatformTuple()) { + t.Fatal("candidate fingerprint accepted tampered runtime metadata") + } +} + +func TestFingerprintReceiptCompatibilityAndCleanup(t *testing.T) { + tests := []struct { + name string + mutate func(*InstallReceipt) + }{ + {name: "legacy missing fields", mutate: func(receipt *InstallReceipt) { + receipt.RegistrationFingerprintAlgorithm = "" + receipt.RegistrationFingerprintVersion = 0 + receipt.RegistrationFingerprint = "" + }}, + {name: "partial fields", mutate: func(receipt *InstallReceipt) { + receipt.RegistrationFingerprintAlgorithm = registrationFingerprintAlgorithm + receipt.RegistrationFingerprintVersion = 0 + }}, + {name: "unknown schema", mutate: func(receipt *InstallReceipt) { + receipt.RegistrationFingerprintVersion++ + }}, + {name: "malformed digest", mutate: func(receipt *InstallReceipt) { + receipt.RegistrationFingerprint = "sha256:bad" + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + cfg := Config{Level: ConfigEnv, Location: root} + archive := makeInstallArchive(t, "example", "1.0.0", "driver.so", []byte("old library")) + firstFile := writeInstallArchive(t, archive, "old-fingerprint") + installed, err := InstallPackage(cfg, "example", firstFile, installExpected("example", "source", archive), InstallOptions{}) + _ = firstFile.Close() + if err != nil { + t.Fatal(err) + } + current, err := GetDriver(cfg, "example") + if err != nil { + t.Fatal(err) + } + receiptPath := filepath.Join(filepath.Dir(installed.Driver.Shared.Get(PlatformTuple())), installReceiptName) + receiptData, err := os.ReadFile(receiptPath) + if err != nil { + t.Fatal(err) + } + var receipt InstallReceipt + if err := json.Unmarshal(receiptData, &receipt); err != nil { + t.Fatal(err) + } + test.mutate(&receipt) + mutatedData, err := json.Marshal(receipt) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(receiptPath, mutatedData, 0o600); err != nil { + t.Fatal(err) + } + inspected, managed, present, valid, err := InspectDriverInstallReceipt(cfg, current) + if err != nil || !managed || !present || !valid { + t.Fatalf("fingerprint metadata changed receipt inspection: managed=%t present=%t valid=%t error=%v", managed, present, valid, err) + } + if InstallReceiptMatchesRuntimeRegistration(inspected, current, PlatformTuple()) { + t.Fatal("legacy/unknown/malformed fingerprint was accepted as reuse proof") + } + + nextArchive := makeInstallArchive(t, "example", "2.0.0", "new-driver.so", []byte("new library")) + nextFile := writeInstallArchive(t, nextArchive, "new-fingerprint") + nextExpected := installExpected("example", "next-source", nextArchive) + nextExpected.Version = "2.0.0" + _, err = InstallPackage(cfg, "example", nextFile, nextExpected, InstallOptions{}) + _ = nextFile.Close() + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Dir(installed.Driver.Shared.Get(PlatformTuple()))); !os.IsNotExist(err) { + t.Fatalf("cleanup did not preserve receipt compatibility; old generation stat error = %v", err) + } + }) + } +} + +func TestSameRuntimeDriverRegistrationUsesSemanticVersionsAndFeatureSets(t *testing.T) { + first, identity := fingerprintFixture() + second := first + second.FilePath = "/another/registration/root" + second.Version = semver.MustParse("v1.2.3+driver.7") + second.AdbcInfo.Version = semver.MustParse("v0.8.1+abi.2") + second.AdbcInfo.Features.Supported = []string{"feature-a", "feature-b", "feature-a"} + second.AdbcInfo.Features.Unsupported = nil + if !SameRuntimeDriverRegistration(first, second, PlatformTuple()) { + t.Fatal("canonical semver equality and feature set equality were not applied") + } + buildMetadataChange := second + buildMetadataChange.Version = semver.MustParse("1.2.3+different-build") + if SameRuntimeDriverRegistration(first, buildMetadataChange, PlatformTuple()) { + t.Fatal("driver version build metadata difference was ignored") + } + buildMetadataChange = second + buildMetadataChange.AdbcInfo.Version = semver.MustParse("0.8.1+different-build") + if SameRuntimeDriverRegistration(first, buildMetadataChange, PlatformTuple()) { + t.Fatal("ADBC version build metadata difference was ignored") + } + second.Driver.Shared = driverMap{} + second.Driver.Shared.Set(PlatformTuple(), "/opt/another/driver.so") + if SameRuntimeDriverRegistration(first, second, PlatformTuple()) { + t.Fatal("external shared reference change was ignored") + } + if _, err := runtimeRegistrationFingerprint(first, PlatformTuple(), identity); err != nil { + t.Fatal(err) + } +} + +func TestRegistrationFingerprintDigestIsCanonicalSHA256(t *testing.T) { + info, identity := fingerprintFixture() + fingerprint := fingerprintForTest(t, info, identity) + if !strings.HasPrefix(fingerprint, "sha256:") || !validRegistrationFingerprint("sha256", registrationFingerprintVersion, fingerprint) { + t.Fatalf("registration fingerprint is not canonical SHA-256: %q", fingerprint) + } + if validRegistrationFingerprint("future", registrationFingerprintVersion, fingerprint) || + validRegistrationFingerprint("sha256", registrationFingerprintVersion+1, fingerprint) || + validRegistrationFingerprint("sha256", registrationFingerprintVersion, "sha256:malformed") { + t.Fatal("unknown or malformed fingerprint metadata was accepted") + } +} diff --git a/docs/guides/driver_list.md b/docs/guides/driver_list.md index 1f3bb93f..b2ec64a8 100644 --- a/docs/guides/driver_list.md +++ b/docs/guides/driver_list.md @@ -98,30 +98,46 @@ $ dbc sync Done! ``` +{{ since_version('v0.4.0') }} + +The following lockfile replay behavior is planned for dbc 0.4.0. + The first time you run `dbc sync`, dbc creates a [lockfile](#lockfile) in the same directory as the driver list. By default, this file is called `dbc.lock` but will match the name of your driver list file if you choose to use a custom one. -When you run `dbc sync` and a lockfile already exists, dbc will install the exact versions in the lockfile. -To upgrade the versions in the lockfile, delete the lockfile and run `dbc sync`. +When a complete lock entry exists, `dbc sync` replays the exact version and +artifact, verifying its SHA-256 digest and any recorded size without registry +or Packslip discovery. A partial lock may gain the current target; existing +targets remain unchanged. ## Lockfile `dbc sync` automatically creates a lockfile file in the same directory as the driver list. By default, this file is called `dbc.lock` but will match the name of your driver list file if you choose to use a custom one. -The lockfile records the exact version of the drivers that were installed, including version, platform, and a checksum: +The v2 lockfile records an exact version, source identity, verification +evidence, and target artifacts with a required SHA-256 digest and optional size +from signed Packslip metadata or local measurement. Registry artifact metadata +may include a hash but no size; downloaded sizes are not added to locks. +Existing recorded sizes remain valid and are verified. A missing target is not +evidence that the target is available. -```console -$ cat dbc.lock -version = 1 - -[[drivers]] -name = 'mysql' -version = '0.1.0' -platform = 'macos_arm64' -checksum = 'e989f8c49262359093f03e2f43a796b163d2774de519e07cef14ebd63590c81d' +For example, a source can be pinned explicitly: + +```toml +[drivers.mysql.source] +type = 'registry' +url = 'https://registry.example.test' ``` -Every time you run `dbc sync`, this file is updated with the exact information about each driver that was installed. +For an implicit registry source, the selected registry URL is saved in the +lock, and partial refresh remains pinned to it. A Packslip replay does not +re-check a later release withdrawal or trust-store change; discovery and +verification run again only during refresh. Path sources are also snapshotted: +relative paths use the lockfile directory. Changed bytes fail hash verification +for an already-locked target; if the current target is absent from a partial +lock, path resolution may snapshot the current bytes as a new target while +retaining existing targets. A dedicated refresh UX is not yet available, so +review lockfile changes as deliberate dependency updates. It's a good idea to track `dbc.lock` as well as `dbc.toml` in version control if you want to ensure a completely reproducible set of drivers. ## Version Constraints diff --git a/docs/guides/installing.md b/docs/guides/installing.md index 1c54353d..20588227 100644 --- a/docs/guides/installing.md +++ b/docs/guides/installing.md @@ -267,23 +267,48 @@ Installed mysql 0.1.0 to /opt/homebrew/Caskroom/miniforge/base/envs/my-adbc-proj ## From Local Archive -dbc can install drivers from local archives as an alternative for users who can't or don't want to install from a [Driver Registry](../concepts/driver_registry.md). This is meant for advanced use cases and requires understanding the [ADBC Driver Manifests](https://arrow.apache.org/adbc/current/format/driver_manifests.html) spec and loading process. +dbc can install drivers from local archives as an alternative to a [Driver Registry](../concepts/driver_registry.md). It reads package metadata and generates the installed `.toml` ADBC Driver Manifest used by Driver Managers. + +### Package archive metadata + +Legacy archives with a root-level `MANIFEST` are the only supported package format. They work with local paths, registries, and Packslip releases. Source metadata, including Packslip extensions, identifies the driver and artifact target independently of the archive format. + +Packslip installations verify signed release metadata and the selected archive's hash and any recorded size. They do not require a separate legacy library signature or `--no-verify`. Registry and ordinary local installations retain their existing library-signature checks. + +For example, `MANIFEST` can contain: + +```toml +name = "Example ADBC Driver" +version = "1.0.0" + +[Driver] +entrypoint = "AdbcDriverExampleInit" + +[Files] +driver = "libadbc_driver_example.so" +``` + +The archive must be flat: place `MANIFEST` and the library file at its root. `Files.driver` names the library file in the archive root. Existing manifest-only packages that register externally managed libraries remain supported. + +The root-level filename `dbc-package.toml` is reserved for a future dbc-owned package metadata format. dbc rejects archives containing it as unsupported, and rejects archives containing both metadata filenames. The reserved file is never installed as ordinary package payload. Its schema, mapping to ADBC Driver Manifest metadata, migration policy, and compatibility rules will be designed separately. + +The future format is intended to make packages self-describing, including their identity and target, and separate dbc-owned package metadata from the installed `.toml` ADBC Driver Manifest. Registry, Packslip, local path sources, lockfile v2, archive verification, receipts, and replay do not depend on that future format. To install from a local archive, pass the path to a local archive instead of a name and set the `--no-verify` flag to skip signature verification: ```console -$ dbc install --no-verify some_driver.tar.gz -Installing from local package: some_driver.tar.gz +$ dbc install --no-verify example.tar.gz +Installing from local package: example.tar.gz [✓] installing [-] verifying signature -Installed some_driver 1.0.0 to /Users/user/Library/Application Support/ADBC/Drivers +Installed example 1.0.0 to /Users/user/Library/Application Support/ADBC/Drivers ``` !!! note - Make note of the name "some_driver" printed above as this will be the name to use when loading the driver with a [Driver Manager](../concepts/driver_manager.md). i.e., `dbapi.connect(driver="some_driver")`. + Direct local installation derives the runtime driver ID from the archive filename because legacy `MANIFEST` metadata does not declare it. For example, `example.tar.gz` and `example_linux_amd64_v1.2.3.tar.gz` on Linux amd64 both use `example` when connecting through a [Driver Manager](../concepts/driver_manager.md). Project path sources use the driver ID declared in `dbc.toml`; Packslip uses the signed driver identity. ## GitHub Actions diff --git a/docs/reference/driver_list.md b/docs/reference/driver_list.md index ec26abb1..c356d32b 100644 --- a/docs/reference/driver_list.md +++ b/docs/reference/driver_list.md @@ -18,6 +18,11 @@ limitations under the License. `dbc.toml` is the default filename dbc uses for a [driver list](../concepts/driver_list.md). This page outlines the structure of that file. +{{ since_version('v0.4.0') }} + +The source-aware lockfile and source pinning described below are planned for +dbc 0.4.0. + This file uses the [TOML](https://toml.io) file format and contains a single TOML Table called "drivers". Each driver must have a name and may optionally have a version constraint and pre-release setting. See [Version Constraints](../guides/installing.md#version-constraints) to learn how to specify version constraints. @@ -87,3 +92,18 @@ To allow the pre-release in this case, either: - Add `prerelease = 'allow'` - Change the constraint to reference the pre-release: `version = '>=0.1.1-beta.1'` + +### `source` + +Optional. Pins a driver to one source identity. A registry source uses a +canonical registry URL: + +```toml +[drivers.mysql.source] +type = 'registry' +url = 'https://registry.example.test' +``` + +When `source` is omitted, the configured registry policy selects the source; +the selected URL is then recorded in `dbc.lock`, so a partial refresh remains +pinned to it. Packslip and path sources are snapshotted similarly. diff --git a/drivers.go b/drivers.go index 2bc7585b..de0026b6 100644 --- a/drivers.go +++ b/drivers.go @@ -37,6 +37,7 @@ import ( "github.com/ProtonMail/gopenpgp/v3/crypto" "github.com/columnar-tech/dbc/auth" "github.com/columnar-tech/dbc/internal" + "github.com/go-faster/yaml" "github.com/google/uuid" ) @@ -263,6 +264,10 @@ type PkgInfo struct { PlatformTuple string Path *url.URL + // ArtifactHash is the optional digest of the downloaded archive. + ArtifactHash string + // ArtifactSize is the optional size in bytes of the downloaded archive. + ArtifactSize *int64 } // Deprecated: Use Client.Download instead. @@ -311,11 +316,14 @@ func (p PkgInfo) DownloadPackage(prog ProgressFunc) (*os.File, error) { } type pkginfo struct { - Version *semver.Version `yaml:"version"` - Packages []struct { - PlatformTuple string `yaml:"platform"` - URL string `yaml:"url"` - } `yaml:"packages"` + Version *semver.Version `yaml:"version"` + Packages []registryPackage `yaml:"packages"` +} + +type registryPackage struct { + PlatformTuple string `yaml:"platform"` + URL string `yaml:"url"` + Hash yaml.Node `yaml:"hash,omitempty"` } func (p pkginfo) GetPackage(d Driver, platformTuple string) (PkgInfo, error) { @@ -326,30 +334,23 @@ func (p pkginfo) GetPackage(d Driver, platformTuple string) (PkgInfo, error) { if d.Registry == nil { return PkgInfo{}, fmt.Errorf("cannot resolve package URL for %s: driver has no registry", d.Title) } - base := d.Registry.BaseURL for _, pkg := range p.Packages { if pkg.PlatformTuple == platformTuple { - var uri *url.URL - - if pkg.URL != "" { - var err error - uri, err = url.Parse(pkg.URL) - if err != nil { - return PkgInfo{}, fmt.Errorf("invalid package URL %q: %w", pkg.URL, err) - } - if !uri.IsAbs() { - uri = base.JoinPath(pkg.URL) - } - } else { - uri = base.JoinPath(d.Path, p.Version.String(), - d.Path+"_"+platformTuple+"-"+p.Version.String()+".tar.gz") + uri, err := resolveRegistryPackageURL(d, p.Version, pkg) + if err != nil { + return PkgInfo{}, err } + artifact, err := pkg.resolveArtifact() + if err != nil { + return PkgInfo{}, err + } return PkgInfo{ Driver: d, Version: p.Version, PlatformTuple: platformTuple, Path: uri, + ArtifactHash: artifact.Hash, }, nil } } @@ -395,10 +396,7 @@ func (d Driver) GetWithConstraint(c *semver.Constraints, platformTuple string) ( return false } - return slices.ContainsFunc(p.Packages, func(p struct { - PlatformTuple string `yaml:"platform"` - URL string `yaml:"url"` - }) bool { + return slices.ContainsFunc(p.Packages, func(p registryPackage) bool { return p.PlatformTuple == platformTuple }) }) @@ -474,6 +472,47 @@ func (d Driver) GetPackage(version *semver.Version, platformTuple string, allowP return pkg.GetPackage(d, platformTuple) } +// GetPackages returns every artifact described by the exact registry release. +// The returned packages are ordered by platform tuple so callers can use the +// metadata to build deterministic release snapshots. A nil version is not +// accepted because this method must not select a release independently from +// the normal registry version-resolution path. +func (d Driver) GetPackages(version *semver.Version) ([]PkgInfo, error) { + if version == nil { + return nil, errors.New("exact registry version is required") + } + var release *pkginfo + for i := range d.PkgInfo { + if d.PkgInfo[i].Version.Equal(version) { + release = &d.PkgInfo[i] + break + } + } + if release == nil { + return nil, fmt.Errorf("version %s not found", version) + } + + packages := make([]PkgInfo, 0, len(release.Packages)) + for _, artifact := range release.Packages { + uri, err := resolveRegistryPackageURL(d, release.Version, artifact) + if err != nil { + return nil, err + } + metadata, err := artifact.resolveArtifact() + if err != nil { + return nil, err + } + packages = append(packages, PkgInfo{ + Driver: d, Version: release.Version, PlatformTuple: artifact.PlatformTuple, + Path: uri, ArtifactHash: metadata.Hash, + }) + } + sort.Slice(packages, func(i, j int) bool { + return packages[i].PlatformTuple < packages[j].PlatformTuple + }) + return packages, nil +} + func (d Driver) MaxVersion() (VersionInfo, bool) { if len(d.PkgInfo) == 0 { return VersionInfo{}, false diff --git a/drivers_test.go b/drivers_test.go new file mode 100644 index 00000000..0a5237d1 --- /dev/null +++ b/drivers_test.go @@ -0,0 +1,69 @@ +// 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 dbc + +import ( + "strings" + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/go-faster/yaml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDriverGetPackagesReturnsExactReleaseInPlatformOrder(t *testing.T) { + const hash = "sha256:" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + var fixture struct { + Drivers []Driver `yaml:"drivers"` + } + index := `drivers: + - name: Example Driver + description: test fixture + license: MIT + path: example-driver + pkginfo: + - version: 1.2.3 + packages: + - platform: windows_amd64 + url: windows.tar.gz + hash: ` + hash + ` + - platform: linux_amd64 + url: linux.tar.gz + hash: ` + hash + ` + - version: 1.1.0 + packages: + - platform: linux_amd64 + url: old.tar.gz + hash: ` + hash + ` +` + require.NoError(t, yaml.NewDecoder(strings.NewReader(index)).Decode(&fixture)) + require.Len(t, fixture.Drivers, 1) + driver := fixture.Drivers[0] + driver.Registry = &Registry{BaseURL: mustParseURL("https://registry.example.test")} + + packages, err := driver.GetPackages(semver.MustParse("1.2.3")) + require.NoError(t, err) + require.Len(t, packages, 2) + assert.Equal(t, []string{"linux_amd64", "windows_amd64"}, []string{packages[0].PlatformTuple, packages[1].PlatformTuple}) + assert.Equal(t, "https://registry.example.test/linux.tar.gz", packages[0].Path.String()) + assert.Equal(t, hash, packages[0].ArtifactHash) + assert.Nil(t, packages[0].ArtifactSize) + + _, err = driver.GetPackages(nil) + require.ErrorContains(t, err, "exact registry version is required") + _, err = driver.GetPackages(semver.MustParse("9.9.9")) + require.ErrorContains(t, err, "version 9.9.9 not found") +} diff --git a/go.mod b/go.mod index e0257bb7..420d76a7 100644 --- a/go.mod +++ b/go.mod @@ -34,7 +34,9 @@ require ( github.com/google/uuid v1.6.0 github.com/mattn/go-isatty v0.0.24 github.com/pelletier/go-toml/v2 v2.4.3 + github.com/sigstore/sigstore-go v1.3.0 github.com/stretchr/testify v1.12.1 + github.com/theupdateframework/go-tuf/v2 v2.4.2 github.com/zeroshade/machine-id v0.0.0-20251223181436-930511047eef golang.org/x/sys v0.48.0 ) @@ -42,7 +44,11 @@ require ( require ( github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/alexflint/go-scalar v1.2.0 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/atotto/clipboard v0.1.4 // indirect + github.com/blang/semver v3.5.1+incompatible // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect @@ -51,17 +57,77 @@ require ( github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect + github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect + github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect + github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect github.com/go-faster/errors v0.7.1 // indirect github.com/go-faster/jx v1.2.0 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/analysis v0.25.5 // indirect + github.com/go-openapi/errors v0.22.8 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.0 // indirect + github.com/go-openapi/loads v0.25.0 // indirect + github.com/go-openapi/runtime v0.33.0 // indirect + github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect + github.com/go-openapi/spec v0.22.9 // indirect + github.com/go-openapi/strfmt v0.27.0 // indirect + github.com/go-openapi/swag v0.26.1 // indirect + github.com/go-openapi/swag/cmdutils v0.27.0 // indirect + github.com/go-openapi/swag/conv v0.27.3 // indirect + github.com/go-openapi/swag/fileutils v0.27.3 // indirect + github.com/go-openapi/swag/jsonname v0.26.1 // indirect + github.com/go-openapi/swag/jsonutils v0.27.3 // indirect + github.com/go-openapi/swag/loading v0.27.3 // indirect + github.com/go-openapi/swag/mangling v0.27.3 // indirect + github.com/go-openapi/swag/netutils v0.27.0 // indirect + github.com/go-openapi/swag/pools v0.27.3 // indirect + github.com/go-openapi/swag/stringutils v0.27.3 // indirect + github.com/go-openapi/swag/typeutils v0.27.3 // indirect + github.com/go-openapi/swag/yamlutils v0.27.3 // indirect + github.com/go-openapi/validate v0.26.1 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/google/certificate-transparency-go v1.3.3 // indirect + github.com/google/go-containerregistry v0.21.7 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/in-toto/attestation v1.2.0 // indirect + github.com/in-toto/in-toto-golang v0.11.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/mattn/go-runewidth v0.0.27 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.3 // indirect + github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect github.com/segmentio/asm v1.2.1 // indirect + github.com/shibumi/go-pathspec v1.3.0 // indirect + github.com/sigstore/protobuf-specs v0.5.1 // indirect + github.com/sigstore/rekor v1.5.3 // indirect + github.com/sigstore/rekor-tiles/v2 v2.3.0 // indirect + github.com/sigstore/sigstore v1.10.8 // indirect + github.com/sigstore/timestamp-authority/v2 v2.1.3 // indirect + github.com/transparency-dev/formats v0.1.1 // indirect + github.com/transparency-dev/merkle v0.0.2 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.52.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect golang.org/x/sync v0.22.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect + google.golang.org/grpc v1.82.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect + k8s.io/klog/v2 v2.140.0 // indirect ) diff --git a/go.sum b/go.sum index 2c6d2c25..1b78afc8 100644 --- a/go.sum +++ b/go.sum @@ -4,20 +4,94 @@ charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8= charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= +cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= +cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= +cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= +cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY= +cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e h1:VsUbObBMxXlc23Eb9VeeJYE4jvTs87qa5RqSN2U5FJU= +filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e/go.mod h1:32qQ5yj3R24Eu03iWFWchdC3OB653wPvoepWejkefbY= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0/go.mod h1:i2h9fsTFKZorh8RdV2IcSUf/Qj98GlTkrTvUbX/s8as= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 h1:4iB+IesclUXdP0ICgAabvq2FYLXrJWKx1fJQ+GxSo3Y= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/ProtonMail/gopenpgp/v3 v3.4.1 h1:K7uUhSHSJxORZ+RuHpilTT6S4MA2whCRlXNwLqd0+ys= github.com/ProtonMail/gopenpgp/v3 v3.4.1/go.mod h1:bGdV9f6edhmd581wzXsQCTKdH8bXBbyhkgDKPjwPc6U= +github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= +github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/alexflint/go-arg v1.6.1 h1:uZogJ6VDBjcuosydKgvYYRhh9sRCusjOvoOLZopBlnA= github.com/alexflint/go-arg v1.6.1/go.mod h1:nQ0LFYftLJ6njcaee0sU+G0iS2+2XJQfA8I062D0LGc= github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw= github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= +github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= +github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc= +github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc= +github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 h1:QNtg+Mtj1zmepk568+UKBD5DFfqh+ESTUUqQT27JkQc= +github.com/aws/aws-sdk-go-v2/service/kms v1.52.0/go.mod h1:Y0+uxvxz6ib4KktRdK0V4X45Vcs/JyYoz8H71pO8xeI= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8= +github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= +github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= @@ -46,52 +120,319 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= +github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= +github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 h1:ge14PCmCvPjpMQMIAH7uKg0lrtNSOdpYsRXlwk3QbaE= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1GUYL7P0MlNa00M67axePTq+9nBSGddR8I= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI= github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE= github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I= github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/analysis v0.25.5 h1:xPYEvTb90o1y0epuiOPAoG4QqahjP3cdp5xNlHeKJRI= +github.com/go-openapi/analysis v0.25.5/go.mod h1:d3UGtQC5uq5Kqqqis2VH09Km/v3vwsWrYkbp4gdm+Rc= +github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= +github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= +github.com/go-openapi/loads v0.25.0 h1:74Bc2snfaVlsHzwdQj/3gsA9XJz3daXTJVs+4ZaK7jI= +github.com/go-openapi/loads v0.25.0/go.mod h1:JFBw4SIB9+PTIFHDfcXuSSy5h6aWzjtUCrPYyx3qWU8= +github.com/go-openapi/runtime v0.33.0 h1:Dd3Oj2ig+WH8ckK95l0Wn2V8a4bH/UqWPRZVT0vc8yU= +github.com/go-openapi/runtime v0.33.0/go.mod h1:+rsupH3+TFKqmFysqkmgBOTxpVJV8eV+j9myvvea2Xw= +github.com/go-openapi/runtime/server-middleware v0.30.0 h1:8rPoJ/xv7JL8BsovaqboKETlpWBArVh8n+0L/GyePog= +github.com/go-openapi/runtime/server-middleware v0.30.0/go.mod h1:OYNT/TxNvB/VK5oe4htM2jDTwlEXuejVJmu0DVZfAMs= +github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w= +github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0= +github.com/go-openapi/strfmt v0.27.0 h1:kbcTeaD9TXuXD0hhMXzuYa1sdTo6+dWGvwjW93E80IM= +github.com/go-openapi/strfmt v0.27.0/go.mod h1:s/qhDqfY72irigXUGJmtgid2Rm+3tnz3k8hZaRmvWYc= +github.com/go-openapi/swag v0.26.1 h1:l5sVEyVpwj+DDYeZyo7wQI/Ebn/mKYIyGB/pFwAfGoQ= +github.com/go-openapi/swag v0.26.1/go.mod h1:yNY38BbIVthxbkDtq1UHBCGasBqjakW3lCR6ANzdBEw= +github.com/go-openapi/swag/cmdutils v0.27.0 h1:aIKiqhB29AaP+7xm8/CPg3uOpeHx2SUp6TvMpu/a31Y= +github.com/go-openapi/swag/cmdutils v0.27.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.27.3 h1:iqJFmGEjmX3AY0lSszABFqRVqOSt99XS0LzNIMJYuhU= +github.com/go-openapi/swag/conv v0.27.3/go.mod h1:nPRmN6jgNme99hpf+nM0auDZGALWIqlwhisKPK/bQhQ= +github.com/go-openapi/swag/fileutils v0.27.3 h1:3UVoZ2RLaIs1lt+2jcKzL8RM3Yk0rmsDE9FLA/HGxFE= +github.com/go-openapi/swag/fileutils v0.27.3/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= +github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= +github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= +github.com/go-openapi/swag/jsonutils v0.27.3 h1:1DEz+O82frtSMBcos/7XIn1GnpNTbsD4Bru4Dc/uhRc= +github.com/go-openapi/swag/jsonutils v0.27.3/go.mod h1:qiDCoQvzkMxrV3G8FLEdIU5L+EFYc0zcDOHWT3Yofvo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3 h1:h/eT9kmGCDdFLJF29lOhzLtF0FmP1AX2MhLJWVebsb8= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.27.3 h1:L9nQkEgzU7QgFQL+pLEMfGUKxeM4pWwGwbET9Z3weW0= +github.com/go-openapi/swag/loading v0.27.3/go.mod h1:rJ0NeaKsF4CVPnMGjPQl7JlSHzvD0bc2DKXLss1hiuE= +github.com/go-openapi/swag/mangling v0.27.3 h1:gRzzD1PAUoLTtGMgI3KpBmCSOlTuLTFWnviLxLcTnyg= +github.com/go-openapi/swag/mangling v0.27.3/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= +github.com/go-openapi/swag/netutils v0.27.0 h1:lEUG+hHvPvLggB3A8snFk0IRKNf9uC0YKc+7WYqvAF8= +github.com/go-openapi/swag/netutils v0.27.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= +github.com/go-openapi/swag/pools v0.27.3 h1:gXjImP3F6/56wRRcFgEPld084Y6u2gs21ikPBt8NKBk= +github.com/go-openapi/swag/pools v0.27.3/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.27.3 h1:Ru28hnbAvN5wycALQYy8IobHvASq+FUFMlp1QzLM0JI= +github.com/go-openapi/swag/stringutils v0.27.3/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.27.3 h1:l6SSrx5eR5/WVwrGNzN6bQ9WqL04mrxNBl9YgQ3rcJ4= +github.com/go-openapi/swag/typeutils v0.27.3/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.27.3 h1:cRFCAoYtslYn9L9T0xWryHy1t7c1MACC+DMj3CLvwvs= +github.com/go-openapi/swag/yamlutils v0.27.3/go.mod h1:6JYBGj8sw/NawMllyZY+cTA8Mzk2etS3ZBASdcyPsiU= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/validate v0.26.1 h1:pZSbvtRO8G2R2FpWTYRn3w8LrsNwbtaVhP2dWiBa0Us= +github.com/go-openapi/validate v0.26.1/go.mod h1:B8UMgXiQiwwQWIbmuROlwJZDPGlikPuh7iHV1vPX9Oo= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo= +github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU= +github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/trillian v1.7.3 h1:hziW+vo4czis48tzx2GK5xRBl/ZxBA9B0/UR5avXOro= +github.com/google/trillian v1.7.3/go.mod h1:qh8iy4x/GvnVXUBd5pK4oncuT1Y9vVYfibQVsR/WpKg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= +github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= +github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpahjQk= +github.com/in-toto/attestation v1.2.0/go.mod h1:r79G45gOmzPismgObLSL+rZTFxUgZLOQJI6LofTZgXk= +github.com/in-toto/in-toto-golang v0.11.0 h1:nfidMYBFx+E0lnmX5KUnN2Pdm8zdNKal1ayjJuzzRoA= +github.com/in-toto/in-toto-golang v0.11.0/go.mod h1:u3PjTnwFKjp5a1YCcw8SJg0G+tMeKfVoWsWeFMDCMtw= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b h1:ZGiXF8sz7PDk6RgkP+A/SFfUD0ZR/AgG6SpRNEDKZy8= +github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b/go.mod h1:hQmNrgofl+IY/8L+n20H6E6PWBBTokdsv+q49j0QhsU= +github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= +github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/letsencrypt/boulder v0.20260309.0 h1:kZynrxK3QfqLGx6hhoz+Rfs3hgltJs1p9Mp+4+VwnY0= +github.com/letsencrypt/boulder v0.20260309.0/go.mod h1:yG8lj8pNPZ8taq3oNdTpfBS+eC74IaEuiewqzVpXiWE= github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU= github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= +github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A= +github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk= +github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4= +github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k= +github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs= +github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= +github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= +github.com/sigstore/protobuf-specs v0.5.1 h1:/5OPaNuolRJmQfeZLayJGFXMpsRJEdgC6ah1/+7Px7U= +github.com/sigstore/protobuf-specs v0.5.1/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats= +github.com/sigstore/rekor v1.5.3 h1:0Tyolw3zreRgm7PUW8dccFLXGBThi08278jI8EXNSr4= +github.com/sigstore/rekor v1.5.3/go.mod h1:h3GK5dDqCcWJJZUJwdpKGSSmEV2GEjPUjJy3WTjBwzA= +github.com/sigstore/rekor-tiles/v2 v2.3.0 h1:HhMgH61UP0t899V8Fjt7pz1YdgOBptbaQdnCF+79cdc= +github.com/sigstore/rekor-tiles/v2 v2.3.0/go.mod h1:DEFiKSyQ4nF75QRVNdOPaIH3cmvMkO2B6xDZjNYngPc= +github.com/sigstore/sigstore v1.10.8 h1:1Mgkxvkw4AXMfIP1DOjc6kw0GkUgA8pGVpveN/EfOq4= +github.com/sigstore/sigstore v1.10.8/go.mod h1:f9+B/4iaYimvUkySyb2mvc73n3RLqNn24grHZM/ET8M= +github.com/sigstore/sigstore-go v1.3.0 h1:hnIMHREyCNTYFtOE1o7ae3Axa9B5W5EjUSBJICP2NBE= +github.com/sigstore/sigstore-go v1.3.0/go.mod h1:AyRQXfpH89py1twjE3kEZxlRersng90GSYqQV9zGJE8= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8 h1:tofVQ+UWJgad/69I5zbqxdFCN5gpIn9tRQP7iBzIpBw= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8/go.mod h1:73AfJE8H6w5KGCFPBu4x/OG+i1Yxgmh0L/FtV7prd88= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8 h1:8Mt7J36GcUEmbiJaiFhz2tud5ZIgkfVVCe2H/WJCHmw= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8/go.mod h1:YiTpAsxoWXhF9KlLOVWCh7BckN5cYO8X01WufDq1ido= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8 h1:MxpAIMZVzn0Tpbarc9ax1I498oQBp7oYSMgoMSsOmKI= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8/go.mod h1:bnAUEkFNam6STvkVZhptVwWzWR5pS24CEtQ+lhxu7S0= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8 h1:1DGe4/clcdOnkz5MINEczWlmEvjUtZd+AjPPT/cBhQ8= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8/go.mod h1:6IDFhpgxtzqbnzrFkyegbj7RfWwKeRrb3/+xAD1Wp+Y= +github.com/sigstore/timestamp-authority/v2 v2.1.3 h1:Fc+LjCTfik1lh3YLkaosENfkXa3R2Y1nswiUKutBdFA= +github.com/sigstore/timestamp-authority/v2 v2.1.3/go.mod h1:myoFOKJB/u5vNTFwvBBJVkG3NnOBeIJevbfjNeasLjo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= +github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= +github.com/theupdateframework/go-tuf/v2 v2.4.2 h1:w7976/W8uTwlsegP5nRymlpjPgrwSh+AXUf85is6nJk= +github.com/theupdateframework/go-tuf/v2 v2.4.2/go.mod h1:JqBrIUnNLAaNq/8GmBcEMFWfAFBbqp/MkJEJseXKbks= +github.com/tink-crypto/tink-go-awskms/v3 v3.0.0 h1:XSohRhCkXAVI0iaCnWB/GS05TEmpnKurQmzaY1jzt3Y= +github.com/tink-crypto/tink-go-awskms/v3 v3.0.0/go.mod h1:+7MXsShLzVbSQ6dI0Pe4JuZM52jD1jQ1itAygd/MDsA= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.3.0 h1:3s6YMgMOBZRU8qG6ybpKSF2Sau+y3sMvxR911M59SwA= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.3.0/go.mod h1:X8UNvbQu2wanAGa8ixRUU/DWt1V2hUBfvPGy6s9nE2s= +github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0 h1:eXuNqgrcYelxU1MVikOJDP3wTS5lvihM4ntoAbAMfvs= +github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0/go.mod h1:3RhcxAqek6xUlRFmJifvU4CYLZN60KMQdIKqpZAZJG0= +github.com/tink-crypto/tink-go/v2 v2.7.0 h1:k7QnUXJ1cRDpvoy/5l1FimZqMAArRff8vjUqzi5N04o= +github.com/tink-crypto/tink-go/v2 v2.7.0/go.mod h1:cWNpQ/yAT/QHzAV0kBGMOSJzzYTKofDZdJaUqOPPWCI= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= +github.com/transparency-dev/formats v0.1.1 h1:4bVHJc+KdBgpA1OJD1yjI+g0i5Z1graCppTMH8lWKJI= +github.com/transparency-dev/formats v0.1.1/go.mod h1:qtZ8goRuJ8FTBG9c9+Bj0rn2rUG7eG/AUTkr+Aw3jFw= +github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= +github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= +github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= github.com/zeroshade/machine-id v0.0.0-20251223181436-930511047eef h1:1UOIz6tPkZ6ZBbtbk/ci1apJFAJ3EvQZNh1QVMENjuY= github.com/zeroshade/machine-id v0.0.0-20251223181436-930511047eef/go.mod h1:RHX47A/DYmoTfyT25mb6C+Eve7X5miOporc+RVUoRoY= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.step.sm/crypto v0.77.7 h1:6azC+pD678Vjju8yXnMDHCZJ+HzFaEmL3sCryiezTIA= +go.step.sm/crypto v0.77.7/go.mod h1:OW/2sEHwTtDKq70PvSQ5B0JGy/CrLyDKOiVy3YvZMTQ= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.283.0 h1:0lkp8u0MPwJVHqRL+nJlMAoZVVzbmiXmFHXMOTmSPik= +google.golang.org/api v0.283.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/internal/atomicfile/atomicfile.go b/internal/atomicfile/atomicfile.go new file mode 100644 index 00000000..675afe4e --- /dev/null +++ b/internal/atomicfile/atomicfile.go @@ -0,0 +1,65 @@ +// 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 provides a portable atomic replacement operation for a +// single file in an existing directory. +package atomicfile + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" +) + +// WriteFile writes data to a temporary file next to path, syncs it, and then +// atomically replaces path. If writing or replacement fails, the old file is +// left untouched. +func WriteFile(path string, data []byte, perm fs.FileMode) error { + return writeFile(path, data, perm, replace) +} + +func writeFile(path string, data []byte, perm fs.FileMode, replaceFile func(string, string) error) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".dbc-atomic-*.tmp") + if err != nil { + return fmt.Errorf("create temporary file: %w", err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return fmt.Errorf("set temporary file permissions: %w", err) + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write temporary file: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("sync temporary file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temporary file: %w", err) + } + if err := replaceFile(tmpPath, path); err != nil { + return fmt.Errorf("replace destination: %w", err) + } + if dirFile, err := os.Open(dir); err == nil { + _ = dirFile.Sync() + _ = dirFile.Close() + } + return nil +} diff --git a/internal/atomicfile/atomicfile_test.go b/internal/atomicfile/atomicfile_test.go new file mode 100644 index 00000000..51319bde --- /dev/null +++ b/internal/atomicfile/atomicfile_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. + +package atomicfile + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWriteFileReplacesExistingFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + require.NoError(t, os.WriteFile(path, []byte("old"), 0o600)) + require.NoError(t, WriteFile(path, []byte("new"), 0o600)) + + actual, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, "new", string(actual)) +} + +func TestWriteFileLeavesOldFileWhenReplacementFails(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + require.NoError(t, os.WriteFile(path, []byte("old"), 0o600)) + errInjected := errors.New("injected replace failure") + err := writeFile(path, []byte("new"), 0o600, func(string, string) error { return errInjected }) + require.ErrorIs(t, err, errInjected) + + actual, readErr := os.ReadFile(path) + require.NoError(t, readErr) + require.Equal(t, "old", string(actual)) +} diff --git a/internal/atomicfile/replace_unix.go b/internal/atomicfile/replace_unix.go new file mode 100644 index 00000000..df1f363e --- /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(oldPath, newPath string) error { + return os.Rename(oldPath, newPath) +} diff --git a/internal/atomicfile/replace_windows.go b/internal/atomicfile/replace_windows.go new file mode 100644 index 00000000..2ba7c38d --- /dev/null +++ b/internal/atomicfile/replace_windows.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 "golang.org/x/sys/windows" + +func replace(oldPath, newPath string) error { + return windows.Rename(oldPath, newPath) +} diff --git a/internal/fslock/fslock.go b/internal/fslock/fslock.go index 4684a6bb..7158c152 100644 --- a/internal/fslock/fslock.go +++ b/internal/fslock/fslock.go @@ -17,8 +17,11 @@ package fslock import ( + "context" "errors" + "fmt" "os" + "time" ) // Lock represents an acquired advisory file lock. @@ -28,3 +31,56 @@ type Lock struct { } var ErrLockContended = errors.New("lock is held by another process") + +const retryInterval = 50 * time.Millisecond + +// 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) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + // The original timeout-based API always tried the lock once before + // checking whether its deadline had elapsed. Preserve that behavior for + // zero and negative timeouts. + lock, err := acquireContext(ctx, path, true) + if errors.Is(err, context.DeadlineExceeded) { + return Lock{}, fmt.Errorf("fslock: could not acquire lock on %s within %s (%v): %w", + path, timeout, err, ErrLockContended) + } + return lock, err +} + +// AcquireContext acquires an exclusive advisory lock on the file at path, +// retrying until the lock is acquired or ctx is canceled. If ctx is already +// canceled, it returns immediately without opening or creating the lock file. +func AcquireContext(ctx context.Context, path string) (Lock, error) { + if err := ctx.Err(); err != nil { + return Lock{}, err + } + return acquireContext(ctx, path, false) +} + +// retryContextError retains both the cancellation cause and the last lock +// syscall error for callers that need to diagnose a failed wait. +func retryContextError(path string, ctxErr, lockErr error) error { + if lockErr == nil { + return ctxErr + } + return fmt.Errorf("fslock: could not acquire lock on %s: %w (last lock attempt: %w)", + path, ctxErr, lockErr) +} + +// waitForRetry pauses between non-blocking lock attempts while remaining +// responsive to cancellation and deadlines. +func waitForRetry(ctx context.Context) error { + timer := time.NewTimer(retryInterval) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/internal/fslock/fslock_test.go b/internal/fslock/fslock_test.go index 52d52207..648fe6f4 100644 --- a/internal/fslock/fslock_test.go +++ b/internal/fslock/fslock_test.go @@ -15,9 +15,11 @@ package fslock_test import ( + "context" "errors" "os" "path/filepath" + "strings" "sync" "testing" "time" @@ -121,6 +123,139 @@ func TestAcquireTimeout(t *testing.T) { if !errors.Is(err, fslock.ErrLockContended) { t.Fatalf("timeout error must wrap ErrLockContended, got: %v", err) } + if !strings.Contains(err.Error(), "last lock attempt:") { + t.Fatalf("timeout error must include the last OS lock error, got: %v", err) + } +} + +func TestAcquireContextCanceledWhileContended(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.lock") + held, err := fslock.Acquire(path, 5*time.Second) + if err != nil { + t.Fatalf("Acquire: %v", err) + } + defer held.Release() + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + _, err := fslock.AcquireContext(ctx, path) + done <- err + }() + + start := time.Now() + time.Sleep(20 * time.Millisecond) + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("AcquireContext error = %v, want context.Canceled", err) + } + if !strings.Contains(err.Error(), "last lock attempt:") { + t.Fatalf("cancellation error must include the last OS lock error, got: %v", err) + } + if elapsed := time.Since(start); elapsed > 300*time.Millisecond { + t.Fatalf("AcquireContext took %s to observe cancellation", elapsed) + } + case <-time.After(time.Second): + t.Fatal("AcquireContext did not return after cancellation") + } +} + +func TestAcquireContextPreCanceledDoesNotCreateLockFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.lock") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := fslock.AcquireContext(ctx, path) + if !errors.Is(err, context.Canceled) { + t.Fatalf("AcquireContext error = %v, want context.Canceled", err) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("pre-canceled AcquireContext left a lock file: stat error = %v", err) + } +} + +func TestAcquireContextPreDeadlineDoesNotCreateLockFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.lock") + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + + _, err := fslock.AcquireContext(ctx, path) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("AcquireContext error = %v, want context.DeadlineExceeded", err) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expired AcquireContext left a lock file: stat error = %v", err) + } +} + +func TestAcquireZeroTimeoutStillTriesOnce(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.lock") + lock, err := fslock.Acquire(path, 0) + if err != nil { + t.Fatalf("Acquire with zero timeout: %v", err) + } + if err := lock.Release(); err != nil { + t.Fatalf("Release: %v", err) + } +} + +func TestAcquireContextDeadlineWhileContended(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.lock") + held, err := fslock.Acquire(path, 5*time.Second) + if err != nil { + t.Fatalf("Acquire: %v", err) + } + defer held.Release() + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + _, err = fslock.AcquireContext(ctx, path) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("AcquireContext error = %v, want context.DeadlineExceeded", err) + } + if !strings.Contains(err.Error(), "last lock attempt:") { + t.Fatalf("deadline error must include the last OS lock error, got: %v", err) + } +} + +func TestAcquireContextAfterRelease(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.lock") + held, err := fslock.Acquire(path, 5*time.Second) + if err != nil { + t.Fatalf("Acquire: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + done := make(chan struct { + lock fslock.Lock + err error + }, 1) + go func() { + lock, err := fslock.AcquireContext(ctx, path) + done <- struct { + lock fslock.Lock + err error + }{lock: lock, err: err} + }() + + time.Sleep(20 * time.Millisecond) + if err := held.Release(); err != nil { + t.Fatalf("Release: %v", err) + } + select { + case result := <-done: + if result.err != nil { + t.Fatalf("AcquireContext: %v", result.err) + } + if err := result.lock.Release(); err != nil { + t.Fatalf("context lock Release: %v", err) + } + case <-time.After(time.Second): + t.Fatal("AcquireContext did not acquire after the prior lock was released") + } } func TestAcquireUnwritableDirIsNotContention(t *testing.T) { diff --git a/internal/fslock/fslock_unix.go b/internal/fslock/fslock_unix.go index 5c089841..441af03d 100644 --- a/internal/fslock/fslock_unix.go +++ b/internal/fslock/fslock_unix.go @@ -17,37 +17,39 @@ package fslock import ( + "context" "errors" "fmt" "os" "syscall" - "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) { - deadline := time.Now().Add(timeout) +func acquireContext(ctx context.Context, path string, allowInitialAttempt bool) (Lock, error) { for { + if !allowInitialAttempt { + 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) } - lock, err := lockFile(f, path, deadline) + lock, err := lockFile(ctx, f, path, allowInitialAttempt) + allowInitialAttempt = false 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. - // Reopen and try again within the remaining budget. - continue + if ctx.Err() != nil { + return Lock{}, ctx.Err() } - return Lock{}, fmt.Errorf("fslock: could not acquire lock on %s within %s (%v): %w", - path, timeout, err, ErrLockContended) + // Previous holder unlinked the file between our open and our + // flock, so reopen and confirm the current inode. + continue } return Lock{}, err } @@ -57,8 +59,17 @@ 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, allowInitialAttempt bool) (Lock, error) { + firstAttempt := true + var lastLockErr error for { + if !(firstAttempt && allowInitialAttempt) { + if err := ctx.Err(); err != nil { + return Lock{}, retryContextError(path, err, lastLockErr) + } + } + firstAttempt = false + 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 @@ -73,11 +84,10 @@ func lockFile(f *os.File, path string, deadline time.Time) (Lock, error) { } return Lock{f: f, path: path}, nil } - if time.Now().After(deadline) { - return Lock{}, fmt.Errorf("fslock: could not acquire lock on %s (%v): %w", - path, err, ErrLockContended) + lastLockErr = err + if err := waitForRetry(ctx); err != nil { + return Lock{}, retryContextError(path, err, lastLockErr) } - time.Sleep(50 * time.Millisecond) } } diff --git a/internal/fslock/fslock_windows.go b/internal/fslock/fslock_windows.go index d8d504dd..925c3189 100644 --- a/internal/fslock/fslock_windows.go +++ b/internal/fslock/fslock_windows.go @@ -17,37 +17,43 @@ package fslock import ( + "context" "errors" "fmt" "os" - "time" "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, allowInitialAttempt bool) (Lock, error) { 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) } ol := new(windows.Overlapped) - deadline := time.Now().Add(timeout) + firstAttempt := true + var lastLockErr error for { + if !(firstAttempt && allowInitialAttempt) { + if err := ctx.Err(); err != nil { + f.Close() + return Lock{}, retryContextError(path, err, lastLockErr) + } + } + firstAttempt = false + 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) { + lastLockErr = err + if err := waitForRetry(ctx); err != nil { f.Close() - return Lock{}, fmt.Errorf("fslock: could not acquire lock on %s within %s (%v): %w", - path, timeout, err, ErrLockContended) + return Lock{}, retryContextError(path, err, lastLockErr) } - time.Sleep(50 * time.Millisecond) } } diff --git a/internal/jsonschema/schema.go b/internal/jsonschema/schema.go index 22e1737d..3d777461 100644 --- a/internal/jsonschema/schema.go +++ b/internal/jsonschema/schema.go @@ -57,6 +57,14 @@ type InstallStatus struct { Conflict string `json:"conflict,omitempty"` // Checksum is the hex-encoded checksum of the installed artifact (added for T7). Checksum string `json:"checksum,omitempty"` + // Source identifies the resolved release source when available. + Source *InstallSource `json:"source,omitempty"` +} + +// InstallSource is the source provenance of a resolved package release. +type InstallSource struct { + Type string `json:"type"` + Reference string `json:"reference"` } // InstallProgressEvent is a single line in the NDJSON progress stream emitted @@ -257,6 +265,13 @@ type SyncError struct { Error string `json:"error"` } +// SyncMigration records a lockfile schema migration completed by sync. +// It is omitted when the input lockfile was already at the current version. +type SyncMigration struct { + FromVersion int `json:"from_version"` + ToVersion int `json:"to_version"` +} + // SyncStatus is the final JSON payload emitted after a sync operation completes. type SyncStatus struct { // Installed lists drivers that were newly installed. @@ -265,6 +280,8 @@ type SyncStatus struct { Skipped []SyncedDriver `json:"skipped"` // Errors lists drivers that failed to install. Errors []SyncError `json:"errors"` + // Migration describes a lockfile schema migration, when one was persisted. + Migration *SyncMigration `json:"migration,omitempty"` } // ----------------------------------------------------------------------------- diff --git a/internal/jsonschema/schema_test.go b/internal/jsonschema/schema_test.go index 7c2d3087..1ca33f18 100644 --- a/internal/jsonschema/schema_test.go +++ b/internal/jsonschema/schema_test.go @@ -16,6 +16,7 @@ package jsonschema_test import ( "encoding/json" + "reflect" "testing" "github.com/columnar-tech/dbc/internal/jsonschema" @@ -86,6 +87,7 @@ func TestInstallStatus(t *testing.T) { Message: "post-install note", Conflict: "snowflake (version: 1.0.0)", Checksum: "abc123", + Source: &jsonschema.InstallSource{Type: "packslip", Reference: "github.com/example/driver"}, }, }, { @@ -110,7 +112,7 @@ func TestInstallStatus(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := roundTrip(t, tc.in) - if got != tc.in { + if !reflect.DeepEqual(got, tc.in) { t.Errorf("round-trip mismatch:\n want %+v\n got %+v", tc.in, got) } }) @@ -122,7 +124,7 @@ func TestInstallStatus_OmitemptyAbsent(t *testing.T) { b, _ := json.Marshal(v) var m map[string]interface{} _ = json.Unmarshal(b, &m) - for _, key := range []string{"message", "conflict", "checksum"} { + for _, key := range []string{"message", "conflict", "checksum", "source"} { if _, ok := m[key]; ok { t.Errorf("omitempty field %q should be absent", key) } @@ -420,6 +422,7 @@ func TestSyncStatus(t *testing.T) { Installed: []jsonschema.SyncedDriver{{Name: "snowflake", Version: "1.0.0"}}, Skipped: []jsonschema.SyncedDriver{{Name: "duckdb", Version: "2.0.0"}}, Errors: []jsonschema.SyncError{{Name: "sqlite", Error: "not found"}}, + Migration: &jsonschema.SyncMigration{FromVersion: 1, ToVersion: 2}, } b, _ := json.Marshal(v) var got jsonschema.SyncStatus @@ -435,6 +438,9 @@ func TestSyncStatus(t *testing.T) { if len(got.Errors) != 1 || got.Errors[0] != v.Errors[0] { t.Errorf("Errors mismatch") } + if *got.Migration != *v.Migration { + t.Errorf("Migration mismatch: want %+v got %+v", v.Migration, got.Migration) + } } func TestSyncStatus_EmptySlices(t *testing.T) { @@ -453,6 +459,25 @@ func TestSyncStatus_EmptySlices(t *testing.T) { } } +func TestSyncStatus_MigrationOmitempty(t *testing.T) { + v := jsonschema.SyncStatus{ + Installed: []jsonschema.SyncedDriver{}, + Skipped: []jsonschema.SyncedDriver{}, + Errors: []jsonschema.SyncError{}, + } + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(b, &fields); err != nil { + t.Fatal(err) + } + if _, ok := fields["migration"]; ok { + t.Fatal("migration should be absent when no migration occurred") + } +} + func TestAuthDeviceCodeEvent(t *testing.T) { v := jsonschema.AuthDeviceCodeEvent{ VerificationURI: "https://auth.example.com/activate", diff --git a/internal/packslip/conformance_test.go b/internal/packslip/conformance_test.go new file mode 100644 index 00000000..a30e0879 --- /dev/null +++ b/internal/packslip/conformance_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 packslip + +import ( + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +type conformanceCases struct { + Cases []json.RawMessage `json:"cases"` +} + +func loadConformanceCases(t *testing.T, name string) []json.RawMessage { + t.Helper() + data, err := os.ReadFile("testdata/conformance/" + name) + require.NoError(t, err) + var fixture conformanceCases + require.NoError(t, json.Unmarshal(data, &fixture)) + require.NotEmpty(t, fixture.Cases, "%s must contain cases", name) + return fixture.Cases +} + +func decodeConformanceCase[T any](t *testing.T, raw json.RawMessage, index int) (string, T) { + t.Helper() + var item struct { + Name string `json:"name"` + } + require.NoError(t, json.Unmarshal(raw, &item), "case %d must be an object", index) + require.NotEmpty(t, item.Name, "case %d must have a name", index) + var value T + require.NoError(t, json.Unmarshal(raw, &value), "%s", item.Name) + return item.Name, value +} + +func TestPackslipArtifactSelectionConformance(t *testing.T) { + type host struct { + OS string `json:"os"` + Arch string `json:"arch"` + LibC json.RawMessage `json:"libc"` + } + type expectation struct { + Artifact *string `json:"artifact"` + Error *string `json:"error"` + } + type testCase struct { + Host *host `json:"host"` + Variant json.RawMessage `json:"variant"` + Formats []string `json:"formats"` + Artifacts []releaseArtifact `json:"artifacts"` + Expect expectation `json:"expect"` + } + + for index, raw := range loadConformanceCases(t, "artifact-selection.json") { + name, test := decodeConformanceCase[testCase](t, raw, index) + t.Run(name, func(t *testing.T) { + require.NotNil(t, test.Host, "host is required") + require.NotEmpty(t, test.Host.OS, "host.os is required") + require.NotEmpty(t, test.Host.Arch, "host.arch is required") + require.NotEmpty(t, test.Host.LibC, "host.libc is required (use null when unknown)") + require.NotEmpty(t, test.Variant, "variant is required (use null for the ordinary variant)") + require.NotEmpty(t, test.Formats, "formats is required") + require.NotEmpty(t, test.Artifacts, "artifacts is required") + require.True(t, test.Expect.Artifact != nil || test.Expect.Error != nil, "expect must specify an artifact or error") + require.False(t, test.Expect.Artifact != nil && test.Expect.Error != nil, "expect cannot specify both artifact and error") + target := canonicalizeTargetAliases(Target{OS: test.Host.OS, Arch: test.Host.Arch}) + var libc, variant *string + require.NoError(t, json.Unmarshal(test.Host.LibC, &libc), "host.libc must be a string or null") + require.NoError(t, json.Unmarshal(test.Variant, &variant), "variant must be a string or null") + if libc != nil { + target.LibC = *libc + } + target.Variant = stringValue(variant) + for i := range test.Artifacts { + require.NotEmpty(t, test.Artifacts[i].Name, "artifact name is required") + require.NotNil(t, test.Artifacts[i].Format, "artifact format is required") + canonicalizeArtifactAliases(&test.Artifacts[i]) + } + release := &parsedRelease{predicate: releasePredicate{Artifacts: test.Artifacts}} + selected, err := selectArtifactByFormats(release, target, test.Formats) + if test.Expect.Artifact != nil { + require.NoError(t, err) + require.Equal(t, *test.Expect.Artifact, selected.Name) + return + } + switch *test.Expect.Error { + case "no-match": + require.ErrorIs(t, err, ErrReleaseNotFound) + case "ambiguous": + require.ErrorIs(t, err, ErrAmbiguousArtifact) + default: + t.Fatalf("unknown expected selection error %q", *test.Expect.Error) + } + }) + } +} + +func TestPackslipStatementValidityConformance(t *testing.T) { + type testCase struct { + Expect string `json:"expect"` + Statement json.RawMessage `json:"statement"` + } + for index, raw := range loadConformanceCases(t, "statement-validity.json") { + name, test := decodeConformanceCase[testCase](t, raw, index) + t.Run(name, func(t *testing.T) { + require.NotEmpty(t, test.Expect, "expect is required") + require.NotEmpty(t, test.Statement, "statement is required") + parsed, err := parseRelease(test.Statement, "") + switch test.Expect { + case "accept": + require.NoError(t, err) + require.NotNil(t, parsed) + case "reject": + require.Error(t, err) + default: + t.Fatalf("unknown expected statement outcome %q", test.Expect) + } + }) + } +} + +func TestPackslipTagVersionConformance(t *testing.T) { + type testCase struct { + Tag string `json:"tag"` + Project string `json:"project"` + Expect json.RawMessage `json:"expect"` + } + for index, raw := range loadConformanceCases(t, "tag-versions.json") { + name, test := decodeConformanceCase[testCase](t, raw, index) + t.Run(name, func(t *testing.T) { + require.NotEmpty(t, test.Tag, "tag is required") + require.NotEmpty(t, test.Project, "project is required") + require.NotEmpty(t, test.Expect, "expect is required") + version, ok := tagVersion(test.Tag, test.Project) + if string(test.Expect) == "null" { + require.False(t, ok) + return + } + var expected string + require.NoError(t, json.Unmarshal(test.Expect, &expected)) + require.True(t, ok, "expected version %s", expected) + require.Equal(t, expected, version) + }) + } +} diff --git a/internal/packslip/dbc_extension.go b/internal/packslip/dbc_extension.go new file mode 100644 index 00000000..bb46e34b --- /dev/null +++ b/internal/packslip/dbc_extension.go @@ -0,0 +1,114 @@ +// 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 + +package packslip + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strings" +) + +type dbcReleaseExtension struct { + SchemaVersion int `json:"schema_version"` + DriverID string `json:"driver_id"` +} + +// validateDBCReleaseExtensions applies dbc's consumer contract to a verified +// Packslip release. Other Packslip extensions remain owned by their consumers. +func validateDBCReleaseExtensions(release *parsedRelease) error { + if release == nil { + return errors.New("packslip release is nil") + } + raw, ok := release.predicate.Extensions["dbc"] + if !ok { + return errors.New("packslip release is missing predicate.extensions.dbc") + } + var declaration dbcReleaseExtension + if err := decodeDBCObject(raw, &declaration); err != nil { + return fmt.Errorf("invalid packslip predicate.extensions.dbc declaration: %w", err) + } + if declaration.SchemaVersion != 1 { + return fmt.Errorf("unsupported dbc release extension schema_version %d", declaration.SchemaVersion) + } + if err := validateRuntimeDriverID(declaration.DriverID); err != nil { + return fmt.Errorf("invalid dbc release driver_id %q: %w", declaration.DriverID, err) + } + release.driverID = declaration.DriverID + + for i := range release.predicate.Artifacts { + artifact := &release.predicate.Artifacts[i] + raw, ok := artifact.Extensions["dbc"] + if !ok { + continue + } + if err := validateDBCArtifactMarker(raw); err != nil { + return fmt.Errorf("invalid packslip artifact %q extensions.dbc declaration: %w", artifact.Name, err) + } + artifact.dbcArtifact = true + } + return nil +} + +func decodeDBCObject(raw json.RawMessage, destination any) error { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || trimmed[0] != '{' { + return errors.New("must be a non-null object") + } + if err := decodeWire(trimmed, destination); err != nil { + return err + } + return nil +} + +func validateDBCArtifactMarker(raw json.RawMessage) error { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || trimmed[0] != '{' { + return errors.New("must be a non-null object") + } + var marker map[string]json.RawMessage + if err := decodeWire(trimmed, &marker); err != nil { + return err + } + if marker == nil { + return errors.New("must be a non-null object") + } + return nil +} + +func validateRuntimeDriverID(id string) error { + if id == "" || id == "." || id == ".." { + return errors.New("ID must be a non-empty flat file name") + } + if strings.ContainsAny(id, "/\\\x00:") { + return errors.New("absolute paths and path separators are not allowed") + } + if strings.TrimSpace(id) != id || strings.HasSuffix(id, ".") { + return errors.New("trailing spaces and dots are not allowed") + } + for _, char := range id { + if char < 0x20 || strings.ContainsRune(`<>"|?*`, char) { + return errors.New("ID contains a character unsupported by Windows") + } + } + deviceName := strings.ToUpper(strings.TrimRight(strings.SplitN(id, ".", 2)[0], " .")) + switch deviceName { + case "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$": + return errors.New("ID is reserved by Windows") + } + for _, prefix := range []string{"COM", "LPT"} { + for _, suffix := range []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "¹", "²", "³"} { + if strings.EqualFold(deviceName, prefix+suffix) { + return errors.New("ID is reserved by Windows") + } + } + } + return nil +} diff --git a/internal/packslip/dbc_extension_test.go b/internal/packslip/dbc_extension_test.go new file mode 100644 index 00000000..1ad0f446 --- /dev/null +++ b/internal/packslip/dbc_extension_test.go @@ -0,0 +1,134 @@ +// 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 + +package packslip + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDBCReleaseExtensionValidatesRequiredFieldsAndIgnoresAdditiveFields(t *testing.T) { + tests := []struct { + name string + raw json.RawMessage + missing bool + want string + }{ + {name: "missing", missing: true, want: "missing predicate.extensions.dbc"}, + {name: "null", raw: json.RawMessage("null"), want: "non-null object"}, + {name: "malformed", raw: json.RawMessage("{"), want: "invalid packslip predicate.extensions.dbc declaration"}, + {name: "array", raw: json.RawMessage("[]"), want: "non-null object"}, + {name: "invalid schema type", raw: json.RawMessage(`{"schema_version":"1","driver_id":"driver"}`), want: "cannot unmarshal"}, + {name: "unknown schema", raw: json.RawMessage(`{"schema_version":2,"driver_id":"driver"}`), want: "unsupported dbc release extension schema_version"}, + {name: "missing schema", raw: json.RawMessage(`{"driver_id":"driver"}`), want: "schema_version 0"}, + {name: "missing driver ID", raw: json.RawMessage(`{"schema_version":1}`), want: "invalid dbc release driver_id"}, + {name: "invalid driver ID type", raw: json.RawMessage(`{"schema_version":1,"driver_id":7}`), want: "cannot unmarshal"}, + {name: "invalid id", raw: json.RawMessage(`{"schema_version":1,"driver_id":"../driver"}`), want: "invalid dbc release driver_id"}, + {name: "reserved id", raw: json.RawMessage(`{"schema_version":1,"driver_id":"CON"}`), want: "reserved by Windows"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + release, err := parseRelease(validRelease("1.0.0"), testProject) + require.NoError(t, err) + if test.missing { + release.predicate.Extensions = nil + } else { + release.predicate.Extensions["dbc"] = test.raw + } + err = validateDBCReleaseExtensions(release) + require.ErrorContains(t, err, test.want) + }) + } + + release, err := parseRelease(validRelease("1.0.0"), testProject) + require.NoError(t, err) + require.NoError(t, validateDBCReleaseExtensions(release)) + require.Equal(t, "driver", release.driverID) + release.predicate.Extensions["dbc"] = json.RawMessage(`{"schema_version":1,"driver_id":"driver","homepage":"https://example.com","future_hint":true}`) + require.NoError(t, validateDBCReleaseExtensions(release), "supported declarations ignore additive fields") +} + +func TestDBCArtifactExtensionIsExtensibleMembershipMarker(t *testing.T) { + tests := []struct { + name string + raw json.RawMessage + want string + }{ + {name: "null", raw: json.RawMessage("null"), want: "non-null object"}, + {name: "malformed", raw: json.RawMessage("{"), want: "invalid packslip artifact"}, + {name: "array", raw: json.RawMessage("[]"), want: "non-null object"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + release, err := parseRelease(validRelease("1.0.0"), testProject) + require.NoError(t, err) + release.predicate.Artifacts[0].Extensions["dbc"] = test.raw + err = validateDBCReleaseExtensions(release) + require.ErrorContains(t, err, test.want) + }) + } + + release, err := parseRelease(validRelease("1.0.0"), testProject) + require.NoError(t, err) + require.NoError(t, validateDBCReleaseExtensions(release)) + require.True(t, release.predicate.Artifacts[0].dbcArtifact) + release.predicate.Artifacts[0].Extensions["dbc"] = json.RawMessage(`{"future_optional_metadata":"x","future_annotation":2}`) + require.NoError(t, validateDBCReleaseExtensions(release), "all artifact dbc object keys are additive metadata") + require.True(t, release.predicate.Artifacts[0].dbcArtifact) + release.predicate.Artifacts[0].Extensions["dbc"] = json.RawMessage(`true`) + require.ErrorContains(t, validateDBCReleaseExtensions(release), "non-null object") +} + +func TestDBCArtifactEligibilityExcludesUnrelatedArchivesFromTargetsAndAmbiguity(t *testing.T) { + format := "tar.gz" + selected := releaseArtifact{ + Name: "dbc-linux.tar.gz", OS: ptr("linux"), Arch: ptr("amd64"), LibC: ptr("gnu"), + Size: ptr(uint64(10)), Format: &format, URL: ptr("https://dl.example/dbc-linux.tar.gz"), + Extensions: map[string]json.RawMessage{"dbc": json.RawMessage(`{}`)}, + } + other := selected + other.Name = "unrelated-windows.tar.gz" + other.OS = ptr("windows") + other.URL = ptr("https://dl.example/unrelated-windows.tar.gz") + other.Extensions = map[string]json.RawMessage{"other-consumer": json.RawMessage(`true`)} + unsupported := selected + unsupported.Name = "dbc-linux.zip" + unsupported.Format = ptr("zip") + unsupported.URL = ptr("https://dl.example/dbc-linux.zip") + + release, err := parseRelease(validRelease("1.0.0", selected, other, unsupported), testProject) + require.NoError(t, err) + require.NoError(t, validateDBCReleaseExtensions(release)) + require.NoError(t, validateSupportedArtifactSet(release)) + targets, err := deriveConcreteTargets(release) + require.NoError(t, err) + require.Equal(t, []Target{{OS: "linux", Arch: "amd64", LibC: "gnu"}}, targets, + "only dbc-declared supported archives seed concrete targets") + chosen, err := selectArtifact(release, targets[0]) + require.NoError(t, err, "unrelated archive must not create a selection tie") + require.Equal(t, "dbc-linux.tar.gz", chosen.Name) + _, err = selectArtifact(release, Target{OS: "windows", Arch: "amd64"}) + require.ErrorIs(t, err, ErrReleaseNotFound, "non-dbc artifacts must not be selected") +} + +func TestDBCReleaseRequiresAtLeastOneSupportedDeclaredPackage(t *testing.T) { + format := "tar.gz" + nonDBC := releaseArtifact{ + Name: "linux.tar.gz", OS: ptr("linux"), Arch: ptr("amd64"), Size: ptr(uint64(1)), Format: &format, URL: ptr("https://dl.example/linux.tar.gz"), + Extensions: map[string]json.RawMessage{"other-consumer": json.RawMessage(`true`)}, + } + release, err := parseRelease(validRelease("1.0.0", nonDBC), testProject) + require.NoError(t, err) + require.NoError(t, validateDBCReleaseExtensions(release)) + require.ErrorContains(t, validateSupportedArtifactSet(release), "no dbc artifact") + _, err = deriveConcreteTargets(release) + require.ErrorContains(t, err, "no supported artifact") +} diff --git a/internal/packslip/github.go b/internal/packslip/github.go new file mode 100644 index 00000000..aa30f9e3 --- /dev/null +++ b/internal/packslip/github.go @@ -0,0 +1,291 @@ +// 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 packslip + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/columnar-tech/dbc/internal/sourceidentity" +) + +const ( + defaultGitHubAPIBase = "https://api.github.com" + defaultGitHubRawBase = "https://raw.githubusercontent.com" + maxGitHubPageCount = 100 + maxMetadataBytes = 32 << 20 +) + +var errHTTPNotFound = errors.New("HTTP resource not found") + +// GitHubPackslipDiscovery discovers release assets and the optional +// supplementary signed release list for a GitHub project. +type GitHubPackslipDiscovery struct { + client *http.Client + apiBase *url.URL + rawBase *url.URL +} + +// NewGitHubPackslipDiscovery creates a discovery transport. Empty endpoint +// URLs select the public GitHub API and raw content host. +func NewGitHubPackslipDiscovery(client *http.Client, apiBaseURL, rawBaseURL string) (*GitHubPackslipDiscovery, error) { + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + if apiBaseURL == "" { + apiBaseURL = defaultGitHubAPIBase + } + if rawBaseURL == "" { + rawBaseURL = defaultGitHubRawBase + } + apiBase, err := parseBaseURL(apiBaseURL) + if err != nil { + return nil, fmt.Errorf("GitHub API base URL: %w", err) + } + rawBase, err := parseBaseURL(rawBaseURL) + if err != nil { + return nil, fmt.Errorf("GitHub raw-content base URL: %w", err) + } + return &GitHubPackslipDiscovery{client: client, apiBase: apiBase, rawBase: rawBase}, nil +} + +func parseBaseURL(value string) (*url.URL, error) { + parsed, err := url.Parse(value) + if err != nil { + return nil, err + } + if (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, fmt.Errorf("expected an absolute HTTP(S) URL without credentials, query, or fragment") + } + if parsed.Scheme == "http" && !isLoopbackHost(parsed.Hostname()) { + return nil, fmt.Errorf("non-HTTPS endpoint is allowed only for loopback tests") + } + parsed.Path = strings.TrimSuffix(parsed.Path, "/") + return parsed, nil +} + +func (d *GitHubPackslipDiscovery) ListReleases(ctx context.Context, source PackslipSource) ([]githubRelease, error) { + projectKey, err := sourceidentity.Parse(sourceidentity.Packslip, source.Project) + if err != nil { + return nil, err + } + project := projectKey.Reference + owner, repo, _ := projectParts(project) + result := make([]githubRelease, 0) + seenTags := map[string]bool{} + for page := 1; page <= maxGitHubPageCount; page++ { + endpoint := *d.apiBase + endpoint.Path = strings.TrimSuffix(endpoint.Path, "/") + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/releases" + query := endpoint.Query() + query.Set("per_page", "100") + query.Set("page", strconv.Itoa(page)) + endpoint.RawQuery = query.Encode() + body, _, err := d.get(ctx, endpoint.String(), true) + if err != nil { + return nil, fmt.Errorf("list GitHub releases for %s: %w", project, err) + } + var releases []githubRelease + if err := json.Unmarshal(body, &releases); err != nil { + return nil, fmt.Errorf("decode GitHub releases for %s: %w", project, err) + } + if len(releases) == 0 { + return result, nil + } + for _, release := range releases { + if release.Draft { + continue + } + if release.TagName == "" { + return nil, fmt.Errorf("GitHub release for %s has an empty tag", project) + } + if seenTags[release.TagName] { + return nil, fmt.Errorf("GitHub returned duplicate release tag %q", release.TagName) + } + seenTags[release.TagName] = true + result = append(result, release) + } + if len(releases) < 100 { + return result, nil + } + } + return nil, fmt.Errorf("GitHub release listing exceeded %d pages", maxGitHubPageCount) +} + +func (d *GitHubPackslipDiscovery) ReleaseListURL(source PackslipSource) (string, error) { + projectKey, err := sourceidentity.Parse(sourceidentity.Packslip, source.Project) + if err != nil { + return "", err + } + project := projectKey.Reference + owner, repo, tool := projectParts(project) + u := *d.rawBase + path := []string{owner, repo, "HEAD", ".well-known"} + if len(tool) == 0 { + path = append(path, "packslip.json") + } else { + path = append(path, "packslip") + path = append(path, tool[:len(tool)-1]...) + path = append(path, tool[len(tool)-1]+".json") + } + encoded := make([]string, len(path)) + for i, part := range path { + encoded[i] = url.PathEscape(part) + } + u.Path = strings.TrimSuffix(u.Path, "/") + "/" + strings.Join(encoded, "/") + return u.String(), nil +} + +func (d *GitHubPackslipDiscovery) get(ctx context.Context, resourceURL string, githubAPI bool) ([]byte, string, error) { + initialURL, err := url.Parse(resourceURL) + if err != nil { + return nil, "", err + } + base := d.rawBase + if githubAPI { + base = d.apiBase + } + var loopbackHTTPEndpoint *url.URL + if strings.EqualFold(base.Scheme, "http") && isLoopbackHost(base.Hostname()) && sameAuthority(initialURL, base) { + loopbackHTTPEndpoint = initialURL + } + if err := validateMetadataURL(initialURL, loopbackHTTPEndpoint); err != nil { + return nil, "", err + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil) + if err != nil { + return nil, "", err + } + request.Header.Set("User-Agent", "dbc-packslip/0.4.0") + if githubAPI { + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("X-GitHub-Api-Version", "2022-11-28") + } + client := *d.client + checkRedirect := d.client.CheckRedirect + client.CheckRedirect = func(next *http.Request, via []*http.Request) error { + if err := validateRedirectChain(next, via, loopbackHTTPEndpoint); err != nil { + return err + } + if checkRedirect != nil { + if err := checkRedirect(next, via); err != nil { + return err + } + // Caller policies may adjust the next request, so validate the full + // chain again before net/http follows it. + return validateRedirectChain(next, via, loopbackHTTPEndpoint) + } + if len(via) >= 10 { + return errors.New("stopped after 10 metadata redirects") + } + return nil + } + response, err := client.Do(request) + if err != nil { + return nil, "", err + } + defer response.Body.Close() + finalURL := resourceURL + if response.Request != nil && response.Request.URL != nil { + finalURL = response.Request.URL.String() + if err := validateMetadataURL(response.Request.URL, loopbackHTTPEndpoint); err != nil { + return nil, finalURL, err + } + } + if response.StatusCode == http.StatusNotFound { + return nil, finalURL, errHTTPNotFound + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, finalURL, fmt.Errorf("HTTP %s", response.Status) + } + body, err := io.ReadAll(io.LimitReader(response.Body, maxMetadataBytes+1)) + if err != nil { + return nil, finalURL, err + } + if len(body) > maxMetadataBytes { + return nil, finalURL, fmt.Errorf("metadata response exceeds %d bytes", maxMetadataBytes) + } + return body, finalURL, nil +} + +func validateRedirectChain(next *http.Request, via []*http.Request, loopbackHTTPEndpoint *url.URL) error { + if next == nil || next.URL == nil || len(via) == 0 { + return errors.New("metadata redirect has no valid request chain") + } + var previousURL *url.URL + for _, previous := range via { + if previous == nil || previous.URL == nil { + return errors.New("metadata redirect has an invalid previous request") + } + if err := validateMetadataURL(previous.URL, loopbackHTTPEndpoint); err != nil { + return fmt.Errorf("metadata redirect chain rejected: %w", err) + } + if previousURL != nil && strings.EqualFold(previousURL.Scheme, "https") && strings.EqualFold(previous.URL.Scheme, "http") { + return errors.New("metadata redirect chain attempted an HTTPS-to-HTTP downgrade") + } + previousURL = previous.URL + } + initialURL := via[0].URL + if strings.EqualFold(initialURL.Scheme, "http") && loopbackHTTPEndpoint == nil { + return errors.New("HTTP metadata is allowed only for a configured loopback test endpoint") + } + if strings.EqualFold(previousURL.Scheme, "https") && strings.EqualFold(next.URL.Scheme, "http") { + return errors.New("metadata redirect attempted an HTTPS-to-HTTP downgrade") + } + if err := validateMetadataURL(next.URL, loopbackHTTPEndpoint); err != nil { + return fmt.Errorf("metadata redirect rejected: %w", err) + } + return nil +} + +func validateMetadataURL(target *url.URL, loopbackHTTPEndpoint *url.URL) error { + if target == nil || target.Hostname() == "" || target.User != nil || target.Fragment != "" { + return errors.New("metadata URL must have a host and no credentials or fragment") + } + switch strings.ToLower(target.Scheme) { + case "https": + return nil + case "http": + if loopbackHTTPEndpoint != nil && isLoopbackHost(target.Hostname()) && sameAuthority(target, loopbackHTTPEndpoint) { + return nil + } + return errors.New("HTTP metadata is allowed only for a configured loopback test endpoint") + default: + return fmt.Errorf("metadata URL scheme %q is not HTTPS", target.Scheme) + } +} + +func sameAuthority(left, right *url.URL) bool { + return left != nil && right != nil && strings.EqualFold(left.Scheme, right.Scheme) && + strings.EqualFold(left.Host, right.Host) +} + +func isLoopbackHost(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/internal/packslip/github_native_test.go b/internal/packslip/github_native_test.go new file mode 100644 index 00000000..7d8f5151 --- /dev/null +++ b/internal/packslip/github_native_test.go @@ -0,0 +1,122 @@ +// 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 packslip + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDiscoveryRejectsHTTPSRedirectToLoopbackHTTP(t *testing.T) { + var targetHits atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + targetHits.Add(1) + _, _ = w.Write([]byte("private response")) + })) + defer target.Close() + + origin := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL+"/private", http.StatusFound) + })) + defer origin.Close() + discovery, err := NewGitHubPackslipDiscovery(origin.Client(), origin.URL, origin.URL) + require.NoError(t, err) + _, _, err = discovery.get(context.Background(), origin.URL+"/metadata", false) + require.ErrorContains(t, err, "HTTPS-to-HTTP downgrade") + require.Zero(t, targetHits.Load(), "the redirected loopback endpoint must not receive a request") +} + +func TestDiscoveryAllowsRedirectWithinConfiguredLoopbackHTTPTestEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/start" { + http.Redirect(w, r, "/final", http.StatusFound) + return + } + _, _ = w.Write([]byte("test metadata")) + })) + defer server.Close() + discovery, err := NewGitHubPackslipDiscovery(server.Client(), server.URL, server.URL) + require.NoError(t, err) + body, finalURL, err := discovery.get(context.Background(), server.URL+"/start", false) + require.NoError(t, err) + require.Equal(t, []byte("test metadata"), body) + require.Equal(t, server.URL+"/final", finalURL) + + outside := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("unconfigured endpoint")) + })) + defer outside.Close() + _, _, err = discovery.get(context.Background(), outside.URL+"/metadata", false) + require.ErrorContains(t, err, "configured loopback test endpoint") +} + +func TestDiscoveryComposesCallerRedirectPolicy(t *testing.T) { + var targetHits atomic.Int32 + target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + targetHits.Add(1) + _, _ = w.Write([]byte("target")) + })) + defer target.Close() + + origin := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL+"/redirected", http.StatusFound) + })) + defer origin.Close() + client := origin.Client() + callerErr := errors.New("caller redirect policy rejected request") + client.CheckRedirect = func(*http.Request, []*http.Request) error { return callerErr } + discovery, err := NewGitHubPackslipDiscovery(client, origin.URL, origin.URL) + require.NoError(t, err) + _, _, err = discovery.get(context.Background(), origin.URL+"/metadata", false) + require.ErrorIs(t, err, callerErr) + require.Zero(t, targetHits.Load(), "the caller policy must still control safe redirects") +} + +func TestDiscoveryRevalidatesRedirectAfterCallerPolicyMutation(t *testing.T) { + var targetHits atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + targetHits.Add(1) + _, _ = w.Write([]byte("mutated target")) + })) + defer target.Close() + + safeTarget := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("safe target")) + })) + defer safeTarget.Close() + origin := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, safeTarget.URL+"/redirected", http.StatusFound) + })) + defer origin.Close() + client := origin.Client() + client.CheckRedirect = func(request *http.Request, _ []*http.Request) error { + request.URL.Scheme = "http" + request.URL.Host = target.Listener.Addr().String() + return nil + } + discovery, err := NewGitHubPackslipDiscovery(client, origin.URL, origin.URL) + require.NoError(t, err) + _, _, err = discovery.get(context.Background(), origin.URL+"/metadata", false) + require.ErrorContains(t, err, "HTTPS-to-HTTP downgrade") + require.Zero(t, targetHits.Load(), "a caller mutation must not bypass redirect validation") +} diff --git a/internal/packslip/model.go b/internal/packslip/model.go new file mode 100644 index 00000000..5a144d01 --- /dev/null +++ b/internal/packslip/model.go @@ -0,0 +1,144 @@ +// 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 packslip + +import "encoding/json" + +type statementEnvelope struct { + Type string `json:"_type"` + Subject []subject `json:"subject"` + PredicateType string `json:"predicateType"` + Predicate json.RawMessage `json:"predicate"` +} + +type subject struct { + Name string `json:"name"` + Digest map[string]string `json:"digest"` +} + +type sourceMetadata struct { + Repo string `json:"repo"` + Commit *string `json:"commit,omitempty"` + Tag *string `json:"tag,omitempty"` +} + +type releaseIdentity struct { + Scheme string `json:"scheme"` + KeyID string `json:"key_id"` + Issuer *string `json:"issuer,omitempty"` +} + +type releasePredicate struct { + Project string `json:"project"` + Version string `json:"version"` + PublishedAt string `json:"published_at"` + Source *sourceMetadata `json:"source,omitempty"` + Artifacts []releaseArtifact `json:"artifacts"` + Resources []releaseResource `json:"resources,omitempty"` + Identity releaseIdentity `json:"identity"` + AttestedBy string `json:"attested_by,omitempty"` + NotesURL string `json:"notes_url,omitempty"` + Extensions map[string]json.RawMessage `json:"extensions,omitempty"` +} + +type releaseArtifact struct { + Name string `json:"name"` + OS *string `json:"os,omitempty"` + Arch *string `json:"arch,omitempty"` + LibC *string `json:"libc,omitempty"` + Variant *string `json:"variant,omitempty"` + Size *uint64 `json:"size"` + URL *string `json:"url,omitempty"` + Format *string `json:"format"` + Bin []json.RawMessage `json:"bin,omitempty"` + Requires *requirements `json:"requires,omitempty"` + Provenance []string `json:"provenance,omitempty"` + Extensions map[string]json.RawMessage `json:"extensions,omitempty"` + dbcArtifact bool +} + +type requirements struct { + OSMin *string `json:"os_min,omitempty"` + GLibCMin *string `json:"glibc_min,omitempty"` + Libs *[]string `json:"libs,omitempty"` + Bins []requiredCommand `json:"bin,omitempty"` +} + +type requiredCommand struct { + Name string `json:"name"` + Min *string `json:"min,omitempty"` +} + +type releaseResource struct { + Kind string `json:"kind"` + Artifact *string `json:"artifact,omitempty"` + OS *string `json:"os,omitempty"` + Arch *string `json:"arch,omitempty"` + LibC *string `json:"libc,omitempty"` + Shell *string `json:"shell,omitempty"` + Shells []string `json:"shells,omitempty"` + Name *string `json:"name,omitempty"` + Bin *string `json:"bin,omitempty"` + Format *string `json:"format,omitempty"` + Archive *string `json:"archive,omitempty"` + Asset *string `json:"asset,omitempty"` + URL *string `json:"url,omitempty"` + Repo *string `json:"repo,omitempty"` + Exec []string `json:"exec,omitempty"` + Env map[string]string `json:"env,omitempty"` + Extensions map[string]json.RawMessage `json:"extensions,omitempty"` +} + +type releaseListPredicate struct { + Project string `json:"project"` + Generated string `json:"generated_at"` + Expires string `json:"expires_at"` + Sequence *uint64 `json:"sequence"` + Identity releaseIdentity `json:"identity"` + Latest *string `json:"latest,omitempty"` + Releases []releaseRef `json:"releases"` + Extensions map[string]json.RawMessage `json:"extensions,omitempty"` +} + +type releaseRef struct { + Version string `json:"version"` + Tag *string `json:"tag,omitempty"` + PublishedAt string `json:"published_at"` + Packslip string `json:"packslip"` + Status string `json:"status,omitempty"` + StatusReason *string `json:"status_reason,omitempty"` + Security bool `json:"security,omitempty"` + Extensions map[string]json.RawMessage `json:"extensions,omitempty"` + Evidence []releaseEvidence `json:"evidence,omitempty"` +} + +type releaseEvidence struct { + Kind string `json:"kind"` + Detail *string `json:"detail,omitempty"` +} + +type parsedRelease struct { + envelope statementEnvelope + predicate releasePredicate + byName map[string]subject + attested string + driverID string +} + +type parsedList struct { + envelope statementEnvelope + predicate releaseListPredicate + byURL map[string]subject +} diff --git a/internal/packslip/model_native_test.go b/internal/packslip/model_native_test.go new file mode 100644 index 00000000..c0429baa --- /dev/null +++ b/internal/packslip/model_native_test.go @@ -0,0 +1,42 @@ +// 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 packslip + +import ( + "context" + "os" + "testing" + + "github.com/columnar-tech/dbc/internal/packslipverify" + "github.com/stretchr/testify/require" +) + +func TestRealSigstoreFixtureIsVerifiedBeforePackslipPredicateIsRejected(t *testing.T) { + trustedRootJSON, err := os.ReadFile("../packslipverify/testdata/trusted-root-public-good.json") + require.NoError(t, err) + bundleJSON, err := os.ReadFile("../packslipverify/testdata/bundle-provenance.json") + require.NoError(t, err) + verifier, err := packslipverify.New(packslipverify.Config{TrustedRootJSON: trustedRootJSON}) + require.NoError(t, err) + policy, err := githubIdentityPolicy("github.com/sigstore/sigstore-js") + require.NoError(t, err) + resolver := &nativeResolver{verifier: verifier} + verified, err := resolver.verifyBundle(context.Background(), bundleJSON, policy) + require.NoError(t, err) + _, err = parseRelease(verified.payload, "github.com/sigstore/sigstore-js") + require.ErrorContains(t, err, "_type") +} diff --git a/internal/packslip/model_test.go b/internal/packslip/model_test.go new file mode 100644 index 00000000..cf92d0fd --- /dev/null +++ b/internal/packslip/model_test.go @@ -0,0 +1,333 @@ +// 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 packslip + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/columnar-tech/dbc/internal/sourceidentity" + "github.com/stretchr/testify/require" +) + +const testProject = "github.com/acme/driver" + +func ptr[T any](value T) *T { return &value } + +func validRelease(version string, artifacts ...releaseArtifact) []byte { + if len(artifacts) == 0 { + format := "tar.gz" + artifacts = []releaseArtifact{{Name: "driver-linux.tar.gz", OS: ptr("linux"), Arch: ptr("x86_64"), LibC: ptr("gnu"), Size: ptr(uint64(10)), Format: &format, URL: ptr("https://dl.example/driver-linux.tar.gz")}} + } + for i := range artifacts { + if artifacts[i].Extensions == nil { + artifacts[i].Extensions = map[string]json.RawMessage{"dbc": json.RawMessage(`{}`)} + } + } + project := testProject + issued := GitHubOIDCIssuer + envelope := statementEnvelope{ + Type: StatementType, + PredicateType: ReleasePredicateType, + Predicate: mustJSON(releasePredicate{ + Project: project, Version: version, PublishedAt: "2026-09-01T12:00:00Z", Artifacts: artifacts, + Extensions: map[string]json.RawMessage{"dbc": mustJSON(dbcReleaseExtension{SchemaVersion: 1, DriverID: "driver"})}, + Identity: releaseIdentity{Scheme: "sigstore-oidc", KeyID: "https://github.com/acme/driver/.github/workflows/release.yml@refs/tags/v1", Issuer: &issued}, + }), + } + for _, artifact := range artifacts { + envelope.Subject = append(envelope.Subject, subject{Name: artifact.Name, Digest: map[string]string{"sha256": strings.Repeat("a", 64)}}) + } + return mustJSON(envelope) +} + +func parseDBCRelease(payload []byte, expectedProject string) (*parsedRelease, error) { + release, err := parseRelease(payload, expectedProject) + if err != nil { + return nil, err + } + if err := validateDBCReleaseExtensions(release); err != nil { + return nil, err + } + return release, nil +} + +func validList(sequence uint64, expires string) []byte { + issued := GitHubOIDCIssuer + url := "https://github.com/acme/driver/releases/download/v1/packslip.sigstore.json" + envelope := statementEnvelope{ + Type: StatementType, + PredicateType: ListPredicateType, + Subject: []subject{{Name: url, Digest: map[string]string{"sha256": strings.Repeat("b", 64)}}}, + Predicate: mustJSON(releaseListPredicate{ + Project: testProject, Generated: "2026-09-01T12:00:00Z", Expires: expires, Sequence: &sequence, + Identity: releaseIdentity{Scheme: "sigstore-oidc", KeyID: "https://github.com/acme/driver/.github/workflows/release.yml@refs/heads/main", Issuer: &issued}, + Releases: []releaseRef{{Version: "1.0.0", Tag: ptr("v1.0.0"), PublishedAt: "2026-09-01T12:00:00Z", Packslip: url}}, + }), + } + return mustJSON(envelope) +} + +func mustJSON(value any) []byte { + data, err := json.Marshal(value) + if err != nil { + panic(err) + } + return data +} + +func TestParsePackslipReleaseStrictlyValidatesProjectAndSubjects(t *testing.T) { + parsed, err := parseRelease(validRelease("1.2.3"), testProject) + require.NoError(t, err) + require.Equal(t, "1.2.3", parsed.predicate.Version) + require.Equal(t, strings.Repeat("a", 64), parsed.byName["driver-linux.tar.gz"].Digest["sha256"]) + + tests := []struct { + name string + payload []byte + project string + wantErr string + }{ + {name: "wrong project", payload: validRelease("1.2.3"), project: "github.com/attacker/driver", wantErr: "does not match requested project"}, + {name: "bad semver", payload: validRelease("01.2.3"), project: testProject, wantErr: "not SemVer"}, + } + for _, test := range tests[:2] { + t.Run(test.name, func(t *testing.T) { + _, err := parseRelease(test.payload, test.project) + require.ErrorContains(t, err, test.wantErr) + }) + } + mixedCase := strings.Replace(string(validRelease("1.2.3")), `"project":"github.com/acme/driver"`, `"project":"GitHub.COM/ACME/DRIVER"`, 1) + canonical, err := parseRelease([]byte(mixedCase), testProject) + require.NoError(t, err) + require.Equal(t, testProject, canonical.predicate.Project) + missingSubject := validRelease("1.2.3") + var missingEnvelope statementEnvelope + require.NoError(t, json.Unmarshal(missingSubject, &missingEnvelope)) + missingEnvelope.Subject = nil + _, err = parseRelease(mustJSON(missingEnvelope), testProject) + require.ErrorContains(t, err, "has no subject") + + badDigest := validRelease("1.2.3") + var envelope statementEnvelope + require.NoError(t, json.Unmarshal(badDigest, &envelope)) + envelope.Subject[0].Digest["sha256"] = "UPPER" + strings.Repeat("a", 58) + badDigest = mustJSON(envelope) + _, err = parseRelease(badDigest, testProject) + require.ErrorContains(t, err, "lowercase SHA-256") + + unknownField := strings.Replace(string(validRelease("1.2.3")), `"version":"1.2.3"`, `"version":"1.2.3","unexpected":true`, 1) + _, err = parseRelease([]byte(unknownField), testProject) + require.NoError(t, err, "unknown optional wire fields are ignored") + + duplicateField := strings.Replace(string(validRelease("1.2.3")), `"_type":"`+StatementType+`"`, `"_type":"`+StatementType+`","_type":"`+StatementType+`"`, 1) + _, err = parseRelease([]byte(duplicateField), testProject) + require.ErrorContains(t, err, "duplicate JSON field") +} + +func TestParseSignedReleaseListRejectsMalformedAndExpiredShape(t *testing.T) { + list, err := parseList(validList(4, "2026-10-01T00:00:00Z"), testProject) + require.NoError(t, err) + require.Equal(t, uint64(4), *list.predicate.Sequence) + + _, err = parseList(validList(4, "2026-09-01T12:00:00Z"), testProject) + require.ErrorContains(t, err, "expires_at must be after generated_at") + _, err = parseList(validList(4, "2026-10-01T00:00:00Z"), "github.com/other/repo") + require.ErrorContains(t, err, "does not match requested project") + + var envelope statementEnvelope + require.NoError(t, json.Unmarshal(validList(4, "2026-10-01T00:00:00Z"), &envelope)) + envelope.Subject[0].Digest["sha256"] = strings.Repeat("0", 64) + _, err = parseList(mustJSON(envelope), testProject) + require.NoError(t, err) // The list's own subject is valid; resolver compares fetched bundle bytes. +} + +func TestSelectArtifactUsesPackslipOrderAndRejectsAmbiguity(t *testing.T) { + makeArtifact := func(name, osName, arch, libc, format string, variant *string) releaseArtifact { + return releaseArtifact{Name: name, OS: optionalToken(osName), Arch: optionalToken(arch), LibC: optionalToken(libc), Variant: variant, Size: ptr(uint64(1)), Format: ptr(format), URL: ptr("https://dl.example/" + name)} + } + release, err := parseDBCRelease(validRelease("1.0.0", + makeArtifact("portable.tar.gz", "", "", "", "tar.gz", nil), + makeArtifact("linux-specific.tgz", "linux", "x86_64", "gnu", "tgz", nil), + makeArtifact("linux-specific.tar.gz", "linux", "x86_64", "gnu", "tar.gz", nil), + ), testProject) + require.NoError(t, err) + require.NoError(t, validateSupportedArtifactSet(release), "tar.gz and tgz with one selector are resolved by format preference") + selected, err := selectArtifact(release, Target{OS: "linux", Arch: "x86_64", LibC: "gnu"}) + require.NoError(t, err) + require.Equal(t, "linux-specific.tar.gz", selected.Name) + + ambiguous, err := parseDBCRelease(validRelease("1.0.0", + makeArtifact("linux-x64.tar.gz", "linux", "", "", "tar.gz", nil), + makeArtifact("x64-linux.tar.gz", "", "x86_64", "", "tar.gz", nil), + ), testProject) + require.NoError(t, err) + _, err = selectArtifact(ambiguous, Target{OS: "linux", Arch: "x86_64"}) + require.ErrorIs(t, err, ErrAmbiguousArtifact) + + variant := "fips" + withVariant, err := parseDBCRelease(validRelease("1.0.0", + makeArtifact("default.tar.gz", "linux", "x86_64", "gnu", "tar.gz", nil), + makeArtifact("fips.tar.gz", "linux", "x86_64", "gnu", "tar.gz", &variant), + ), testProject) + require.NoError(t, err) + selected, err = selectArtifact(withVariant, Target{OS: "linux", Arch: "x86_64", LibC: "gnu", Variant: "fips"}) + require.NoError(t, err) + require.Equal(t, "fips.tar.gz", selected.Name) + selected, err = selectArtifact(withVariant, Target{OS: "linux", Arch: "x86_64", LibC: "gnu"}) + require.NoError(t, err) + require.Equal(t, "default.tar.gz", selected.Name, "an empty target variant selects the ordinary variant") + _, err = selectArtifact(withVariant, Target{OS: "linux", Arch: "x86_64", LibC: "gnu", Variant: "other"}) + require.ErrorIs(t, err, ErrReleaseNotFound, "variant selectors must match exactly") +} + +func TestDBCSupportedFormatsAndPackslipHostLibcSemantics(t *testing.T) { + makeArtifact := func(name, osName, arch, libc, format string) releaseArtifact { + return releaseArtifact{Name: name, OS: optionalToken(osName), Arch: optionalToken(arch), LibC: optionalToken(libc), Size: ptr(uint64(1)), Format: ptr(format), URL: ptr("https://dl.example/" + name)} + } + release, err := parseDBCRelease(validRelease("1.0.0", + makeArtifact("linux.tar.xz", "linux", "x86_64", "gnu", "tar.xz"), + makeArtifact("portable.tar.gz", "", "", "", "tar.gz"), + ), testProject) + require.NoError(t, err) + selected, err := selectArtifact(release, Target{OS: "linux", Arch: "x86_64", LibC: "gnu"}) + require.NoError(t, err) + require.Equal(t, "portable.tar.gz", selected.Name, "dbc must ignore unsupported formats even when they are more specific") + + gnuOnly, err := parseDBCRelease(validRelease("1.0.0", makeArtifact("linux-gnu.tar.gz", "linux", "amd64", "gnu", "tar.gz")), testProject) + require.NoError(t, err) + _, err = selectArtifact(gnuOnly, Target{OS: "linux", Arch: "x86_64"}) + require.ErrorIs(t, err, ErrReleaseNotFound, "a host with unknown libc must not be treated as GNU") +} + +func TestParseReleaseRejectsExactSelectorDuplicatesButDefersOverlappingScopeTies(t *testing.T) { + artifact := func(name, osName, arch string) releaseArtifact { + return releaseArtifact{Name: name, OS: optionalToken(osName), Arch: optionalToken(arch), Size: ptr(uint64(1)), Format: ptr("tar.gz"), URL: ptr("https://dl.example/" + name)} + } + _, err := parseDBCRelease(validRelease("1.0.0", + artifact("first.tar.gz", "linux", "x86_64"), + artifact("second.tar.gz", "linux", "amd64"), + ), testProject) + require.ErrorContains(t, err, "duplicate selectors", "generic selector validity also applies to dbc extensions") + + _, err = parseDBCRelease(validRelease("1.0.0", + artifact("darwin.tar.gz", "darwin", "x86_64"), + artifact("macos.tar.gz", "macos", "amd64"), + ), testProject) + require.ErrorContains(t, err, "duplicate selectors", "OS and architecture aliases are canonicalized before duplicate detection") + + release, err := parseDBCRelease(validRelease("1.0.0", + artifact("linux.tar.gz", "linux", ""), + artifact("x64.tar.gz", "", "amd64"), + ), testProject) + require.NoError(t, err, "different overlapping selectors are valid statement data") + _, err = selectArtifact(release, Target{OS: "linux", Arch: "amd64"}) + require.ErrorIs(t, err, ErrAmbiguousArtifact, "the tie is reported only for a matching host") + + concreteTie, err := parseDBCRelease(validRelease("1.0.0", + artifact("linux-amd64.tar.gz", "linux", "amd64"), + releaseArtifact{Name: "linux-gnu.tar.gz", OS: ptr("linux"), LibC: ptr("gnu"), Size: ptr(uint64(1)), Format: ptr("tar.gz"), URL: ptr("https://dl.example/linux-gnu.tar.gz")}, + ), testProject) + require.NoError(t, err) + targets, err := deriveConcreteTargets(concreteTie) + require.NoError(t, err) + require.Equal(t, []Target{{OS: "linux", Arch: "amd64", LibC: "gnu"}}, targets) + _, err = selectArtifact(concreteTie, targets[0]) + require.ErrorIs(t, err, ErrAmbiguousArtifact, "ambiguity is reported for an actual derived target with tied highest-specificity candidates") +} + +func optionalToken(value string) *string { + if value == "" { + return nil + } + return &value +} + +func TestNormalizeProjectAndTagVersion(t *testing.T) { + projectKey, err := sourceidentity.Parse(sourceidentity.Packslip, "GitHub.COM/Acme/driver/Tools/Tool") + require.NoError(t, err) + project := projectKey.Reference + require.Equal(t, "github.com/acme/driver/Tools/Tool", project) + version, ok := tagVersion("Tool_v1.2.0", project) + require.True(t, ok) + require.Equal(t, "1.2.0", version) + _, ok = tagVersion("tool_v1.2.0", project) + require.False(t, ok, "monorepo subpaths preserve case") + _, err = sourceidentity.Parse(sourceidentity.Packslip, "https://github.com/acme/driver") + require.Error(t, err) +} + +func TestProjectCanonicalizationPreservesMonorepoSubpathCase(t *testing.T) { + project := "GitHub.COM/ACME/Driver/Arrow/Flight" + payload := strings.Replace(string(validRelease("1.2.3")), `"project":"github.com/acme/driver"`, `"project":"github.com/acme/driver/Arrow/Flight"`, 1) + parsed, err := parseRelease([]byte(payload), project) + require.NoError(t, err) + require.Equal(t, "github.com/acme/driver/Arrow/Flight", parsed.predicate.Project) + + wrongCase := strings.Replace(payload, `"project":"github.com/acme/driver/Arrow/Flight"`, `"project":"github.com/acme/driver/arrow/Flight"`, 1) + _, err = parseRelease([]byte(wrongCase), project) + require.ErrorContains(t, err, "does not match requested project") +} + +func TestWireDecoderIgnoresUnknownOptionalFieldsButRejectsMalformedJSON(t *testing.T) { + payload := string(validRelease("1.2.3")) + payload = strings.Replace(payload, `"_type":"`+StatementType+`"`, `"_type":"`+StatementType+`","future_statement_field":{"enabled":true}`, 1) + payload = strings.Replace(payload, `"version":"1.2.3"`, `"version":"1.2.3","future_predicate_field":[1,2,3]`, 1) + payload = strings.Replace(payload, `"name":"driver-linux.tar.gz"`, `"name":"driver-linux.tar.gz","future_artifact_field":"ignored"`, 1) + parsed, err := parseRelease([]byte(payload), testProject) + require.NoError(t, err) + require.Equal(t, "1.2.3", parsed.predicate.Version) + + _, err = parseRelease(append([]byte(payload), []byte(` {}`)...), testProject) + require.ErrorContains(t, err, "multiple JSON values") + badType := strings.Replace(payload, `"version":"1.2.3"`, `"version":true`, 1) + _, err = parseRelease([]byte(badType), testProject) + require.Error(t, err) + duplicate := strings.Replace(payload, `"version":"1.2.3"`, `"version":"1.2.3","version":"1.2.3"`, 1) + _, err = parseRelease([]byte(duplicate), testProject) + require.ErrorContains(t, err, "duplicate JSON field") +} + +func TestSupportedArtifactInventoryAllowsOverlappingSelectorsAndChoosesMostSpecific(t *testing.T) { + artifact := func(name, osName, arch string) releaseArtifact { + return releaseArtifact{Name: name, OS: optionalToken(osName), Arch: optionalToken(arch), Size: ptr(uint64(1)), Format: ptr("tar.gz"), URL: ptr("https://dl.example/" + name)} + } + release, err := parseDBCRelease(validRelease("1.0.0", + artifact("linux-any-arch.tar.gz", "linux", ""), + artifact("any-os-amd64.tar.gz", "", "amd64"), + artifact("linux-amd64.tar.gz", "linux", "amd64"), + ), testProject) + require.NoError(t, err) + require.NoError(t, validateSupportedArtifactSet(release), "inventory validation does not reject pairwise overlap before concrete target derivation") + targets, err := deriveConcreteTargets(release) + require.NoError(t, err) + require.Equal(t, []Target{{OS: "linux", Arch: "amd64", LibC: "gnu"}}, targets) + selected, err := selectArtifact(release, targets[0]) + require.NoError(t, err) + require.Equal(t, "linux-amd64.tar.gz", selected.Name, "the exact selector has higher specificity than linux/* and */amd64") +} + +func TestSupportedArtifactInventoryRequiresAtLeastOneSupportedFormat(t *testing.T) { + unsupported := releaseArtifact{Name: "linux.tar.xz", OS: ptr("linux"), Arch: ptr("amd64"), Format: ptr("tar.xz"), Size: ptr(uint64(1)), URL: ptr("https://dl.example/linux.tar.xz")} + release, err := parseDBCRelease(validRelease("1.0.0", unsupported), testProject) + require.NoError(t, err) + require.ErrorContains(t, validateSupportedArtifactSet(release), "no dbc artifact with a supported tar.gz or tgz format") +} + +func TestParseReleaseHasNoUnsignedFallback(t *testing.T) { + _, err := parseRelease([]byte(`{"_type":"https://in-toto.io/Statement/v1","subject":[],"predicateType":"https://packslip.dev/release/v1","predicate":{}}`), testProject) + require.Error(t, err) +} diff --git a/internal/packslip/project.go b/internal/packslip/project.go new file mode 100644 index 00000000..ed20ac7e --- /dev/null +++ b/internal/packslip/project.go @@ -0,0 +1,82 @@ +// 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 packslip + +import "strings" + +func projectParts(project string) (owner, repo string, tool []string) { + parts := strings.Split(project, "/") + return parts[1], parts[2], parts[3:] +} + +func tagVersion(tag, project string) (string, bool) { + _, repo, tool := projectParts(project) + prefixes := make([]string, 0, 3) + if len(tool) > 0 { + prefixes = append(prefixes, strings.Join(tool, "/")) + if len(tool) > 1 { + prefixes = append(prefixes, tool[len(tool)-1]) + } + } + prefixes = append(prefixes, repo) + rest := tag + for _, prefix := range prefixes { + for _, separator := range []string{"/", "-", "_", "@"} { + if strings.HasPrefix(rest, prefix+separator) { + rest = strings.TrimPrefix(rest, prefix+separator) + goto prefixFound + } + } + } +prefixFound: + rest = strings.TrimPrefix(rest, "v") + version, ok := normalizeLooseTagVersion(rest) + if !ok || !semverPattern.MatchString(version) { + return "", false + } + return version, true +} + +func normalizeLooseTagVersion(value string) (string, bool) { + coreEnd := strings.IndexAny(value, "-+") + core, suffix := value, "" + if coreEnd >= 0 { + core, suffix = value[:coreEnd], value[coreEnd:] + } + parts := strings.Split(core, ".") + if len(parts) < 2 || len(parts) > 3 { + return "", false + } + for i, part := range parts { + if part == "" { + return "", false + } + for _, ch := range part { + if ch < '0' || ch > '9' { + return "", false + } + } + trimmed := strings.TrimLeft(part, "0") + if trimmed == "" { + trimmed = "0" + } + parts[i] = trimmed + } + if len(parts) == 2 { + parts = append(parts, "0") + } + version := strings.Join(parts, ".") + suffix + return version, semverPattern.MatchString(version) +} diff --git a/internal/packslip/release.go b/internal/packslip/release.go new file mode 100644 index 00000000..39b136cd --- /dev/null +++ b/internal/packslip/release.go @@ -0,0 +1,357 @@ +// 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 packslip + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "regexp" + "strings" + "time" + + "github.com/columnar-tech/dbc/internal/sourceidentity" +) + +var ( + semverPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$`) + tokenPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`) +) + +// parseRelease checks the release/v1 wire shape and all digest relationships. +// The caller invokes it only on a payload returned by the verifier. +func parseRelease(payload []byte, expectedProject string) (*parsedRelease, error) { + var envelope statementEnvelope + if err := decodeWire(payload, &envelope); err != nil { + return nil, fmt.Errorf("decode packslip statement: %w", err) + } + if envelope.Type != StatementType { + return nil, fmt.Errorf("packslip _type %q is unsupported", envelope.Type) + } + if envelope.PredicateType != ReleasePredicateType { + return nil, fmt.Errorf("packslip predicateType %q is not %s", envelope.PredicateType, ReleasePredicateType) + } + var predicate releasePredicate + if err := decodeWire(envelope.Predicate, &predicate); err != nil { + return nil, fmt.Errorf("decode packslip release predicate: %w", err) + } + projectKey, err := sourceidentity.Parse(sourceidentity.Packslip, predicate.Project) + if err != nil { + return nil, fmt.Errorf("packslip project %q is invalid: %w", predicate.Project, err) + } + canonicalProject := projectKey.Reference + if expectedProject != "" { + expectedKey, err := sourceidentity.Parse(sourceidentity.Packslip, expectedProject) + if err != nil { + return nil, err + } + expectedProject = expectedKey.Reference + if canonicalProject != expectedProject { + return nil, fmt.Errorf("packslip project %q does not match requested project %q", predicate.Project, expectedProject) + } + } + predicate.Project = canonicalProject + if !semverPattern.MatchString(predicate.Version) { + return nil, fmt.Errorf("packslip version %q is not SemVer 2.0.0", predicate.Version) + } + if _, err := parseUTC("published_at", predicate.PublishedAt); err != nil { + return nil, err + } + if predicate.Source != nil { + if predicate.Source.Repo == "" { + return nil, errors.New("packslip source.repo is empty") + } + if err := validateHTTPSURL(predicate.Source.Repo); err != nil { + return nil, fmt.Errorf("packslip source.repo: %w", err) + } + if predicate.Source.Tag != nil && *predicate.Source.Tag == "" { + return nil, errors.New("packslip source.tag is empty") + } + } + if predicate.NotesURL != "" { + if err := validateHTTPSURL(predicate.NotesURL); err != nil { + return nil, fmt.Errorf("packslip notes_url: %w", err) + } + } + if len(predicate.Artifacts) == 0 { + return nil, errors.New("packslip release has no artifacts") + } + if err := validateIdentityShape(predicate.Identity); err != nil { + return nil, err + } + attested := predicate.AttestedBy + if attested == "" { + attested = "vendor" + } + if attested != "vendor" && attested != "repackager" { + return nil, fmt.Errorf("unsupported packslip attested_by value %q", attested) + } + + byName := make(map[string]subject, len(envelope.Subject)) + for _, entry := range envelope.Subject { + if entry.Name == "" { + return nil, errors.New("packslip subject name is empty") + } + if _, exists := byName[entry.Name]; exists { + return nil, fmt.Errorf("packslip has duplicate subject %q", entry.Name) + } + if err := validateSubjectDigest(entry); err != nil { + return nil, err + } + byName[entry.Name] = entry + } + seenArtifacts := make(map[string]bool, len(predicate.Artifacts)) + seenSelectors := make(map[string]string, len(predicate.Artifacts)) + for i := range predicate.Artifacts { + artifact := &predicate.Artifacts[i] + if artifact.Name == "" || strings.ContainsAny(artifact.Name, "/\\\x00") || artifact.Name == "." || artifact.Name == ".." { + return nil, fmt.Errorf("packslip artifact %q has an invalid name", artifact.Name) + } + if seenArtifacts[artifact.Name] { + return nil, fmt.Errorf("packslip has duplicate artifact %q", artifact.Name) + } + seenArtifacts[artifact.Name] = true + if _, ok := byName[artifact.Name]; !ok { + return nil, fmt.Errorf("packslip artifact %q has no subject", artifact.Name) + } + if artifact.Size == nil { + return nil, fmt.Errorf("packslip artifact %q has no size", artifact.Name) + } + if artifact.Format == nil || !validToken(*artifact.Format) { + return nil, fmt.Errorf("packslip artifact %q has an invalid or missing format", artifact.Name) + } + canonicalizeArtifactAliases(artifact) + selector := artifactSelectorKey(artifact) + if previous, exists := seenSelectors[selector]; exists { + return nil, fmt.Errorf("packslip artifacts %q and %q have duplicate selectors", previous, artifact.Name) + } + seenSelectors[selector] = artifact.Name + for field, value := range map[string]*string{ + "os": artifact.OS, "arch": artifact.Arch, "libc": artifact.LibC, "variant": artifact.Variant, + } { + if value != nil && !validToken(*value) { + return nil, fmt.Errorf("packslip artifact %q has invalid %s token %q", artifact.Name, field, *value) + } + } + if artifact.URL != nil { + if err := validateHTTPSURL(*artifact.URL); err != nil { + return nil, fmt.Errorf("packslip artifact %q URL: %w", artifact.Name, err) + } + } + if err := validateArtifactRequirements(artifact); err != nil { + return nil, err + } + if err := validateBins(artifact.Bin); err != nil { + return nil, fmt.Errorf("packslip artifact %q: %w", artifact.Name, err) + } + } + for _, resource := range predicate.Resources { + if resource.Kind == "" { + return nil, errors.New("packslip resource kind is empty") + } + sources := 0 + if resource.Archive != nil { + sources++ + } + if resource.Asset != nil { + sources++ + } + if resource.Repo != nil { + sources++ + } + if len(resource.Exec) > 0 { + sources++ + } + if sources != 1 { + return nil, fmt.Errorf("packslip resource %q must have exactly one source", resource.Kind) + } + if resource.Asset != nil { + if _, ok := byName[*resource.Asset]; !ok { + return nil, fmt.Errorf("packslip resource asset %q has no subject", *resource.Asset) + } + } + for field, value := range map[string]*string{ + "os": resource.OS, "arch": resource.Arch, "libc": resource.LibC, + } { + if value != nil && !validToken(*value) { + return nil, fmt.Errorf("packslip resource has invalid %s token %q", field, *value) + } + } + if resource.URL != nil { + if resource.Asset == nil { + return nil, errors.New("packslip resource URL is only valid with an asset source") + } + if err := validateHTTPSURL(*resource.URL); err != nil { + return nil, fmt.Errorf("packslip resource asset URL: %w", err) + } + } + } + allowedSubjects := make(map[string]bool, len(predicate.Artifacts)+len(predicate.Resources)) + for name := range seenArtifacts { + allowedSubjects[name] = true + } + for _, resource := range predicate.Resources { + if resource.Asset != nil { + allowedSubjects[*resource.Asset] = true + } + } + if len(allowedSubjects) != len(byName) { + return nil, errors.New("packslip subjects must correspond exactly to artifacts and asset resources") + } + for name := range byName { + if !allowedSubjects[name] { + return nil, fmt.Errorf("packslip subject %q is not an artifact or asset resource", name) + } + } + return &parsedRelease{envelope: envelope, predicate: predicate, byName: byName, attested: attested}, nil +} + +func validateIdentityShape(identity releaseIdentity) error { + if identity.Scheme != "sigstore-oidc" { + return fmt.Errorf("unsupported packslip signer scheme %q", identity.Scheme) + } + if strings.TrimSpace(identity.KeyID) == "" { + return errors.New("packslip signer key_id is empty") + } + if identity.Issuer == nil || *identity.Issuer == "" { + return errors.New("packslip Sigstore OIDC identity has no issuer") + } + return nil +} + +func validateSubjectDigest(entry subject) error { + digest, ok := entry.Digest["sha256"] + if !ok || !validHexDigest(digest, 64) { + return fmt.Errorf("packslip subject %q must have a lowercase SHA-256 digest", entry.Name) + } + for algorithm := range entry.Digest { + if algorithm != "sha256" && algorithm != "sha512" { + return fmt.Errorf("packslip subject %q has an unsupported digest algorithm %q", entry.Name, algorithm) + } + } + if sha512, exists := entry.Digest["sha512"]; exists && !validHexDigest(sha512, 128) { + return fmt.Errorf("packslip subject %q has an invalid SHA-512 digest", entry.Name) + } + return nil +} + +func validateArtifactRequirements(artifact *releaseArtifact) error { + if artifact.Requires == nil { + return nil + } + r := artifact.Requires + for name, value := range map[string]*string{"os_min": r.OSMin, "glibc_min": r.GLibCMin} { + if value != nil && (strings.TrimSpace(*value) == "" || strings.ContainsAny(*value, " \t\r\n")) { + return fmt.Errorf("packslip artifact %q has invalid requires.%s", artifact.Name, name) + } + } + if r.Libs != nil { + seen := map[string]bool{} + for _, lib := range *r.Libs { + if !validRequirementName(lib) || seen[lib] { + return fmt.Errorf("packslip artifact %q has invalid or duplicate required library %q", artifact.Name, lib) + } + seen[lib] = true + } + } + seen := map[string]bool{} + for _, bin := range r.Bins { + if !validRequirementName(bin.Name) || strings.HasSuffix(strings.ToLower(bin.Name), ".exe") || seen[bin.Name] { + return fmt.Errorf("packslip artifact %q has invalid or duplicate required command %q", artifact.Name, bin.Name) + } + seen[bin.Name] = true + if bin.Min != nil && !validMinimumVersion(*bin.Min) { + return fmt.Errorf("packslip artifact %q has invalid minimum version for %q", artifact.Name, bin.Name) + } + } + return nil +} + +func validateBins(bins []json.RawMessage) error { + for _, raw := range bins { + var path string + if err := json.Unmarshal(raw, &path); err == nil { + if path == "" { + return errors.New("bin path is empty") + } + continue + } + var named struct { + Path string `json:"path"` + Name string `json:"name"` + } + if err := decodeWire(raw, &named); err != nil { + return fmt.Errorf("invalid bin entry: %w", err) + } + if named.Path == "" || named.Name == "" || strings.Contains(named.Name, "/") { + return errors.New("bin entry has an invalid path or command name") + } + } + return nil +} + +func validRequirementName(value string) bool { + return value != "" && !strings.ContainsAny(value, "/\\\x00 \t\r\n") +} + +func validMinimumVersion(value string) bool { + if value == "" || value[0] < '0' || value[0] > '9' { + return false + } + for _, ch := range value { + if !(ch >= '0' && ch <= '9' || ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || strings.ContainsRune(".-+", ch)) { + return false + } + } + return true +} + +func validToken(value string) bool { return tokenPattern.MatchString(value) } + +func validHexDigest(value string, length int) bool { + if len(value) != length { + return false + } + for _, ch := range value { + if !(ch >= '0' && ch <= '9' || ch >= 'a' && ch <= 'f') { + return false + } + } + return true +} + +func parseUTC(field, value string) (time.Time, error) { + timestamp, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return time.Time{}, fmt.Errorf("packslip %s is not RFC 3339: %w", field, err) + } + _, offset := timestamp.Zone() + if offset != 0 { + return time.Time{}, fmt.Errorf("packslip %s must be UTC", field) + } + return timestamp, nil +} + +func validateHTTPSURL(value string) error { + parsed, err := url.Parse(value) + if err != nil { + return err + } + if parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil || parsed.Fragment != "" { + return errors.New("URL must be an absolute HTTPS URL without credentials or fragment") + } + return nil +} diff --git a/internal/packslip/release_list.go b/internal/packslip/release_list.go new file mode 100644 index 00000000..2b608ae7 --- /dev/null +++ b/internal/packslip/release_list.go @@ -0,0 +1,128 @@ +// 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 packslip + +import ( + "errors" + "fmt" + + "github.com/columnar-tech/dbc/internal/sourceidentity" +) + +func parseList(payload []byte, expectedProject string) (*parsedList, error) { + var envelope statementEnvelope + if err := decodeWire(payload, &envelope); err != nil { + return nil, fmt.Errorf("decode packslip release list: %w", err) + } + if envelope.Type != StatementType { + return nil, fmt.Errorf("release list _type %q is unsupported", envelope.Type) + } + if envelope.PredicateType != ListPredicateType { + return nil, fmt.Errorf("release list predicateType %q is not %s", envelope.PredicateType, ListPredicateType) + } + var predicate releaseListPredicate + if err := decodeWire(envelope.Predicate, &predicate); err != nil { + return nil, fmt.Errorf("decode packslip release-list predicate: %w", err) + } + projectKey, err := sourceidentity.Parse(sourceidentity.Packslip, predicate.Project) + if err != nil { + return nil, fmt.Errorf("release-list project %q is invalid: %w", predicate.Project, err) + } + canonicalProject := projectKey.Reference + expectedKey, err := sourceidentity.Parse(sourceidentity.Packslip, expectedProject) + if err != nil { + return nil, err + } + expectedProject = expectedKey.Reference + if canonicalProject != expectedProject { + return nil, fmt.Errorf("release-list project %q does not match requested project %q", predicate.Project, expectedProject) + } + predicate.Project = canonicalProject + generated, err := parseUTC("generated_at", predicate.Generated) + if err != nil { + return nil, err + } + expires, err := parseUTC("expires_at", predicate.Expires) + if err != nil { + return nil, err + } + if !expires.After(generated) { + return nil, errors.New("release-list expires_at must be after generated_at") + } + if predicate.Sequence == nil { + return nil, errors.New("release-list sequence is missing") + } + if len(predicate.Releases) == 0 { + return nil, errors.New("release list has no releases") + } + if err := validateIdentityShape(predicate.Identity); err != nil { + return nil, err + } + byURL := make(map[string]subject, len(envelope.Subject)) + for _, entry := range envelope.Subject { + if entry.Name == "" { + return nil, errors.New("release-list subject URL is empty") + } + if _, exists := byURL[entry.Name]; exists { + return nil, fmt.Errorf("release list has duplicate subject %q", entry.Name) + } + if err := validateSubjectDigest(entry); err != nil { + return nil, err + } + byURL[entry.Name] = entry + } + seenVersion := make(map[string]bool, len(predicate.Releases)) + seenURL := make(map[string]bool, len(predicate.Releases)) + for _, ref := range predicate.Releases { + if !semverPattern.MatchString(ref.Version) { + return nil, fmt.Errorf("release-list version %q is not SemVer 2.0.0", ref.Version) + } + if seenVersion[ref.Version] { + return nil, fmt.Errorf("release list has duplicate version %q", ref.Version) + } + seenVersion[ref.Version] = true + if _, err := parseUTC("release published_at", ref.PublishedAt); err != nil { + return nil, err + } + if err := validateHTTPSURL(ref.Packslip); err != nil { + return nil, fmt.Errorf("release-list packslip URL: %w", err) + } + if seenURL[ref.Packslip] { + return nil, fmt.Errorf("release list has duplicate packslip URL %q", ref.Packslip) + } + seenURL[ref.Packslip] = true + if _, ok := byURL[ref.Packslip]; !ok { + return nil, fmt.Errorf("release-list packslip URL %q has no subject", ref.Packslip) + } + if ref.Status != "" && ref.Status != "yanked" { + return nil, fmt.Errorf("unsupported release-list status %q", ref.Status) + } + if ref.StatusReason != nil && ref.Status != "yanked" { + return nil, fmt.Errorf("status_reason is only valid for a yanked release") + } + for _, evidence := range ref.Evidence { + if evidence.Kind == "" { + return nil, errors.New("release-list evidence kind is empty") + } + } + } + if len(byURL) != len(seenURL) { + return nil, errors.New("release-list subjects must correspond exactly to listed packslip bundles") + } + if predicate.Latest != nil && !seenVersion[*predicate.Latest] { + return nil, fmt.Errorf("release-list latest version %q is not listed", *predicate.Latest) + } + return &parsedList{envelope: envelope, predicate: predicate, byURL: byURL}, nil +} diff --git a/internal/packslip/resolver_js.go b/internal/packslip/resolver_js.go new file mode 100644 index 00000000..17243b7e --- /dev/null +++ b/internal/packslip/resolver_js.go @@ -0,0 +1,42 @@ +// 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 + +//go:build js + +package packslip + +import ( + "context" + "fmt" + "net/http" + + "github.com/columnar-tech/dbc/internal/resolution" +) + +type unsupportedResolver struct{} + +// NewResolver returns a resolver that rejects unsupported sources before +// reading trust state or making a network request. +func NewResolver(Config) (Resolver, error) { return unsupportedResolver{}, nil } + +func (unsupportedResolver) Resolve(context.Context, PackslipSource, Request) (resolution.ResolvedRelease, error) { + return resolution.ResolvedRelease{}, fmt.Errorf("%w: Node and browser builds cannot resolve GitHub packslip sources", ErrUnsupported) +} + +// GitHubPackslipDiscovery is an unavailable transport on JavaScript targets. +type GitHubPackslipDiscovery struct{} + +func NewGitHubPackslipDiscovery(*http.Client, string, string) (*GitHubPackslipDiscovery, error) { + return nil, ErrUnsupported +} + +func NewTrustStore() (*TrustStore, error) { return nil, ErrUnsupported } + +func NewTrustStoreAt(string) (*TrustStore, error) { return nil, ErrUnsupported } + +var _ Resolver = unsupportedResolver{} diff --git a/internal/packslip/resolver_native.go b/internal/packslip/resolver_native.go new file mode 100644 index 00000000..bdc8cd79 --- /dev/null +++ b/internal/packslip/resolver_native.go @@ -0,0 +1,497 @@ +// 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 + +//go:build !js + +package packslip + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "regexp" + "sort" + "strings" + "time" + + "github.com/columnar-tech/dbc/internal/packslipverify" + "github.com/columnar-tech/dbc/internal/resolution" + "github.com/columnar-tech/dbc/internal/sourceidentity" +) + +type nativeResolver struct { + discovery *GitHubPackslipDiscovery + verifier packslipverify.PackslipVerifier + trust *TrustStore + now func() time.Time +} + +type verifiedRelease struct { + statement *parsedRelease + identity trustedIdentity + identityKey string + bundleHash string +} + +// NewResolver constructs the native GitHub packslip resolver. +func NewResolver(config Config) (Resolver, error) { + client := config.HTTPClient + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + discovery, err := NewGitHubPackslipDiscovery(client, config.GitHubAPIBaseURL, config.GitHubRawBaseURL) + if err != nil { + return nil, err + } + verifier := config.Verifier + if verifier == nil { + verifier, err = packslipverify.New(packslipverify.Config{ + HTTPClient: client, + TrustedRootJSON: config.TrustedRootJSON, + }) + if err != nil { + return nil, fmt.Errorf("create packslip Sigstore verifier: %w", err) + } + } + trust := config.TrustStore + if trust == nil { + trust, err = NewTrustStore() + if err != nil { + return nil, err + } + } + now := config.Now + if now == nil { + now = time.Now + } + return &nativeResolver{discovery: discovery, verifier: verifier, trust: trust, now: now}, nil +} + +func (r *nativeResolver) Resolve(ctx context.Context, source PackslipSource, request Request) (resolution.ResolvedRelease, error) { + if err := ctx.Err(); err != nil { + return resolution.ResolvedRelease{}, err + } + projectKey, err := sourceidentity.Parse(sourceidentity.Packslip, source.Project) + if err != nil { + return resolution.ResolvedRelease{}, err + } + project := projectKey.Reference + if request.DriverID != "" { + if err := validateRuntimeDriverID(request.DriverID); err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("invalid expected driver ID %q: %w", request.DriverID, err) + } + } + if !semverPattern.MatchString(request.Version) { + return resolution.ResolvedRelease{}, fmt.Errorf("requested packslip version %q must be an exact SemVer 2.0.0 version", request.Version) + } + source.Project = project + identityPolicy, err := githubIdentityPolicy(project) + if err != nil { + return resolution.ResolvedRelease{}, err + } + observedList, err := r.trust.observeList(project) + if err != nil { + return resolution.ResolvedRelease{}, err + } + + var signedList *parsedList + var acceptedList *listAcceptance + var listURL, listHash string + listURL, err = r.discovery.ReleaseListURL(source) + if err != nil { + return resolution.ResolvedRelease{}, err + } + listBytes, _, listErr := r.discovery.get(ctx, listURL, false) + switch { + case errors.Is(listErr, errHTTPNotFound): + if observedList.Accepted { + return resolution.ResolvedRelease{}, fmt.Errorf("previously accepted signed release list for %s is missing", project) + } + listURL = "" + case listErr != nil: + return resolution.ResolvedRelease{}, fmt.Errorf("fetch supplementary signed release list for %s: %w", project, listErr) + default: + verified, err := r.verifyBundle(ctx, listBytes, identityPolicy) + if err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("verify signed release list: %w", err) + } + list, err := parseList(verified.payload, project) + if err != nil { + return resolution.ResolvedRelease{}, err + } + if err := checkDeclaredIdentity(list.predicate.Identity, verified.verified); err != nil { + return resolution.ResolvedRelease{}, err + } + expires, _ := parseUTC("expires_at", list.predicate.Expires) + if !r.now().Before(expires) { + return resolution.ResolvedRelease{}, fmt.Errorf("signed release list for %s expired at %s", project, list.predicate.Expires) + } + identity, identityKey, err := identityTrust(list.predicate.Identity.Scheme, *list.predicate.Identity.Issuer, verified.verified.Signer) + if err != nil { + return resolution.ResolvedRelease{}, err + } + listHash = digestBytes(listBytes) + acceptedList = &listAcceptance{Signer: identity, IdentityKey: identityKey, Sequence: *list.predicate.Sequence, Hash: listHash} + signedList = list + } + + var listed *releaseRef + if signedList != nil { + for i := range signedList.predicate.Releases { + if signedList.predicate.Releases[i].Version == request.Version { + listed = &signedList.predicate.Releases[i] + break + } + } + if listed != nil && listed.Status == "yanked" { + if err := r.trust.acceptResolution(ctx, project, observedList, acceptedList, nil); err != nil { + return resolution.ResolvedRelease{}, err + } + return resolution.ResolvedRelease{}, fmt.Errorf("%w: %s", ErrYankedRelease, request.Version) + } + } + + githubReleases, err := r.discovery.ListReleases(ctx, source) + if err != nil { + return resolution.ResolvedRelease{}, err + } + releaseByTag := make(map[string]githubRelease, len(githubReleases)) + var endpointMatches []githubRelease + for _, release := range githubReleases { + releaseByTag[release.TagName] = release + version, ok := tagVersion(release.TagName, project) + if ok && version == request.Version { + endpointMatches = append(endpointMatches, release) + } + } + var bundleURL string + var bundleBytes []byte + var releaseAssets []githubAsset + var expectedTag string + if listed != nil { + bundleURL = listed.Packslip + if release, ok := releaseByTag[stringValue(listed.Tag)]; stringValue(listed.Tag) != "" && ok { + releaseAssets = release.Assets + expectedTag = release.TagName + } else { + expectedTag = stringValue(listed.Tag) + if expectedTag == "" && len(endpointMatches) == 1 { + releaseAssets = endpointMatches[0].Assets + } + } + bundleBytes, _, err = r.discovery.get(ctx, bundleURL, false) + if err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("fetch signed packslip for %s: %w", request.Version, err) + } + pinned := signedList.byURL[listed.Packslip].Digest["sha256"] + if digestHex(bundleBytes) != pinned { + return resolution.ResolvedRelease{}, fmt.Errorf("signed release list digest mismatch for %s", request.Version) + } + } else { + if len(endpointMatches) > 1 { + return resolution.ResolvedRelease{}, fmt.Errorf("multiple GitHub tags map to packslip version %s", request.Version) + } + if len(endpointMatches) == 0 { + if acceptedList != nil { + if err := r.trust.acceptResolution(ctx, project, observedList, acceptedList, nil); err != nil { + return resolution.ResolvedRelease{}, err + } + } + return resolution.ResolvedRelease{}, fmt.Errorf("%w: %s for %s", ErrReleaseNotFound, request.Version, project) + } + release := endpointMatches[0] + expectedTag = release.TagName + releaseAssets = release.Assets + var candidates []githubAsset + for _, asset := range release.Assets { + if strings.HasPrefix(asset.Name, "packslip") && strings.HasSuffix(asset.Name, ".sigstore.json") { + candidates = append(candidates, asset) + } + } + if len(candidates) == 0 { + return resolution.ResolvedRelease{}, fmt.Errorf("GitHub release %q has no packslip bundle asset", release.TagName) + } + var found *verifiedRelease + for _, candidate := range candidates { + if err := validateHTTPSURL(candidate.BrowserDownloadURL); err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("invalid GitHub packslip asset URL: %w", err) + } + bytes, _, err := r.discovery.get(ctx, candidate.BrowserDownloadURL, false) + if err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("fetch GitHub packslip asset %q: %w", candidate.Name, err) + } + verified, err := r.verifyReleaseBundle(ctx, bytes, identityPolicy, "", "") + if err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("verify GitHub packslip asset %q: %w", candidate.Name, err) + } + if verified.statement.predicate.Project != project { + continue + } + if verified.statement.predicate.Version != request.Version { + return resolution.ResolvedRelease{}, fmt.Errorf("GitHub release tag %q maps to %s but signed packslip says %s", release.TagName, request.Version, verified.statement.predicate.Version) + } + if _, ok := tagVersion(release.TagName, project); !ok { + return resolution.ResolvedRelease{}, fmt.Errorf("GitHub release tag %q does not name a SemVer version", release.TagName) + } + if tagVersionValue, _ := tagVersion(release.TagName, project); tagVersionValue != verified.statement.predicate.Version { + return resolution.ResolvedRelease{}, fmt.Errorf("GitHub release tag %q does not match signed packslip version %s", release.TagName, verified.statement.predicate.Version) + } + if sourceTag := verified.statement.predicate.Source; sourceTag != nil && sourceTag.Tag != nil && *sourceTag.Tag != release.TagName { + return resolution.ResolvedRelease{}, fmt.Errorf("signed packslip source.tag %q does not match GitHub tag %q", *sourceTag.Tag, release.TagName) + } + if found != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("GitHub release has multiple signed packslips for %s", project) + } + verified.bundleHash = digestBytes(bytes) + verified.statement.predicate.Project = project + bundleURL, bundleBytes = candidate.BrowserDownloadURL, bytes + found = verified + } + if found == nil { + return resolution.ResolvedRelease{}, fmt.Errorf("GitHub release %q has no signed packslip for %s", release.TagName, project) + } + return r.finishResolved(ctx, project, request, observedList, acceptedList, found, bundleURL, bundleBytes, listURL, listHash, releaseAssets) + } + + verified, err := r.verifyReleaseBundle(ctx, bundleBytes, identityPolicy, project, request.Version) + if err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("verify signed packslip for %s: %w", request.Version, err) + } + if listed.PublishedAt != "" { + listedAt, _ := parseUTC("release-list published_at", listed.PublishedAt) + publishedAt, _ := parseUTC("published_at", verified.statement.predicate.PublishedAt) + if !listedAt.Equal(publishedAt) { + return resolution.ResolvedRelease{}, errors.New("release-list publication time does not match signed packslip") + } + } + if expectedTag != "" && listed.Tag != nil { + if source := verified.statement.predicate.Source; source == nil || source.Tag == nil || *source.Tag != expectedTag { + return resolution.ResolvedRelease{}, fmt.Errorf("signed packslip source.tag does not match release-list tag %q", expectedTag) + } + } + verified.bundleHash = digestBytes(bundleBytes) + return r.finishResolved(ctx, project, request, observedList, acceptedList, verified, bundleURL, bundleBytes, listURL, listHash, releaseAssets) +} + +type verifierResult struct { + verified *packslipverify.VerifiedBundle + payload []byte +} + +func (r *nativeResolver) verifyBundle(ctx context.Context, bundleBytes []byte, policy packslipverify.IdentityPolicy) (*verifierResult, error) { + algorithm, digest, peekedPayload, err := peekBundleDigest(bundleBytes) + if err != nil { + return nil, err + } + verified, err := r.verifier.Verify(ctx, bundleBytes, policy, packslipverify.ArtifactDigest{Algorithm: algorithm, Hex: digest}) + if err != nil { + return nil, err + } + if verified == nil { + return nil, errors.New("Sigstore verifier returned no verified bundle") + } + if !bytes.Equal(peekedPayload, verified.SignedPayload) { + return nil, errors.New("Sigstore verifier returned a payload different from the bundle payload") + } + if verified.Issuer != GitHubOIDCIssuer || !policyMatchesSigner(policy, verified.Signer) { + return nil, errors.New("verified Sigstore identity does not match the requested GitHub project") + } + return &verifierResult{verified: verified, payload: verified.SignedPayload}, nil +} + +func (r *nativeResolver) verifyReleaseBundle(ctx context.Context, bundleBytes []byte, policy packslipverify.IdentityPolicy, expectedProject, expectedVersion string) (*verifiedRelease, error) { + result, err := r.verifyBundle(ctx, bundleBytes, policy) + if err != nil { + return nil, err + } + release, err := parseRelease(result.payload, expectedProject) + if err != nil { + return nil, err + } + if expectedVersion != "" && release.predicate.Version != expectedVersion { + return nil, fmt.Errorf("signed packslip version %q does not match requested version %q", release.predicate.Version, expectedVersion) + } + if err := checkDeclaredIdentity(release.predicate.Identity, result.verified); err != nil { + return nil, err + } + identity, identityKey, err := identityTrust(release.predicate.Identity.Scheme, *release.predicate.Identity.Issuer, result.verified.Signer) + if err != nil { + return nil, err + } + return &verifiedRelease{statement: release, identity: identity, identityKey: identityKey}, nil +} + +func (r *nativeResolver) finishResolved(ctx context.Context, project string, request Request, observedList listObservation, acceptedList *listAcceptance, verified *verifiedRelease, bundleURL string, bundleBytes []byte, listURL, listHash string, assets []githubAsset) (resolution.ResolvedRelease, error) { + if (listURL == "") != (listHash == "") { + return resolution.ResolvedRelease{}, errors.New("release-index evidence requires both a URL and hash") + } + if err := validateDBCReleaseExtensions(verified.statement); err != nil { + return resolution.ResolvedRelease{}, err + } + if request.DriverID != "" && request.DriverID != verified.statement.driverID { + return resolution.ResolvedRelease{}, fmt.Errorf("signed dbc driver ID %q does not match requested driver ID %q", verified.statement.driverID, request.DriverID) + } + if err := validateSupportedArtifactSet(verified.statement); err != nil { + return resolution.ResolvedRelease{}, err + } + targets, err := deriveConcreteTargets(verified.statement) + if err != nil { + return resolution.ResolvedRelease{}, err + } + artifacts := make([]resolution.Artifact, 0, len(targets)) + for _, target := range targets { + selected, err := selectArtifact(verified.statement, target) + if err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("select packslip artifact for %s: %w", describeTarget(target), err) + } + artifact, err := convertSelectedArtifact(verified.statement, selected, assets, target) + if err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("resolve packslip artifact for %s: %w", describeTarget(target), err) + } + artifacts = append(artifacts, artifact) + } + provenance, err := provenancePresence(verified.statement) + if err != nil { + return resolution.ResolvedRelease{}, err + } + checksum := digestBytes(bundleBytes) + evidence := []resolution.Evidence{{ + Kind: resolution.EvidenceKindReleaseMetadata, + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: bundleURL}, + Hash: checksum, + }} + if listURL != "" && listHash != "" { + evidence = append(evidence, resolution.Evidence{ + Kind: resolution.EvidenceKindReleaseIndex, + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: listURL}, + Hash: listHash, + }) + } + result := resolution.ResolvedRelease{ + DriverID: verified.statement.driverID, + Version: verified.statement.predicate.Version, + Source: resolution.SourceSpec{Type: "packslip", Reference: project}, + Evidence: evidence, + Artifacts: artifacts, + } + if err := resolution.ValidateResolvedRelease(result); err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("invalid resolved packslip release: %w", err) + } + releaseTrust := &releaseAcceptance{Signer: verified.identity, Attested: verified.statement.attested, Provenance: provenance} + if err := r.trust.acceptResolution(ctx, project, observedList, acceptedList, releaseTrust); err != nil { + return resolution.ResolvedRelease{}, err + } + return result, nil +} + +func describeTarget(target Target) string { + target = resolution.CanonicalTarget(target) + return fmt.Sprintf("os=%q arch=%q libc=%q variant=%q", target.OS, target.Arch, target.LibC, target.Variant) +} + +// deriveConcreteTargets returns the finite set of concrete targets represented +// by supported artifacts with explicit OS and architecture selectors. A +// wildcard-only artifact cannot describe an enumerable platform set, so it +// does not create a target by itself. +func deriveConcreteTargets(release *parsedRelease) ([]Target, error) { + targetSet := make(map[Target]struct{}, len(release.predicate.Artifacts)) + for i := range release.predicate.Artifacts { + artifact := &release.predicate.Artifacts[i] + if !artifact.dbcArtifact || !supportedArchiveFormat(stringValue(artifact.Format)) || artifact.OS == nil || artifact.Arch == nil { + continue + } + target := resolution.CanonicalTarget(Target{ + OS: *artifact.OS, + Arch: *artifact.Arch, + LibC: stringValue(artifact.LibC), + Variant: stringValue(artifact.Variant), + }) + if err := resolution.ValidateConcreteTarget(target); err != nil { + return nil, fmt.Errorf("packslip artifact %q does not identify a concrete target: %w", artifact.Name, err) + } + targetSet[target] = struct{}{} + } + if len(targetSet) == 0 { + return nil, errors.New("packslip release has no supported artifact with explicit OS and architecture selectors") + } + targets := make([]Target, 0, len(targetSet)) + for target := range targetSet { + targets = append(targets, target) + } + sort.Slice(targets, func(i, j int) bool { + left, right := targets[i], targets[j] + if left.OS != right.OS { + return left.OS < right.OS + } + if left.Arch != right.Arch { + return left.Arch < right.Arch + } + if left.LibC != right.LibC { + return left.LibC < right.LibC + } + return left.Variant < right.Variant + }) + return targets, nil +} + +func checkDeclaredIdentity(declared releaseIdentity, verified *packslipverify.VerifiedBundle) error { + if declared.Scheme != "sigstore-oidc" || declared.Issuer == nil || *declared.Issuer != verified.Issuer || declared.KeyID != verified.Signer { + return errors.New("signed packslip identity declaration does not match its verified Sigstore identity") + } + return nil +} + +func githubIdentityPolicy(project string) (packslipverify.IdentityPolicy, error) { + projectKey, err := sourceidentity.Parse(sourceidentity.Packslip, project) + if err != nil { + return packslipverify.IdentityPolicy{}, err + } + project = projectKey.Reference + owner, repo, _ := projectParts(project) + pattern := `^https://(?i:github\.com/` + regexp.QuoteMeta(owner) + `/` + regexp.QuoteMeta(repo) + `)/\.github/workflows/[^@]+@[^@]+$` + return packslipverify.IdentityPolicy{Issuer: GitHubOIDCIssuer, SubjectRegex: pattern}, nil +} + +func policyMatchesSigner(policy packslipverify.IdentityPolicy, signer string) bool { + if policy.Subject != "" { + return signer == policy.Subject + } + compiled, err := regexp.Compile(policy.SubjectRegex) + return err == nil && compiled.MatchString(signer) +} + +func digestBytes(value []byte) string { return "sha256:" + digestHex(value) } + +func digestHex(value []byte) string { + digest := sha256.Sum256(value) + return hex.EncodeToString(digest[:]) +} + +func provenancePresence(release *parsedRelease) (map[string]bool, error) { + if err := validateSupportedArtifactSet(release); err != nil { + return nil, err + } + result := make(map[string]bool, len(release.predicate.Artifacts)) + for _, artifact := range release.predicate.Artifacts { + if !artifact.dbcArtifact || !supportedArchiveFormat(stringValue(artifact.Format)) { + continue + } + selector := artifactSelectorKey(&artifact) + if _, exists := result[selector]; exists { + return nil, fmt.Errorf("packslip provenance selectors are not unique: %q", selector) + } + result[selector] = len(artifact.Provenance) > 0 + } + return result, nil +} + +var _ Resolver = (*nativeResolver)(nil) diff --git a/internal/packslip/resolver_native_test.go b/internal/packslip/resolver_native_test.go new file mode 100644 index 00000000..8f20ce44 --- /dev/null +++ b/internal/packslip/resolver_native_test.go @@ -0,0 +1,657 @@ +// 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 packslip + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/columnar-tech/dbc/internal/packslipverify" + "github.com/columnar-tech/dbc/internal/resolution" + "github.com/stretchr/testify/require" +) + +const fakeSigner = "https://github.com/acme/driver/.github/workflows/release.yml@refs/tags/v1.0.0" + +type mockPackslipVerifier struct { + issuer string + signer string + err error +} + +func (v mockPackslipVerifier) Verify(_ context.Context, bundleJSON []byte, identity packslipverify.IdentityPolicy, digest packslipverify.ArtifactDigest) (*packslipverify.VerifiedBundle, error) { + if v.err != nil { + return nil, v.err + } + var envelope struct { + DSSE struct { + Payload string `json:"payload"` + } `json:"dsseEnvelope"` + } + if err := json.Unmarshal(bundleJSON, &envelope); err != nil { + return nil, err + } + payload, err := base64.StdEncoding.DecodeString(envelope.DSSE.Payload) + if err != nil { + return nil, err + } + algorithm, wanted := digest.Algorithm, digest.Hex + var candidate struct { + Subject []subject `json:"subject"` + } + if err := json.Unmarshal(payload, &candidate); err != nil { + return nil, err + } + found := false + for _, entry := range candidate.Subject { + if entry.Digest[algorithm] == wanted { + found = true + } + } + if !found { + return nil, errors.New("artifact digest does not match a signed subject") + } + if v.issuer != identity.Issuer || !policyMatchesSigner(identity, v.signer) { + return nil, errors.New("signature identity rejected by project policy") + } + return &packslipverify.VerifiedBundle{Issuer: v.issuer, Signer: v.signer, SignedPayload: payload}, nil +} + +func makeBundle(payload []byte) []byte { + return mustJSON(struct { + DSSE struct { + Payload string `json:"payload"` + PayloadType string `json:"payloadType"` + } `json:"dsseEnvelope"` + }{DSSE: struct { + Payload string `json:"payload"` + PayloadType string `json:"payloadType"` + }{Payload: base64.StdEncoding.EncodeToString(payload), PayloadType: "application/vnd.in-toto+json"}}) +} + +func setSignerAndTag(t *testing.T, payload []byte, project, version, signer, tag string) []byte { + t.Helper() + var envelope statementEnvelope + require.NoError(t, json.Unmarshal(payload, &envelope)) + var predicate releasePredicate + require.NoError(t, json.Unmarshal(envelope.Predicate, &predicate)) + predicate.Project = project + predicate.Version = version + predicate.Identity.KeyID = signer + predicate.Source = &sourceMetadata{Repo: "https://github.com/acme/driver", Tag: &tag} + envelope.Predicate = mustJSON(predicate) + return mustJSON(envelope) +} + +func makeSignedList(t *testing.T, project string, sequence uint64, expiry string, releaseURL string, releaseBundle []byte, signer string, version, tag, status string) []byte { + t.Helper() + digest := sha256.Sum256(releaseBundle) + var payload releaseListPredicate + payload.Project = project + payload.Generated = "2026-09-01T12:00:00Z" + payload.Expires = expiry + payload.Sequence = &sequence + issuer := GitHubOIDCIssuer + payload.Identity = releaseIdentity{Scheme: "sigstore-oidc", KeyID: signer, Issuer: &issuer} + payload.Releases = []releaseRef{{Version: version, Tag: &tag, PublishedAt: "2026-09-01T12:00:00Z", Packslip: releaseURL, Status: status}} + envelope := statementEnvelope{ + Type: StatementType, + Subject: []subject{{Name: releaseURL, Digest: map[string]string{"sha256": hex.EncodeToString(digest[:])}}}, + PredicateType: ListPredicateType, + Predicate: mustJSON(payload), + } + return makeBundle(mustJSON(envelope)) +} + +type resolverHTTPFixtures struct { + project string + tag string + list []byte + release []byte + artifactURL string + releaseAssets []githubAsset + customReleaseAssets bool + apiHits atomic.Int32 + requestHits atomic.Int32 + bundleHits atomic.Int32 + archiveHits atomic.Int32 +} + +func newResolverTestServer(t *testing.T, project, tag string, listBytes, releaseBytes []byte, artifactURL string) (*httptest.Server, *nativeResolver, *resolverHTTPFixtures) { + t.Helper() + fixture := &resolverHTTPFixtures{project: project, tag: tag, list: listBytes, release: releaseBytes, artifactURL: artifactURL} + owner, repo, _ := projectParts(project) + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fixture.requestHits.Add(1) + switch { + case r.URL.Path == "/"+owner+"/"+repo+"/HEAD/.well-known/packslip.json": + if fixture.list == nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(fixture.list) + case strings.HasPrefix(r.URL.Path, "/repos/") && strings.HasSuffix(r.URL.Path, "/releases"): + fixture.apiHits.Add(1) + assets := fixture.releaseAssets + if !fixture.customReleaseAssets { + assets = []githubAsset{ + {Name: "packslip.sigstore.json", BrowserDownloadURL: serverURL(r, "/assets/release.json")}, + {Name: "driver-linux.tar.gz", BrowserDownloadURL: fixture.artifactURL}, + } + } + _, _ = w.Write(mustJSON([]githubRelease{{TagName: fixture.tag, Assets: assets}})) + case r.URL.Path == "/assets/release.json": + fixture.bundleHits.Add(1) + _, _ = w.Write(fixture.release) + case r.URL.Path == "/assets/driver.tar.gz": + fixture.archiveHits.Add(1) + _, _ = w.Write([]byte("not a signed packslip")) + default: + http.NotFound(w, r) + } + })) + client := server.Client() + store, err := NewTrustStoreAt(filepath.Join(t.TempDir(), "trust", "packslip.json")) + require.NoError(t, err) + discovery, err := NewGitHubPackslipDiscovery(client, server.URL, server.URL) + require.NoError(t, err) + resolver := &nativeResolver{ + discovery: discovery, + verifier: mockPackslipVerifier{issuer: GitHubOIDCIssuer, signer: fakeSigner}, + trust: store, + now: func() time.Time { return time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC) }, + } + t.Cleanup(server.Close) + return server, resolver, fixture +} + +func serverURL(request *http.Request, path string) string { + return "https://" + request.Host + path +} + +func TestResolverUsesSignedListDigestAndBuildsResolvedRelease(t *testing.T) { + artifact := func(name, osName, arch, libc, format, url string) releaseArtifact { + return releaseArtifact{Name: name, OS: optionalToken(osName), Arch: optionalToken(arch), LibC: optionalToken(libc), Format: ptr(format), Size: ptr(uint64(10)), URL: ptr(url)} + } + releasePayload := setSignerAndTag(t, validRelease("1.0.0", + artifact("driver-windows.tar.gz", "windows", "x86_64", "", "tar.gz", "https://downloads.example/driver-windows.tar.gz"), + artifact("driver-macos.tgz", "darwin", "aarch64", "", "tgz", "https://downloads.example/driver-macos.tgz"), + artifact("driver-linux-arm64.tar.gz", "linux", "aarch64", "gnu", "tar.gz", "https://downloads.example/driver-linux-arm64.tar.gz"), + artifact("driver-macos-x64.tar.gz", "darwin", "x86_64", "", "tar.gz", "https://downloads.example/driver-macos-x64.tar.gz"), + artifact("driver-linux.tar.gz", "linux", "x86_64", "gnu", "tar.gz", "https://downloads.example/driver-linux.tar.gz"), + ), testProject, "1.0.0", fakeSigner, "v1.0.0") + releaseBytes := makeBundle(releasePayload) + server, resolver, fixture := newResolverTestServer(t, testProject, "v1.0.0", nil, releaseBytes, "") + listBytes := makeSignedList(t, testProject, 1, "2026-10-01T00:00:00Z", server.URL+"/assets/release.json", releaseBytes, fakeSigner, "1.0.0", "v1.0.0", "") + fixture.list = listBytes + resolved, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) + require.NoError(t, err) + require.Equal(t, "driver", resolved.DriverID) + require.Equal(t, "1.0.0", resolved.Version) + require.Equal(t, "packslip", resolved.Source.Type) + require.Equal(t, testProject, resolved.Source.Reference) + require.Equal(t, []resolution.Evidence{ + { + Kind: resolution.EvidenceKindReleaseMetadata, + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: server.URL + "/assets/release.json"}, + Hash: "sha256:" + digestHex(releaseBytes), + }, + { + Kind: resolution.EvidenceKindReleaseIndex, + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: server.URL + "/acme/driver/HEAD/.well-known/packslip.json"}, + Hash: "sha256:" + digestHex(listBytes), + }, + }, resolved.Evidence) + require.Len(t, resolved.Artifacts, 5, "a resolution snapshots every concrete target in the release") + require.Equal(t, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://downloads.example/driver-linux.tar.gz"}, resolved.Artifacts[0].Location) + require.Equal(t, "sha256:"+strings.Repeat("a", 64), resolved.Artifacts[0].Hash) + require.Equal(t, int64(10), *resolved.Artifacts[0].Size) + require.Equal(t, Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, resolved.Artifacts[0].Target) + require.Equal(t, Target{OS: "linux", Arch: "arm64", LibC: "gnu"}, resolved.Artifacts[1].Target) + require.Equal(t, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://downloads.example/driver-linux-arm64.tar.gz"}, resolved.Artifacts[1].Location) + require.Equal(t, Target{OS: "macos", Arch: "amd64"}, resolved.Artifacts[2].Target) + require.Equal(t, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://downloads.example/driver-macos-x64.tar.gz"}, resolved.Artifacts[2].Location) + require.Equal(t, Target{OS: "macos", Arch: "arm64"}, resolved.Artifacts[3].Target) + require.Equal(t, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://downloads.example/driver-macos.tgz"}, resolved.Artifacts[3].Location) + require.Equal(t, Target{OS: "windows", Arch: "amd64"}, resolved.Artifacts[4].Target) + require.Equal(t, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://downloads.example/driver-windows.tar.gz"}, resolved.Artifacts[4].Location) + require.NoError(t, resolution.ValidateResolvedRelease(resolved)) + require.Equal(t, int32(1), fixture.apiHits.Load()) +} + +func TestResolverSelectsOneCanonicalWinnerPerTarget(t *testing.T) { + artifact := func(name, osName, arch, libc, format, url string) releaseArtifact { + return releaseArtifact{Name: name, OS: optionalToken(osName), Arch: optionalToken(arch), LibC: optionalToken(libc), Format: ptr(format), Size: ptr(uint64(10)), URL: ptr(url)} + } + release := validRelease("1.0.0", + artifact("portable.tar.gz", "", "", "", "tar.gz", "https://downloads.example/portable.tar.gz"), + artifact("linux-x86-compat.tgz", "linux", "x86_64", "", "tgz", "https://downloads.example/linux-x86-compat.tgz"), + artifact("linux-amd64.tar.gz", "linux", "amd64", "", "tar.gz", "https://downloads.example/linux-amd64.tar.gz"), + artifact("linux-arm64.tgz", "linux", "aarch64", "", "tgz", "https://downloads.example/linux-arm64.tgz"), + ) + bundle := makeBundle(setSignerAndTag(t, release, testProject, "1.0.0", fakeSigner, "v1.0.0")) + _, resolver, _ := newResolverTestServer(t, testProject, "v1.0.0", nil, bundle, "") + resolved, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) + require.NoError(t, err) + require.Len(t, resolved.Artifacts, 2, "canonical aliases that derive the same target must produce one winner") + require.Equal(t, Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, resolved.Artifacts[0].Target) + require.Equal(t, "tar.gz", resolved.Artifacts[0].Format, "tar.gz wins over tgz for the same target") + require.Equal(t, "https://downloads.example/linux-amd64.tar.gz", resolved.Artifacts[0].Location.Value) + require.Equal(t, Target{OS: "linux", Arch: "arm64", LibC: "gnu"}, resolved.Artifacts[1].Target) + require.Equal(t, "tgz", resolved.Artifacts[1].Format) + require.Equal(t, "https://downloads.example/linux-arm64.tgz", resolved.Artifacts[1].Location.Value) + require.NoError(t, resolution.ValidateResolvedRelease(resolved)) +} + +func TestResolverAcceptsOverlappingWildcardAndConcreteSelectors(t *testing.T) { + artifact := func(name, osName, arch, url string) releaseArtifact { + return releaseArtifact{Name: name, OS: optionalToken(osName), Arch: optionalToken(arch), Format: ptr("tar.gz"), Size: ptr(uint64(10)), URL: ptr(url)} + } + release := validRelease("1.0.0", + artifact("linux-any-arch.tar.gz", "linux", "", "https://downloads.example/linux-any-arch.tar.gz"), + artifact("any-os-amd64.tar.gz", "", "amd64", "https://downloads.example/any-os-amd64.tar.gz"), + artifact("linux-amd64.tar.gz", "linux", "amd64", "https://downloads.example/linux-amd64.tar.gz"), + ) + bundle := makeBundle(setSignerAndTag(t, release, testProject, "1.0.0", fakeSigner, "v1.0.0")) + _, resolver, _ := newResolverTestServer(t, testProject, "v1.0.0", nil, bundle, "") + resolved, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) + require.NoError(t, err) + require.Len(t, resolved.Artifacts, 1) + require.Equal(t, Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, resolved.Artifacts[0].Target) + require.Equal(t, "https://downloads.example/linux-amd64.tar.gz", resolved.Artifacts[0].Location.Value, + "resolution chooses the most specific artifact for the derived Linux amd64 target") +} + +func TestResolverAllowsOneSelectedArtifactToWinForSeveralTargets(t *testing.T) { + artifact := func(name, osName, arch, libc, format, url string) releaseArtifact { + return releaseArtifact{Name: name, OS: optionalToken(osName), Arch: optionalToken(arch), LibC: optionalToken(libc), Format: ptr(format), Size: ptr(uint64(10)), URL: ptr(url)} + } + sharedURL := "https://downloads.example/linux-universal.tar.gz" + release := validRelease("1.0.0", + artifact("linux-amd64.tgz", "linux", "amd64", "", "tgz", "https://downloads.example/linux-amd64.tgz"), + artifact("linux-arm64.tgz", "linux", "arm64", "", "tgz", "https://downloads.example/linux-arm64.tgz"), + artifact("linux-any-arch.tar.gz", "linux", "", "gnu", "tar.gz", sharedURL), + ) + bundle := makeBundle(setSignerAndTag(t, release, testProject, "1.0.0", fakeSigner, "v1.0.0")) + _, resolver, _ := newResolverTestServer(t, testProject, "v1.0.0", nil, bundle, "") + resolved, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) + require.NoError(t, err) + require.Len(t, resolved.Artifacts, 2) + require.Equal(t, Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, resolved.Artifacts[0].Target) + require.Equal(t, sharedURL, resolved.Artifacts[0].Location.Value) + require.Equal(t, Target{OS: "linux", Arch: "arm64", LibC: "gnu"}, resolved.Artifacts[1].Target) + require.Equal(t, sharedURL, resolved.Artifacts[1].Location.Value) + require.Equal(t, resolved.Artifacts[0].Hash, resolved.Artifacts[1].Hash) + require.Equal(t, *resolved.Artifacts[0].Size, *resolved.Artifacts[1].Size) + require.NoError(t, resolution.ValidateResolvedRelease(resolved)) +} + +func TestResolverSeparatesOrdinaryAndExplicitVariants(t *testing.T) { + ordinary := releaseArtifact{Name: "ordinary.tar.gz", OS: ptr("linux"), Arch: ptr("amd64"), LibC: ptr("gnu"), Format: ptr("tar.gz"), Size: ptr(uint64(10)), URL: ptr("https://downloads.example/ordinary.tar.gz")} + fips := releaseArtifact{Name: "fips.tar.gz", OS: ptr("linux"), Arch: ptr("amd64"), LibC: ptr("gnu"), Variant: ptr("fips"), Format: ptr("tar.gz"), Size: ptr(uint64(10)), URL: ptr("https://downloads.example/fips.tar.gz")} + bundle := makeBundle(setSignerAndTag(t, validRelease("1.0.0", ordinary, fips), testProject, "1.0.0", fakeSigner, "v1.0.0")) + _, resolver, _ := newResolverTestServer(t, testProject, "v1.0.0", nil, bundle, "") + resolved, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) + require.NoError(t, err) + require.Len(t, resolved.Artifacts, 2) + require.Equal(t, Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, resolved.Artifacts[0].Target) + require.Equal(t, "https://downloads.example/ordinary.tar.gz", resolved.Artifacts[0].Location.Value) + require.Equal(t, Target{OS: "linux", Arch: "amd64", LibC: "gnu", Variant: "fips"}, resolved.Artifacts[1].Target) + require.Equal(t, "https://downloads.example/fips.tar.gz", resolved.Artifacts[1].Location.Value) +} + +func TestResolverPreservesExplicitMuslTargetAndIgnoresUnsupportedFormatSeeds(t *testing.T) { + gnuDefault := releaseArtifact{Name: "linux-default.tar.gz", OS: ptr("linux"), Arch: ptr("amd64"), Format: ptr("tar.gz"), Size: ptr(uint64(10)), URL: ptr("https://downloads.example/linux-default.tar.gz")} + musl := releaseArtifact{Name: "linux-musl.tar.gz", OS: ptr("linux"), Arch: ptr("amd64"), LibC: ptr("musl"), Format: ptr("tar.gz"), Size: ptr(uint64(10)), URL: ptr("https://downloads.example/linux-musl.tar.gz")} + unsupported := releaseArtifact{Name: "windows-arm64.zip", OS: ptr("windows"), Arch: ptr("arm64"), Format: ptr("zip"), Size: ptr(uint64(10)), URL: ptr("https://downloads.example/windows-arm64.zip")} + bundle := makeBundle(setSignerAndTag(t, validRelease("1.0.0", gnuDefault, musl, unsupported), testProject, "1.0.0", fakeSigner, "v1.0.0")) + _, resolver, _ := newResolverTestServer(t, testProject, "v1.0.0", nil, bundle, "") + resolved, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) + require.NoError(t, err) + require.Len(t, resolved.Artifacts, 2, "unsupported formats must not contribute target seeds") + require.Equal(t, Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, resolved.Artifacts[0].Target) + require.Equal(t, "https://downloads.example/linux-default.tar.gz", resolved.Artifacts[0].Location.Value) + require.Equal(t, Target{OS: "linux", Arch: "amd64", LibC: "musl"}, resolved.Artifacts[1].Target) + require.Equal(t, "https://downloads.example/linux-musl.tar.gz", resolved.Artifacts[1].Location.Value) +} + +func TestDerivedConcreteLinuxTargetDefaultsToGNU(t *testing.T) { + linuxDefault := releaseArtifact{ + Name: "linux-default.tar.gz", OS: ptr("linux"), Arch: ptr("x86_64"), + Format: ptr("tar.gz"), Size: ptr(uint64(10)), URL: ptr("https://downloads.example/linux-default.tar.gz"), + } + release, err := parseDBCRelease(validRelease("1.0.0", linuxDefault), testProject) + require.NoError(t, err) + targets, err := deriveConcreteTargets(release) + require.NoError(t, err) + require.Equal(t, []Target{{OS: "linux", Arch: "amd64", LibC: "gnu"}}, targets, + "dbc's release snapshot continues to canonicalize an omitted Linux libc to GNU") +} + +func TestResolverRejectsWildcardOnlyReleaseWithoutUpdatingTrust(t *testing.T) { + wildcard := releaseArtifact{Name: "portable.tar.gz", Format: ptr("tar.gz"), Size: ptr(uint64(10)), URL: ptr("https://downloads.example/portable.tar.gz")} + bundle := makeBundle(setSignerAndTag(t, validRelease("1.0.0", wildcard), testProject, "1.0.0", fakeSigner, "v1.0.0")) + _, resolver, _ := newResolverTestServer(t, testProject, "v1.0.0", nil, bundle, "") + _, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) + require.ErrorContains(t, err, "explicit OS and architecture") + doc, err := resolver.trust.read() + require.NoError(t, err) + require.Nil(t, doc.Projects[testProject], "unsupported wildcard-only inventory must not update release trust") +} + +func TestResolverDoesNotUpdateTrustWhenAnyTargetCannotBeConverted(t *testing.T) { + largeSize := ^uint64(0) + valid := releaseArtifact{Name: "linux.tar.gz", OS: ptr("linux"), Arch: ptr("amd64"), LibC: ptr("gnu"), Format: ptr("tar.gz"), Size: ptr(uint64(10)), URL: ptr("https://downloads.example/linux.tar.gz")} + tooLarge := releaseArtifact{Name: "windows.tar.gz", OS: ptr("windows"), Arch: ptr("amd64"), Format: ptr("tar.gz"), Size: &largeSize, URL: ptr("https://downloads.example/windows.tar.gz")} + bundle := makeBundle(setSignerAndTag(t, validRelease("1.0.0", valid, tooLarge), testProject, "1.0.0", fakeSigner, "v1.0.0")) + _, resolver, _ := newResolverTestServer(t, testProject, "v1.0.0", nil, bundle, "") + _, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) + require.ErrorContains(t, err, "size exceeds dbc's supported range") + require.ErrorContains(t, err, `os="windows" arch="amd64" libc="" variant=""`) + doc, err := resolver.trust.read() + require.NoError(t, err) + require.Nil(t, doc.Projects[testProject], "release trust must be updated only after all targets are finalized") +} + +func TestResolverRetainsSignedListEvidenceWhenReleaseIsAbsentFromList(t *testing.T) { + releaseBytes := makeBundle(setSignerAndTag(t, validRelease("1.0.0"), testProject, "1.0.0", fakeSigner, "v1.0.0")) + server, resolver, fixture := newResolverTestServer(t, testProject, "v1.0.0", nil, releaseBytes, "https://downloads.example/driver.tar.gz") + fixture.list = makeSignedList(t, testProject, 1, "2026-10-01T00:00:00Z", server.URL+"/assets/release.json", releaseBytes, fakeSigner, "0.9.0", "v0.9.0", "") + resolved, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) + require.NoError(t, err) + require.Len(t, resolved.Evidence, 2) + require.Equal(t, resolution.EvidenceKindReleaseIndex, resolved.Evidence[1].Kind) + require.Equal(t, server.URL+"/acme/driver/HEAD/.well-known/packslip.json", resolved.Evidence[1].Location.Value) + require.Equal(t, "sha256:"+digestHex(fixture.list), resolved.Evidence[1].Hash) +} + +func TestResolverOmitsReleaseIndexEvidenceWhenNoListIsObserved(t *testing.T) { + releaseBytes := makeBundle(setSignerAndTag(t, validRelease("1.0.0"), testProject, "1.0.0", fakeSigner, "v1.0.0")) + server, resolver, _ := newResolverTestServer(t, testProject, "v1.0.0", nil, releaseBytes, "https://downloads.example/driver.tar.gz") + resolved, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{ + DriverID: "driver", Version: "1.0.0", + }) + require.NoError(t, err) + require.Equal(t, []resolution.Evidence{{ + Kind: resolution.EvidenceKindReleaseMetadata, + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: server.URL + "/assets/release.json"}, + Hash: "sha256:" + digestHex(releaseBytes), + }}, resolved.Evidence) +} + +func TestFinishResolvedRejectsPartialReleaseIndexEvidence(t *testing.T) { + resolver := &nativeResolver{} + for _, pair := range []struct { + name string + listURL string + listHash string + }{ + {name: "URL without hash", listURL: "https://example.test/index"}, + {name: "hash without URL", listHash: "sha256:" + strings.Repeat("a", 64)}, + } { + t.Run(pair.name, func(t *testing.T) { + _, err := resolver.finishResolved(context.Background(), "", Request{}, listObservation{}, nil, nil, + "", nil, pair.listURL, pair.listHash, nil) + require.ErrorContains(t, err, "requires both a URL and hash") + }) + } +} + +func TestResolverPersistsSequenceBeforeYankAndRejectsRollback(t *testing.T) { + releasePayload := setSignerAndTag(t, validRelease("1.0.0"), testProject, "1.0.0", fakeSigner, "v1.0.0") + releaseBytes := makeBundle(releasePayload) + var listBytes []byte + server, resolver, fixture := newResolverTestServer(t, testProject, "v1.0.0", nil, releaseBytes, "https://downloads.example/driver.tar.gz") + listBytes = makeSignedList(t, testProject, 4, "2026-10-01T00:00:00Z", server.URL+"/assets/release.json", releaseBytes, fakeSigner, "1.0.0", "v1.0.0", "yanked") + fixture.list = listBytes + _, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) + require.ErrorIs(t, err, ErrYankedRelease) + accepted, err := resolver.trust.observeList(testProject) + require.NoError(t, err) + require.True(t, accepted.Accepted) + + var rollback []byte + server, _, fixture = newResolverTestServer(t, testProject, "v1.0.0", nil, releaseBytes, "https://downloads.example/driver.tar.gz") + rollback = makeSignedList(t, testProject, 3, "2026-10-02T00:00:00Z", server.URL+"/assets/release.json", releaseBytes, fakeSigner, "1.0.0", "v1.0.0", "") + fixture.list = rollback + secondStore, err := NewTrustStoreAt(filepath.Join(t.TempDir(), "trust", "packslip.json")) + require.NoError(t, err) + identity, key := testTrustedIdentity(t, fakeSigner) + observed, err := secondStore.observeList(testProject) + require.NoError(t, err) + require.NoError(t, secondStore.acceptResolution(context.Background(), testProject, observed, &listAcceptance{ + Signer: identity, IdentityKey: key, Sequence: 4, Hash: "sha256:" + strings.Repeat("e", 64), + }, nil)) + resolver.trust = secondStore + resolver.discovery, _ = NewGitHubPackslipDiscovery(server.Client(), server.URL, server.URL) + _, err = resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) + require.ErrorContains(t, err, "sequence rollback") +} + +func TestResolverRejectsExpiredListAndAcceptedListDisappearance(t *testing.T) { + releaseBytes := makeBundle(setSignerAndTag(t, validRelease("1.0.0"), testProject, "1.0.0", fakeSigner, "v1.0.0")) + var expired []byte + server, resolver, fixture := newResolverTestServer(t, testProject, "v1.0.0", nil, releaseBytes, "https://downloads.example/driver.tar.gz") + expired = makeSignedList(t, testProject, 1, "2026-09-14T00:00:00Z", server.URL+"/assets/release.json", releaseBytes, fakeSigner, "1.0.0", "v1.0.0", "") + fixture.list = expired + _, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) + require.ErrorContains(t, err, "expired") + accepted, err := resolver.trust.observeList(testProject) + require.NoError(t, err) + require.False(t, accepted.Accepted) + + server, missingResolver, _ := newResolverTestServer(t, testProject, "v1.0.0", nil, releaseBytes, "https://downloads.example/driver.tar.gz") + identity, key := testTrustedIdentity(t, fakeSigner) + observed, err := missingResolver.trust.observeList(testProject) + require.NoError(t, err) + require.NoError(t, missingResolver.trust.acceptResolution(context.Background(), testProject, observed, &listAcceptance{ + Signer: identity, IdentityKey: key, Sequence: 1, Hash: "sha256:" + strings.Repeat("c", 64), + }, nil)) + missingResolver.discovery, _ = NewGitHubPackslipDiscovery(server.Client(), server.URL, server.URL) + _, err = missingResolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) + require.ErrorContains(t, err, "previously accepted signed release list") +} + +func TestResolverRejectsBundleDigestMismatchWrongProjectAndInvalidSignature(t *testing.T) { + releaseBytes := makeBundle(setSignerAndTag(t, validRelease("1.0.0"), testProject, "1.0.0", fakeSigner, "v1.0.0")) + var wrongPin []byte + server, resolver, fixture := newResolverTestServer(t, testProject, "v1.0.0", nil, releaseBytes, "https://downloads.example/driver.tar.gz") + wrongPin = makeSignedList(t, testProject, 1, "2026-10-01T00:00:00Z", server.URL+"/assets/release.json", []byte("different bundle"), fakeSigner, "1.0.0", "v1.0.0", "") + fixture.list = wrongPin + _, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) + require.ErrorContains(t, err, "digest mismatch") + + wrongProjectPayload := setSignerAndTag(t, validRelease("1.0.0"), "github.com/attacker/driver", "1.0.0", fakeSigner, "v1.0.0") + wrongProjectBundle := makeBundle(wrongProjectPayload) + var wrongProjectList []byte + server, resolver, fixture = newResolverTestServer(t, testProject, "v1.0.0", nil, wrongProjectBundle, "https://downloads.example/driver.tar.gz") + wrongProjectList = makeSignedList(t, testProject, 1, "2026-10-01T00:00:00Z", server.URL+"/assets/release.json", wrongProjectBundle, fakeSigner, "1.0.0", "v1.0.0", "") + fixture.list = wrongProjectList + _, err = resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) + require.ErrorContains(t, err, "does not match requested project") + server.Close() + + _, resolver, _ = newResolverTestServer(t, testProject, "v1.0.0", wrongProjectList, wrongProjectBundle, "https://downloads.example/driver.tar.gz") + resolver.verifier = mockPackslipVerifier{issuer: GitHubOIDCIssuer, signer: fakeSigner, err: errors.New("signature is invalid")} + _, err = resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) + require.ErrorContains(t, err, "signature is invalid") +} + +func TestResolveRequiresExactSemVerBeforeDiscovery(t *testing.T) { + _, resolver, fixture := newResolverTestServer(t, testProject, "v1.2.3", nil, nil, "") + for _, version := range []string{"", ">=1.2.3", "latest", "v1.2.3", "1.2"} { + _, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: version}) + require.ErrorContains(t, err, "exact SemVer 2.0.0", "version %q", version) + require.Zero(t, fixture.requestHits.Load(), "version %q must be rejected before transport", version) + } + + _, resolver, fixture = newResolverTestServer(t, testProject, "v1.2.3-rc.1+build.2", nil, nil, "") + _, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{ + DriverID: "driver", Version: "1.2.3-rc.1+build.2", + }) + require.Error(t, err) + require.NotContains(t, err.Error(), "exact SemVer 2.0.0", "valid prerelease and build metadata must pass input validation") + require.Positive(t, fixture.requestHits.Load(), "a valid exact SemVer may proceed to discovery") +} + +func TestResolverUsesVersionBearingTagAndKeepsProjectAsSourceIdentity(t *testing.T) { + release := makeBundle(setSignerAndTag(t, validRelease("1.2.3"), testProject, "1.2.3", fakeSigner, "v1.2.3")) + _, resolver, _ := newResolverTestServer(t, testProject, "v1.2.3", nil, release, "") + + resolved, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{ + DriverID: "driver", Version: "1.2.3", + }) + require.NoError(t, err) + require.Equal(t, "1.2.3", resolved.Version) + require.Equal(t, resolution.SourceSpec{Type: "packslip", Reference: testProject}, resolved.Source) + require.NotEqual(t, "https://downloads.example/driver-linux.tar.gz", resolved.Source.Reference) + require.NotEqual(t, "https://github.com/acme/driver/releases/download/v1.2.3/packslip.sigstore.json", resolved.Source.Reference) + require.Equal(t, "https://dl.example/driver-linux.tar.gz", resolved.Artifacts[0].Location.Value) +} + +func TestResolverAdoptsSignedDriverIDWhenNoExpectedIDIsProvided(t *testing.T) { + release := makeBundle(setSignerAndTag(t, validRelease("1.2.3"), testProject, "1.2.3", fakeSigner, "v1.2.3")) + _, resolver, _ := newResolverTestServer(t, testProject, "v1.2.3", nil, release, "") + + resolved, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{Version: "1.2.3"}) + require.NoError(t, err) + require.Equal(t, "driver", resolved.DriverID) +} + +func TestResolverRejectsExpectedDriverIDThatDiffersFromSignedDeclaration(t *testing.T) { + release := makeBundle(setSignerAndTag(t, validRelease("1.2.3"), testProject, "1.2.3", fakeSigner, "v1.2.3")) + _, resolver, fixture := newResolverTestServer(t, testProject, "v1.2.3", nil, release, "") + + _, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "other", Version: "1.2.3"}) + require.ErrorContains(t, err, `signed dbc driver ID "driver" does not match requested driver ID "other"`) + require.Zero(t, fixture.archiveHits.Load()) +} + +func TestResolverRejectsVersionBearingTagWhenSignedVersionDiffers(t *testing.T) { + release := makeBundle(setSignerAndTag(t, validRelease("2.0.0"), testProject, "2.0.0", fakeSigner, "v1.2.3")) + _, resolver, fixture := newResolverTestServer(t, testProject, "v1.2.3", nil, release, "") + + _, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{ + DriverID: "driver", Version: "1.2.3", + }) + require.ErrorContains(t, err, `GitHub release tag "v1.2.3" maps to 1.2.3 but signed packslip says 2.0.0`) + require.Equal(t, int32(1), fixture.bundleHits.Load()) + require.Zero(t, fixture.archiveHits.Load()) +} + +func TestResolverDoesNotDiscoverArbitraryTagWithoutSignedList(t *testing.T) { + release := makeBundle(setSignerAndTag(t, validRelease("1.2.3"), testProject, "1.2.3", fakeSigner, "nightly")) + _, resolver, fixture := newResolverTestServer(t, testProject, "nightly", nil, release, "") + + _, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{ + DriverID: "driver", Version: "1.2.3", + }) + require.ErrorIs(t, err, ErrReleaseNotFound) + require.Zero(t, fixture.bundleHits.Load(), "unmapped tags must not cause a candidate bundle fetch") + require.Zero(t, fixture.archiveHits.Load(), "resolution must not fall back to downloading an artifact") +} + +func TestResolverUsesSignedListToMapArbitraryTagToExactVersion(t *testing.T) { + release := makeBundle(setSignerAndTag(t, validRelease("1.2.3"), testProject, "1.2.3", fakeSigner, "nightly")) + server, resolver, fixture := newResolverTestServer(t, testProject, "nightly", nil, release, "") + fixture.list = makeSignedList(t, testProject, 1, "2026-10-01T00:00:00Z", server.URL+"/assets/release.json", release, fakeSigner, "1.2.3", "nightly", "") + + resolved, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{ + DriverID: "driver", Version: "1.2.3", + }) + require.NoError(t, err) + require.Equal(t, "1.2.3", resolved.Version) + require.Equal(t, resolution.SourceSpec{Type: "packslip", Reference: testProject}, resolved.Source) + require.Equal(t, "https://dl.example/driver-linux.tar.gz", resolved.Artifacts[0].Location.Value) + require.Equal(t, int32(1), fixture.bundleHits.Load(), "the signed list must authorize fetching the pinned release bundle") +} + +func TestResolverDoesNotFallBackToGenericGitHubArchive(t *testing.T) { + t.Run("missing signed metadata", func(t *testing.T) { + server, resolver, fixture := newResolverTestServer(t, testProject, "v1.0.0", nil, nil, "") + fixture.customReleaseAssets = true + fixture.releaseAssets = []githubAsset{{Name: "driver.tar.gz", BrowserDownloadURL: server.URL + "/assets/driver.tar.gz"}} + _, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) + require.ErrorContains(t, err, "has no packslip bundle asset") + require.Zero(t, fixture.archiveHits.Load(), "an unverified generic archive must never be fetched as a fallback") + }) + t.Run("invalid signature", func(t *testing.T) { + server, resolver, fixture := newResolverTestServer(t, testProject, "v1.0.0", nil, nil, "") + fixture.artifactURL = server.URL + "/assets/driver.tar.gz" + fixture.release = makeBundle(setSignerAndTag(t, validRelease("1.0.0"), testProject, "1.0.0", fakeSigner, "v1.0.0")) + resolver.verifier = mockPackslipVerifier{issuer: GitHubOIDCIssuer, signer: fakeSigner, err: errors.New("signature is invalid")} + _, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) + require.ErrorContains(t, err, "signature is invalid") + require.Zero(t, fixture.archiveHits.Load(), "an invalid signed statement must not unlock generic archive fallback") + }) +} + +func TestResolverRejectsArtifactSelectionAmbiguityAndMalformedHashOrSize(t *testing.T) { + artifact := func(name, osName, arch, format string, size *uint64) releaseArtifact { + return releaseArtifact{Name: name, OS: optionalToken(osName), Arch: optionalToken(arch), Format: ptr(format), Size: size, URL: ptr("https://downloads.example/" + name)} + } + ambiguousPayload := validRelease("1.0.0", + artifact("linux-amd64.tar.gz", "linux", "amd64", "tar.gz", ptr(uint64(1))), + releaseArtifact{Name: "linux-gnu.tar.gz", OS: ptr("linux"), LibC: ptr("gnu"), Format: ptr("tar.gz"), Size: ptr(uint64(1)), URL: ptr("https://downloads.example/linux-gnu.tar.gz")}, + ) + releaseBundle := makeBundle(setSignerAndTag(t, ambiguousPayload, testProject, "1.0.0", fakeSigner, "v1.0.0")) + _, resolver, _ := newResolverTestServer(t, testProject, "v1.0.0", nil, releaseBundle, "https://downloads.example/driver.tar.gz") + _, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) + require.ErrorIs(t, err, ErrAmbiguousArtifact) + doc, err := resolver.trust.read() + require.NoError(t, err) + require.Nil(t, doc.Projects[testProject], "an ambiguous release must not create release trust") + + badDigest := setSignerAndTag(t, validRelease("1.0.0"), testProject, "1.0.0", fakeSigner, "v1.0.0") + var envelope statementEnvelope + require.NoError(t, json.Unmarshal(badDigest, &envelope)) + envelope.Subject[0].Digest["sha256"] = strings.Repeat("d", 64) + badBundle := makeBundle(mustJSON(envelope)) + _, resolver, _ = newResolverTestServer(t, testProject, "v1.0.0", nil, badBundle, "https://downloads.example/driver.tar.gz") + _, err = resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) + // The fake signature verifier accepts the bundle's signed digest; strict + // parsing still validates that it is a well-formed SHA-256 value. + require.NoError(t, err) + + missingSize := setSignerAndTag(t, validRelease("1.0.0"), testProject, "1.0.0", fakeSigner, "v1.0.0") + require.NoError(t, json.Unmarshal(missingSize, &envelope)) + var predicate releasePredicate + require.NoError(t, json.Unmarshal(envelope.Predicate, &predicate)) + predicate.Artifacts[0].Size = nil + envelope.Predicate = mustJSON(predicate) + missingSizeBundle := makeBundle(mustJSON(envelope)) + _, resolver, _ = newResolverTestServer(t, testProject, "v1.0.0", nil, missingSizeBundle, "https://downloads.example/driver.tar.gz") + _, err = resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) + require.ErrorContains(t, err, "no size") + doc, err = resolver.trust.read() + require.NoError(t, err) + require.Nil(t, doc.Projects[testProject], "invalid release metadata must not create release trust") +} + +var _ Resolver = (*nativeResolver)(nil) diff --git a/internal/packslip/selection.go b/internal/packslip/selection.go new file mode 100644 index 00000000..46b6c9e2 --- /dev/null +++ b/internal/packslip/selection.go @@ -0,0 +1,213 @@ +// 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 packslip + +import ( + "errors" + "fmt" + "math" + "strings" + + "github.com/columnar-tech/dbc/internal/resolution" +) + +func selectArtifact(release *parsedRelease, target Target) (*releaseArtifact, error) { + eligible := *release + eligible.predicate.Artifacts = make([]releaseArtifact, 0, len(release.predicate.Artifacts)) + for _, artifact := range release.predicate.Artifacts { + if artifact.dbcArtifact && supportedArchiveFormat(stringValue(artifact.Format)) { + eligible.predicate.Artifacts = append(eligible.predicate.Artifacts, artifact) + } + } + return selectArtifactByFormats(&eligible, target, supportedArchiveFormats) +} + +// selectArtifactByFormats applies Packslip's selector and specificity rules, +// using the caller's ordered list to break ties between equally specific +// formats. It intentionally does not impose dbc's archive-format policy. +func selectArtifactByFormats(release *parsedRelease, target Target, formats []string) (*releaseArtifact, error) { + target = canonicalizeTargetAliases(target) + if err := resolution.ValidateConcreteTarget(target); err != nil { + return nil, err + } + bestSpecificity, bestFormat := -1, len(formats) + var chosen, tied *releaseArtifact + for i := range release.predicate.Artifacts { + artifact := &release.predicate.Artifacts[i] + if !fitsTarget(artifact.OS, target.OS) || !fitsTarget(artifact.Arch, target.Arch) || !fitsTarget(artifact.LibC, target.LibC) { + continue + } + if artifact.Variant == nil && target.Variant != "" || artifact.Variant != nil && *artifact.Variant != target.Variant { + continue + } + formatIndex := -1 + for index, format := range formats { + if *artifact.Format == format { + formatIndex = index + break + } + } + if formatIndex < 0 { + continue + } + specificity := 0 + for _, value := range []*string{artifact.OS, artifact.Arch, artifact.LibC} { + if value != nil { + specificity++ + } + } + if specificity > bestSpecificity || specificity == bestSpecificity && formatIndex < bestFormat { + chosen, tied = artifact, nil + bestSpecificity, bestFormat = specificity, formatIndex + } else if specificity == bestSpecificity && formatIndex == bestFormat { + tied = artifact + } + } + if chosen == nil { + return nil, ErrReleaseNotFound + } + if tied != nil { + return nil, fmt.Errorf("%w: %q and %q", ErrAmbiguousArtifact, chosen.Name, tied.Name) + } + return chosen, nil +} + +var supportedArchiveFormats = []string{"tar.gz", "tgz"} + +func supportedArchiveFormat(format string) bool { + return format == "tar.gz" || format == "tgz" +} + +// validateSupportedArtifactSet ensures the release inventory contains at +// least one artifact that declares dbc membership and an installable +// archive format. Selection ambiguity is checked only for concrete targets. +func validateSupportedArtifactSet(release *parsedRelease) error { + count := 0 + for i := range release.predicate.Artifacts { + if release.predicate.Artifacts[i].dbcArtifact && supportedArchiveFormat(stringValue(release.predicate.Artifacts[i].Format)) { + count++ + } + } + if count == 0 { + return errors.New("packslip release has no dbc artifact with a supported tar.gz or tgz format") + } + return nil +} + +func convertSelectedArtifact(release *parsedRelease, artifact *releaseArtifact, assets []githubAsset, target Target) (resolution.Artifact, error) { + artifactURL := stringValue(artifact.URL) + if artifactURL == "" { + matches := make([]string, 0, 1) + for _, asset := range assets { + if asset.Name == artifact.Name && asset.BrowserDownloadURL != "" { + matches = append(matches, asset.BrowserDownloadURL) + } + } + if len(matches) != 1 { + return resolution.Artifact{}, fmt.Errorf("packslip artifact %q has no unambiguous matching GitHub release asset", artifact.Name) + } + artifactURL = matches[0] + } + if err := validateHTTPSURL(artifactURL); err != nil { + return resolution.Artifact{}, fmt.Errorf("packslip artifact %q URL: %w", artifact.Name, err) + } + return convertArtifact(release, artifact, artifactURL, target) +} + +func artifactSelectorKey(artifact *releaseArtifact) string { + return strings.Join([]string{ + stringValue(artifact.OS), stringValue(artifact.Arch), stringValue(artifact.LibC), + stringValue(artifact.Variant), stringValue(artifact.Format), + }, "|") +} + +func canonicalizeArtifactAliases(artifact *releaseArtifact) { + if artifact.OS != nil { + osName, _ := canonicalizeOSArchAliases(*artifact.OS, "") + artifact.OS = &osName + } + if artifact.Arch != nil { + _, arch := canonicalizeOSArchAliases("", *artifact.Arch) + artifact.Arch = &arch + } +} + +func canonicalizeTargetAliases(target Target) Target { + target.OS, target.Arch = canonicalizeOSArchAliases(target.OS, target.Arch) + return target +} + +func canonicalizeOSArchAliases(osName, arch string) (string, string) { + if osName == "darwin" { + osName = "macos" + } + switch arch { + case "x86_64": + arch = "amd64" + case "aarch64": + arch = "arm64" + } + return osName, arch +} + +func fitsTarget(value *string, target string) bool { + if value == nil { + return true + } + return target != "" && *value == target +} + +func convertArtifact(release *parsedRelease, artifact *releaseArtifact, artifactURL string, target Target) (resolution.Artifact, error) { + if artifact.Size == nil || *artifact.Size > math.MaxInt64 { + return resolution.Artifact{}, fmt.Errorf("packslip artifact %q size exceeds dbc's supported range", artifact.Name) + } + subject, ok := release.byName[artifact.Name] + if !ok { + return resolution.Artifact{}, fmt.Errorf("packslip artifact %q has no subject", artifact.Name) + } + if artifactURL == "" { + return resolution.Artifact{}, fmt.Errorf("packslip artifact %q has no resolved URL", artifact.Name) + } + size := int64(*artifact.Size) + result := resolution.Artifact{ + Target: resolution.CanonicalTarget(target), + Format: stringValue(artifact.Format), + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: artifactURL}, + Hash: "sha256:" + subject.Digest["sha256"], + Size: &size, + } + if artifact.Requires != nil { + requires := artifact.Requires + result.HostRequirements.OSMin = stringValue(requires.OSMin) + result.HostRequirements.GLibCMin = stringValue(requires.GLibCMin) + if requires.Libs != nil { + result.HostRequirements.Libs = append([]string(nil), (*requires.Libs)...) + } + for _, bin := range requires.Bins { + result.HostRequirements.Bins = append(result.HostRequirements.Bins, resolution.NamedRequirement{Name: bin.Name, Min: stringValue(bin.Min)}) + } + } + if err := resolution.ValidateArtifactMetadata(result.Hash, result.Size); err != nil { + return resolution.Artifact{}, err + } + return result, nil +} + +func stringValue(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/internal/packslip/testdata/conformance/LICENSE b/internal/packslip/testdata/conformance/LICENSE new file mode 100644 index 00000000..400a6b17 --- /dev/null +++ b/internal/packslip/testdata/conformance/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jeff Dickey + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/internal/packslip/testdata/conformance/README.md b/internal/packslip/testdata/conformance/README.md new file mode 100644 index 00000000..4d284792 --- /dev/null +++ b/internal/packslip/testdata/conformance/README.md @@ -0,0 +1,12 @@ +# Packslip conformance fixtures + +These fixtures are copied byte for byte from the Packslip source repository: +, commit +`c1a14371667b71f35d60454af8baffe2786ef193`. + +The included fixtures are `artifact-selection.json`, +`statement-validity.json`, and `tag-versions.json`. They are distributed under +the MIT License; see `LICENSE` for the complete license text. + +This subset does not include resource selection cases, signature verification, +or stateful trust rules. diff --git a/internal/packslip/testdata/conformance/artifact-selection.json b/internal/packslip/testdata/conformance/artifact-selection.json new file mode 100644 index 00000000..b3b1f03a --- /dev/null +++ b/internal/packslip/testdata/conformance/artifact-selection.json @@ -0,0 +1,281 @@ +{ + "rule": "https://packslip.dev/release/v1/#selecting-an-artifact", + "description": "Choosing one artifact for a host. `formats` is what the consumer can unpack, best first. `variant` is what the user asked for; null means none. `expect` is the chosen artifact's name, or an error: `no-match` when nothing fits, `ambiguous` when two artifacts fit and nothing tells them apart.", + "cases": [ + { + "name": "a build for the host beats a portable one", + "host": { + "os": "linux", + "arch": "x86_64", + "libc": "gnu" + }, + "variant": null, + "formats": [ + "tar.xz", + "tar.gz" + ], + "artifacts": [ + { + "name": "tool-any.tar.gz", + "size": 1, + "format": "tar.gz" + }, + { + "name": "tool-linux-x64-gnu.tar.gz", + "size": 1, + "os": "linux", + "arch": "x86_64", + "libc": "gnu", + "format": "tar.gz" + } + ], + "expect": { + "artifact": "tool-linux-x64-gnu.tar.gz" + } + }, + { + "name": "among equally specific artifacts the consumer's format preference decides", + "host": { + "os": "linux", + "arch": "x86_64", + "libc": null + }, + "variant": null, + "formats": [ + "tar.xz", + "tar.gz" + ], + "artifacts": [ + { + "name": "tool-linux-x64.tar.gz", + "size": 1, + "os": "linux", + "arch": "x86_64", + "format": "tar.gz" + }, + { + "name": "tool-linux-x64.tar.xz", + "size": 1, + "os": "linux", + "arch": "x86_64", + "format": "tar.xz" + } + ], + "expect": { + "artifact": "tool-linux-x64.tar.xz" + } + }, + { + "name": "the consumer's order decides, not the document's", + "host": { + "os": "linux", + "arch": "x86_64", + "libc": null + }, + "variant": null, + "formats": [ + "tar.gz", + "tar.xz" + ], + "artifacts": [ + { + "name": "tool-linux-x64.tar.xz", + "size": 1, + "os": "linux", + "arch": "x86_64", + "format": "tar.xz" + }, + { + "name": "tool-linux-x64.tar.gz", + "size": 1, + "os": "linux", + "arch": "x86_64", + "format": "tar.gz" + } + ], + "expect": { + "artifact": "tool-linux-x64.tar.gz" + } + }, + { + "name": "two artifacts that still tie are a vendor error, not a guess", + "host": { + "os": "linux", + "arch": "x86_64", + "libc": "gnu" + }, + "variant": null, + "formats": [ + "tar.xz" + ], + "artifacts": [ + { + "name": "tool-linux-x64.tar.xz", + "size": 1, + "os": "linux", + "arch": "x86_64", + "format": "tar.xz" + }, + { + "name": "tool-linux-gnu.tar.xz", + "size": 1, + "os": "linux", + "libc": "gnu", + "format": "tar.xz" + } + ], + "expect": { + "error": "ambiguous" + }, + "comment": "Both name two of os, arch and libc, so neither is more specific, and they share a format so the consumer's preference cannot separate them. Their scopes differ, so this document is valid: the tie is the consumer's to refuse." + }, + { + "name": "a variant is never selected unless it was asked for", + "host": { + "os": "linux", + "arch": "x86_64", + "libc": "gnu" + }, + "variant": null, + "formats": [ + "tar.xz" + ], + "artifacts": [ + { + "name": "tool-fips-linux-x64.tar.xz", + "size": 1, + "os": "linux", + "arch": "x86_64", + "libc": "gnu", + "variant": "fips", + "format": "tar.xz" + }, + { + "name": "tool-linux-x64.tar.xz", + "size": 1, + "os": "linux", + "arch": "x86_64", + "libc": "gnu", + "format": "tar.xz" + } + ], + "expect": { + "artifact": "tool-linux-x64.tar.xz" + } + }, + { + "name": "asking for a variant selects only that variant", + "host": { + "os": "linux", + "arch": "x86_64", + "libc": "gnu" + }, + "variant": "fips", + "formats": [ + "tar.xz" + ], + "artifacts": [ + { + "name": "tool-fips-linux-x64.tar.xz", + "size": 1, + "os": "linux", + "arch": "x86_64", + "libc": "gnu", + "variant": "fips", + "format": "tar.xz" + }, + { + "name": "tool-linux-x64.tar.xz", + "size": 1, + "os": "linux", + "arch": "x86_64", + "libc": "gnu", + "format": "tar.xz" + } + ], + "expect": { + "artifact": "tool-fips-linux-x64.tar.xz" + } + }, + { + "name": "a consumer that does not know its libc cannot take a libc-specific build", + "host": { + "os": "linux", + "arch": "x86_64", + "libc": null + }, + "variant": null, + "formats": [ + "tar.xz" + ], + "artifacts": [ + { + "name": "tool-linux-x64-gnu.tar.xz", + "size": 1, + "os": "linux", + "arch": "x86_64", + "libc": "gnu", + "format": "tar.xz" + }, + { + "name": "tool-linux-x64.tar.xz", + "size": 1, + "os": "linux", + "arch": "x86_64", + "format": "tar.xz" + } + ], + "expect": { + "artifact": "tool-linux-x64.tar.xz" + } + }, + { + "name": "an artifact in a format the consumer cannot unpack does not fit", + "host": { + "os": "windows", + "arch": "x86_64", + "libc": null + }, + "variant": null, + "formats": [ + "zip" + ], + "artifacts": [ + { + "name": "tool-windows-x64.7z", + "size": 1, + "os": "windows", + "arch": "x86_64", + "format": "7z" + } + ], + "expect": { + "error": "no-match" + } + }, + { + "name": "nothing for this host", + "host": { + "os": "freebsd", + "arch": "riscv64", + "libc": null + }, + "variant": null, + "formats": [ + "tar.xz" + ], + "artifacts": [ + { + "name": "tool-linux-x64.tar.xz", + "size": 1, + "os": "linux", + "arch": "x86_64", + "format": "tar.xz" + } + ], + "expect": { + "error": "no-match" + } + } + ] +} diff --git a/internal/packslip/testdata/conformance/statement-validity.json b/internal/packslip/testdata/conformance/statement-validity.json new file mode 100644 index 00000000..eaa8960a --- /dev/null +++ b/internal/packslip/testdata/conformance/statement-validity.json @@ -0,0 +1,688 @@ +{ + "rule": "https://packslip.dev/release/v1/#the-release-statement", + "description": "Whether a release statement is structurally valid, checked after the signature and before anything is installed. `accept` means a consumer proceeds to the digest checks; `reject` means it refuses the document. `reason` quotes the rule at issue. These are payloads, not bundles: signature verification is sigstore's and is not restated here.", + "cases": [ + { + "name": "a minimal release statement", + "expect": "accept", + "statement": { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "tool-linux-x64.tar.xz", + "digest": { + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ], + "predicateType": "https://packslip.dev/release/v1", + "predicate": { + "project": "github.com/example/tool", + "version": "1.0.0", + "published_at": "2026-09-01T00:00:00Z", + "artifacts": [ + { + "name": "tool-linux-x64.tar.xz", + "os": "linux", + "arch": "x86_64", + "libc": "gnu", + "size": 5, + "format": "tar.xz", + "bin": [ + "tool" + ] + } + ], + "identity": { + "scheme": "sigstore-oidc", + "key_id": "https://github.com/example/tool/.github/workflows/release.yml@refs/tags/v1.0.0", + "issuer": "https://token.actions.githubusercontent.com" + } + } + } + }, + { + "name": "two platforms", + "expect": "accept", + "statement": { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "tool-linux-x64.tar.xz", + "digest": { + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + { + "name": "tool-darwin-arm64.tar.xz", + "digest": { + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + } + ], + "predicateType": "https://packslip.dev/release/v1", + "predicate": { + "project": "github.com/example/tool", + "version": "1.0.0", + "published_at": "2026-09-01T00:00:00Z", + "artifacts": [ + { + "name": "tool-linux-x64.tar.xz", + "os": "linux", + "arch": "x86_64", + "libc": "gnu", + "size": 5, + "format": "tar.xz", + "bin": [ + "tool" + ] + }, + { + "name": "tool-darwin-arm64.tar.xz", + "os": "darwin", + "arch": "aarch64", + "size": 5, + "format": "tar.xz", + "bin": [ + "tool" + ] + } + ], + "identity": { + "scheme": "sigstore-oidc", + "key_id": "https://github.com/example/tool/.github/workflows/release.yml@refs/tags/v1.0.0", + "issuer": "https://token.actions.githubusercontent.com" + } + } + } + }, + { + "name": "an unknown field is ignored, not rejected", + "expect": "accept", + "statement": { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "tool-linux-x64.tar.xz", + "digest": { + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ], + "predicateType": "https://packslip.dev/release/v1", + "predicate": { + "project": "github.com/example/tool", + "version": "1.0.0", + "published_at": "2026-09-01T00:00:00Z", + "artifacts": [ + { + "name": "tool-linux-x64.tar.xz", + "os": "linux", + "arch": "x86_64", + "libc": "gnu", + "size": 5, + "format": "tar.xz", + "bin": [ + "tool" + ] + } + ], + "identity": { + "scheme": "sigstore-oidc", + "key_id": "https://github.com/example/tool/.github/workflows/release.yml@refs/tags/v1.0.0", + "issuer": "https://token.actions.githubusercontent.com" + }, + "something_a_later_revision_added": { + "x": 1 + } + } + }, + "reason": "A later revision of version 1 may add optional fields; an older consumer reads the document anyway." + }, + { + "name": "extensions carry data the specification does not define", + "expect": "accept", + "statement": { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "tool-linux-x64.tar.xz", + "digest": { + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ], + "predicateType": "https://packslip.dev/release/v1", + "predicate": { + "project": "github.com/example/tool", + "version": "1.0.0", + "published_at": "2026-09-01T00:00:00Z", + "artifacts": [ + { + "name": "tool-linux-x64.tar.xz", + "os": "linux", + "arch": "x86_64", + "libc": "gnu", + "size": 5, + "format": "tar.xz", + "bin": [ + "tool" + ], + "extensions": { + "mise": { + "install_hint": "brew" + } + } + } + ], + "identity": { + "scheme": "sigstore-oidc", + "key_id": "https://github.com/example/tool/.github/workflows/release.yml@refs/tags/v1.0.0", + "issuer": "https://token.actions.githubusercontent.com" + }, + "extensions": { + "example.com": { + "build_id": "20260901.3" + } + } + } + } + }, + { + "name": "the wrong predicate type", + "expect": "reject", + "statement": { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "tool-linux-x64.tar.xz", + "digest": { + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ], + "predicateType": "https://packslip.dev/releases/v1", + "predicate": { + "project": "github.com/example/tool", + "version": "1.0.0", + "published_at": "2026-09-01T00:00:00Z", + "artifacts": [ + { + "name": "tool-linux-x64.tar.xz", + "os": "linux", + "arch": "x86_64", + "libc": "gnu", + "size": 5, + "format": "tar.xz", + "bin": [ + "tool" + ] + } + ], + "identity": { + "scheme": "sigstore-oidc", + "key_id": "https://github.com/example/tool/.github/workflows/release.yml@refs/tags/v1.0.0", + "issuer": "https://token.actions.githubusercontent.com" + } + } + }, + "reason": "A consumer keeps only the document whose predicateType is release/v1." + }, + { + "name": "a version that is not semver", + "expect": "reject", + "statement": { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "tool-linux-x64.tar.xz", + "digest": { + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ], + "predicateType": "https://packslip.dev/release/v1", + "predicate": { + "project": "github.com/example/tool", + "version": "1.2", + "published_at": "2026-09-01T00:00:00Z", + "artifacts": [ + { + "name": "tool-linux-x64.tar.xz", + "os": "linux", + "arch": "x86_64", + "libc": "gnu", + "size": 5, + "format": "tar.xz", + "bin": [ + "tool" + ] + } + ], + "identity": { + "scheme": "sigstore-oidc", + "key_id": "https://github.com/example/tool/.github/workflows/release.yml@refs/tags/v1.0.0", + "issuer": "https://token.actions.githubusercontent.com" + } + } + }, + "reason": "`version` is a semver 2.0.0 version. A consumer refuses anything else; the tag may still be spelled v1.2." + }, + { + "name": "a project name with a scheme", + "expect": "reject", + "statement": { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "tool-linux-x64.tar.xz", + "digest": { + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ], + "predicateType": "https://packslip.dev/release/v1", + "predicate": { + "project": "https://github.com/example/tool", + "version": "1.0.0", + "published_at": "2026-09-01T00:00:00Z", + "artifacts": [ + { + "name": "tool-linux-x64.tar.xz", + "os": "linux", + "arch": "x86_64", + "libc": "gnu", + "size": 5, + "format": "tar.xz", + "bin": [ + "tool" + ] + } + ], + "identity": { + "scheme": "sigstore-oidc", + "key_id": "https://github.com/example/tool/.github/workflows/release.yml@refs/tags/v1.0.0", + "issuer": "https://token.actions.githubusercontent.com" + } + } + }, + "reason": "A project name is a host with an optional path, with no URL scheme and no trailing slash." + }, + { + "name": "a project host with no dot", + "expect": "reject", + "statement": { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "tool-linux-x64.tar.xz", + "digest": { + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ], + "predicateType": "https://packslip.dev/release/v1", + "predicate": { + "project": "localhost/tool", + "version": "1.0.0", + "published_at": "2026-09-01T00:00:00Z", + "artifacts": [ + { + "name": "tool-linux-x64.tar.xz", + "os": "linux", + "arch": "x86_64", + "libc": "gnu", + "size": 5, + "format": "tar.xz", + "bin": [ + "tool" + ] + } + ], + "identity": { + "scheme": "sigstore-oidc", + "key_id": "https://github.com/example/tool/.github/workflows/release.yml@refs/tags/v1.0.0", + "issuer": "https://token.actions.githubusercontent.com" + } + } + }, + "reason": "The host is lowercase and contains at least one dot." + }, + { + "name": "an empty path segment in the project name", + "expect": "reject", + "statement": { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "tool-linux-x64.tar.xz", + "digest": { + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ], + "predicateType": "https://packslip.dev/release/v1", + "predicate": { + "project": "github.com//tool", + "version": "1.0.0", + "published_at": "2026-09-01T00:00:00Z", + "artifacts": [ + { + "name": "tool-linux-x64.tar.xz", + "os": "linux", + "arch": "x86_64", + "libc": "gnu", + "size": 5, + "format": "tar.xz", + "bin": [ + "tool" + ] + } + ], + "identity": { + "scheme": "sigstore-oidc", + "key_id": "https://github.com/example/tool/.github/workflows/release.yml@refs/tags/v1.0.0", + "issuer": "https://token.actions.githubusercontent.com" + } + } + } + }, + { + "name": "no artifacts", + "expect": "reject", + "statement": { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "tool-linux-x64.tar.xz", + "digest": { + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ], + "predicateType": "https://packslip.dev/release/v1", + "predicate": { + "project": "github.com/example/tool", + "version": "1.0.0", + "published_at": "2026-09-01T00:00:00Z", + "artifacts": [], + "identity": { + "scheme": "sigstore-oidc", + "key_id": "https://github.com/example/tool/.github/workflows/release.yml@refs/tags/v1.0.0", + "issuer": "https://token.actions.githubusercontent.com" + } + } + }, + "reason": "A release statement describes at least one artifact." + }, + { + "name": "an artifact that no subject pins", + "expect": "reject", + "statement": { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "tool-linux-x64.tar.xz", + "digest": { + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ], + "predicateType": "https://packslip.dev/release/v1", + "predicate": { + "project": "github.com/example/tool", + "version": "1.0.0", + "published_at": "2026-09-01T00:00:00Z", + "artifacts": [ + { + "name": "tool-linux-x64-renamed.tar.xz", + "os": "linux", + "arch": "x86_64", + "libc": "gnu", + "size": 5, + "format": "tar.xz", + "bin": [ + "tool" + ] + } + ], + "identity": { + "scheme": "sigstore-oidc", + "key_id": "https://github.com/example/tool/.github/workflows/release.yml@refs/tags/v1.0.0", + "issuer": "https://token.actions.githubusercontent.com" + } + } + }, + "reason": "Every artifact must appear in `subject` with its digest, or nothing signs its bytes." + }, + { + "name": "two artifacts with the same name", + "expect": "reject", + "statement": { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "tool-linux-x64.tar.xz", + "digest": { + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ], + "predicateType": "https://packslip.dev/release/v1", + "predicate": { + "project": "github.com/example/tool", + "version": "1.0.0", + "published_at": "2026-09-01T00:00:00Z", + "artifacts": [ + { + "name": "tool-linux-x64.tar.xz", + "os": "linux", + "arch": "x86_64", + "libc": "gnu", + "size": 5, + "format": "tar.xz", + "bin": [ + "tool" + ] + }, + { + "name": "tool-linux-x64.tar.xz", + "os": "linux", + "arch": "x86_64", + "libc": "gnu", + "size": 5, + "format": "tar.xz", + "bin": [ + "tool" + ] + } + ], + "identity": { + "scheme": "sigstore-oidc", + "key_id": "https://github.com/example/tool/.github/workflows/release.yml@refs/tags/v1.0.0", + "issuer": "https://token.actions.githubusercontent.com" + } + } + } + }, + { + "name": "two artifacts a consumer could not choose between", + "expect": "reject", + "statement": { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "tool-linux-x64.tar.xz", + "digest": { + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + { + "name": "tool-linux-x64-b.tar.xz", + "digest": { + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + } + ], + "predicateType": "https://packslip.dev/release/v1", + "predicate": { + "project": "github.com/example/tool", + "version": "1.0.0", + "published_at": "2026-09-01T00:00:00Z", + "artifacts": [ + { + "name": "tool-linux-x64.tar.xz", + "os": "linux", + "arch": "x86_64", + "libc": "gnu", + "size": 5, + "format": "tar.xz", + "bin": [ + "tool" + ] + }, + { + "name": "tool-linux-x64-b.tar.xz", + "os": "linux", + "arch": "x86_64", + "libc": "gnu", + "size": 5, + "format": "tar.xz", + "bin": [ + "tool" + ] + } + ], + "identity": { + "scheme": "sigstore-oidc", + "key_id": "https://github.com/example/tool/.github/workflows/release.yml@refs/tags/v1.0.0", + "issuer": "https://token.actions.githubusercontent.com" + } + } + }, + "reason": "They share os, arch, libc, variant and format, so nothing in the document tells them apart. The vendor must scope or name them differently." + }, + { + "name": "a published_at that is not a timestamp", + "expect": "reject", + "statement": { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "tool-linux-x64.tar.xz", + "digest": { + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ], + "predicateType": "https://packslip.dev/release/v1", + "predicate": { + "project": "github.com/example/tool", + "version": "1.0.0", + "published_at": "September 2026", + "artifacts": [ + { + "name": "tool-linux-x64.tar.xz", + "os": "linux", + "arch": "x86_64", + "libc": "gnu", + "size": 5, + "format": "tar.xz", + "bin": [ + "tool" + ] + } + ], + "identity": { + "scheme": "sigstore-oidc", + "key_id": "https://github.com/example/tool/.github/workflows/release.yml@refs/tags/v1.0.0", + "issuer": "https://token.actions.githubusercontent.com" + } + } + } + }, + { + "name": "an uppercase token", + "expect": "reject", + "statement": { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "tool-linux-x64.tar.xz", + "digest": { + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ], + "predicateType": "https://packslip.dev/release/v1", + "predicate": { + "project": "github.com/example/tool", + "version": "1.0.0", + "published_at": "2026-09-01T00:00:00Z", + "artifacts": [ + { + "name": "tool-linux-x64.tar.xz", + "os": "Linux", + "arch": "x86_64", + "libc": "gnu", + "size": 5, + "format": "tar.xz", + "bin": [ + "tool" + ] + } + ], + "identity": { + "scheme": "sigstore-oidc", + "key_id": "https://github.com/example/tool/.github/workflows/release.yml@refs/tags/v1.0.0", + "issuer": "https://token.actions.githubusercontent.com" + } + } + }, + "reason": "Vocabulary tokens are lowercase." + }, + { + "name": "an executable name that is a path", + "expect": "reject", + "statement": { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "tool-linux-x64.tar.xz", + "digest": { + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ], + "predicateType": "https://packslip.dev/release/v1", + "predicate": { + "project": "github.com/example/tool", + "version": "1.0.0", + "published_at": "2026-09-01T00:00:00Z", + "artifacts": [ + { + "name": "tool-linux-x64.tar.xz", + "os": "linux", + "arch": "x86_64", + "libc": "gnu", + "size": 5, + "format": "tar.xz", + "bin": [ + { + "name": "sub/tool", + "path": "bin/tool" + } + ] + } + ], + "identity": { + "scheme": "sigstore-oidc", + "key_id": "https://github.com/example/tool/.github/workflows/release.yml@refs/tags/v1.0.0", + "issuer": "https://token.actions.githubusercontent.com" + } + } + }, + "reason": "`bin` names go on PATH; a name cannot contain a separator." + } + ] +} diff --git a/internal/packslip/testdata/conformance/tag-versions.json b/internal/packslip/testdata/conformance/tag-versions.json new file mode 100644 index 00000000..dae63e3e --- /dev/null +++ b/internal/packslip/testdata/conformance/tag-versions.json @@ -0,0 +1,19 @@ +{ + "rule": "https://packslip.dev/release/v1/#tags", + "description": "Which version a forge tag names, so a consumer can list a project's releases from tags alone. `expect` is the version, or null when the tag names none and the release is reachable only through a signed release list. The packslip inside the release remains the authority.", + "cases": [ + { "name": "the common spelling", "tag": "v1.2.3", "project": "github.com/jdx/mise", "expect": "1.2.3" }, + { "name": "no leading v", "tag": "1.2.3", "project": "github.com/jdx/mise", "expect": "1.2.3" }, + { "name": "calver is semver", "tag": "v2026.9.1", "project": "github.com/jdx/mise", "expect": "2026.9.1" }, + { "name": "a two-component tag normalizes", "tag": "v4.1", "project": "github.com/jdx/mise", "expect": "4.1.0" }, + { "name": "leading zeros normalize", "tag": "25.07.1", "project": "github.com/jdx/mise", "expect": "25.7.1" }, + { "name": "a prerelease part survives", "tag": "v1.2.0-rc.1", "project": "github.com/jdx/mise", "expect": "1.2.0-rc.1" }, + { "name": "the repository name may prefix the tag", "tag": "jq-1.7.1", "project": "github.com/jqlang/jq", "expect": "1.7.1" }, + { "name": "a monorepo subpath may prefix it", "tag": "oxlint_v1.0.0", "project": "github.com/oxc-project/oxc/oxlint", "expect": "1.0.0" }, + { "name": "the last segment of a subpath may prefix it", "tag": "cli/v1.9.4", "project": "github.com/biomejs/biome/crates/cli", "expect": "1.9.4" }, + { "name": "an @ separator counts", "tag": "buildifier@v6.4.0", "project": "github.com/bazelbuild/buildtools/buildifier", "expect": "6.4.0" }, + { "name": "a tag naming no version is skipped", "tag": "nightly", "project": "github.com/jdx/mise", "expect": null }, + { "name": "another project's prefix is not this project's", "tag": "otherthing-1.2.3", "project": "github.com/jdx/mise", "expect": null }, + { "name": "a date is not a version tag for this project", "tag": "release-2026-09-01", "project": "github.com/jdx/mise", "expect": null } + ] +} diff --git a/internal/packslip/truststore_native.go b/internal/packslip/truststore_native.go new file mode 100644 index 00000000..9625db19 --- /dev/null +++ b/internal/packslip/truststore_native.go @@ -0,0 +1,391 @@ +// 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 packslip + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/columnar-tech/dbc/internal" + "github.com/columnar-tech/dbc/internal/atomicfile" + "github.com/columnar-tech/dbc/internal/fslock" + "github.com/columnar-tech/dbc/internal/sourceidentity" +) + +const trustStoreVersion = 1 + +type trustDocument struct { + Version int `json:"version"` + Projects map[string]*projectTrust `json:"projects"` +} + +type projectTrust struct { + ReleaseSigner *trustedIdentity `json:"release_signer,omitempty"` + ListSigner *trustedIdentity `json:"list_signer,omitempty"` + AttestedBy string `json:"attested_by,omitempty"` + Provenance map[string]bool `json:"provenance,omitempty"` + Lists map[string]listTrust `json:"lists,omitempty"` +} + +type trustedIdentity struct { + Scheme string `json:"scheme"` + Issuer string `json:"issuer"` + Workflow string `json:"workflow"` +} + +type listTrust struct { + Accepted bool `json:"accepted"` + Sequence uint64 `json:"sequence"` + Hash string `json:"hash"` +} + +type listObservation struct { + Accepted bool + IdentityKey string + Sequence uint64 + Hash string +} + +type listAcceptance struct { + Signer trustedIdentity + IdentityKey string + Sequence uint64 + Hash string +} + +type releaseAcceptance struct { + Signer trustedIdentity + Attested string + Provenance map[string]bool +} + +// NewTrustStore returns the dbc-local persistent packslip trust store at +// $XDG_CONFIG_HOME/columnar/dbc/trust/packslip.json (or the platform's user +// config equivalent). +func NewTrustStore() (*TrustStore, error) { + configPath, err := internal.GetUserConfigPath() + if err != nil { + return nil, fmt.Errorf("find dbc user config directory for packslip trust: %w", err) + } + return NewTrustStoreAt(filepath.Join(configPath, "trust", "packslip.json")) +} + +// NewTrustStoreAt constructs a persistent trust store at path. The file is +// created on first accepted metadata update, not during construction. +func NewTrustStoreAt(path string) (*TrustStore, error) { + if !filepath.IsAbs(path) { + return nil, fmt.Errorf("packslip trust-store path must be absolute: %q", path) + } + return &TrustStore{path: filepath.Clean(path)}, nil +} + +func (s *TrustStore) observeList(project string) (listObservation, error) { + doc, err := s.read() + if err != nil { + return listObservation{}, err + } + state := doc.Projects[project] + if state == nil { + return listObservation{}, nil + } + var observed listObservation + for identityKey, list := range state.Lists { + if list.Accepted { + if observed.Accepted { + return listObservation{}, fmt.Errorf("packslip trust store has multiple accepted release lists for %s", project) + } + observed = listObservation{Accepted: true, IdentityKey: identityKey, Sequence: list.Sequence, Hash: list.Hash} + } + } + return observed, nil +} + +// acceptResolution compares the trust state observed before network work with +// the latest state under one lock, then commits the verified list and release +// together. A stale resolution cannot overwrite newer yanks or sequences. +func (s *TrustStore) acceptResolution(ctx context.Context, project string, observed listObservation, list *listAcceptance, release *releaseAcceptance) error { + return s.update(ctx, project, func(state *projectTrust) error { + current := acceptedListObservation(state) + if current != observed { + return fmt.Errorf("packslip trust state changed during resolution for %s; retry resolution", project) + } + if list != nil { + if err := applyListAcceptance(state, project, *list); err != nil { + return err + } + } + if release != nil { + if err := applyReleaseAcceptance(state, project, *release); err != nil { + return err + } + } + return nil + }) +} + +func acceptedListObservation(state *projectTrust) listObservation { + if state == nil { + return listObservation{} + } + for identityKey, list := range state.Lists { + if list.Accepted { + return listObservation{Accepted: true, IdentityKey: identityKey, Sequence: list.Sequence, Hash: list.Hash} + } + } + return listObservation{} +} + +func applyListAcceptance(state *projectTrust, project string, candidate listAcceptance) error { + if state.Lists == nil { + state.Lists = make(map[string]listTrust) + } + for previousIdentity, previous := range state.Lists { + if previous.Accepted && previousIdentity != candidate.IdentityKey { + return fmt.Errorf("signed release-list identity changed for %s", project) + } + } + previous := state.Lists[candidate.IdentityKey] + if previous.Accepted { + if candidate.Sequence < previous.Sequence { + return fmt.Errorf("signed release-list sequence rollback for %s: got %d after %d", project, candidate.Sequence, previous.Sequence) + } + if candidate.Sequence == previous.Sequence && candidate.Hash != previous.Hash { + return fmt.Errorf("signed release list changed without increasing sequence for %s", project) + } + } + if state.ListSigner != nil && !sameIdentity(*state.ListSigner, candidate.Signer) { + return fmt.Errorf("signed release-list signer changed for %s", project) + } + if state.ReleaseSigner != nil && !sameIdentity(*state.ReleaseSigner, candidate.Signer) { + return fmt.Errorf("signed release-list signer differs from release signer for %s", project) + } + copy := candidate.Signer + state.ListSigner = © + state.Lists[candidate.IdentityKey] = listTrust{Accepted: true, Sequence: candidate.Sequence, Hash: candidate.Hash} + return nil +} + +func applyReleaseAcceptance(state *projectTrust, project string, candidate releaseAcceptance) error { + if state.ReleaseSigner != nil && !sameIdentity(*state.ReleaseSigner, candidate.Signer) { + return fmt.Errorf("release signer changed for %s", project) + } + if state.ListSigner != nil && !sameIdentity(*state.ListSigner, candidate.Signer) { + return fmt.Errorf("release signer differs from signed release-list signer for %s", project) + } + if state.AttestedBy == "vendor" && candidate.Attested == "repackager" { + return fmt.Errorf("release attestation changed from vendor to repackager for %s", project) + } + previousProvenance := canonicalizeProvenanceSelectors(state.Provenance) + candidateProvenance := canonicalizeProvenanceSelectors(candidate.Provenance) + for selector, wasPresent := range previousProvenance { + if wasPresent && !candidateProvenance[selector] { + return fmt.Errorf("release provenance disappeared for artifact selector %s", selector) + } + } + copy := candidate.Signer + state.ReleaseSigner = © + if state.AttestedBy == "" || candidate.Attested == "vendor" { + state.AttestedBy = candidate.Attested + } + mergedProvenance := previousProvenance + if mergedProvenance == nil && candidateProvenance != nil { + mergedProvenance = make(map[string]bool, len(candidateProvenance)) + } + for selector, present := range candidateProvenance { + if present || !mergedProvenance[selector] { + mergedProvenance[selector] = present + } + } + state.Provenance = mergedProvenance + return nil +} + +// canonicalProvenanceSelectorKey normalizes only established OS and +// architecture aliases in the five-field Packslip selector key. A malformed +// key or an unknown token is preserved byte-for-byte so existing trust is not +// accidentally broadened. In particular, an empty libc remains a wildcard. +func canonicalProvenanceSelectorKey(selector string) string { + parts := strings.Split(selector, "|") + if len(parts) != 5 { + return selector + } + for _, part := range parts { + if part != "" && !validToken(part) { + return selector + } + } + switch parts[0] { + case "darwin": + parts[0] = "macos" + } + switch parts[1] { + case "x86_64": + parts[1] = "amd64" + case "aarch64": + parts[1] = "arm64" + } + return strings.Join(parts, "|") +} + +func canonicalizeProvenanceSelectors(provenance map[string]bool) map[string]bool { + if provenance == nil { + return nil + } + canonical := make(map[string]bool, len(provenance)) + for selector, present := range provenance { + key := canonicalProvenanceSelectorKey(selector) + if present || !canonical[key] { + canonical[key] = present + } + } + return canonical +} + +func (s *TrustStore) update(ctx context.Context, project string, mutate func(*projectTrust) error) error { + if err := ctx.Err(); err != nil { + return err + } + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create packslip trust-store directory: %w", err) + } + lock, err := fslock.AcquireContext(ctx, filepath.Join(dir, "packslip.lock")) + if err != nil { + return fmt.Errorf("acquire packslip trust-store lock: %w", err) + } + writeErr := func() error { + doc, err := s.read() + if err != nil { + return err + } + state := doc.Projects[project] + if state == nil { + state = &projectTrust{} + doc.Projects[project] = state + } + if err := mutate(state); err != nil { + return err + } + return s.write(doc) + }() + releaseErr := lock.Release() + if writeErr != nil && releaseErr != nil { + return errors.Join(writeErr, fmt.Errorf("release packslip trust-store lock: %w", releaseErr)) + } + if writeErr != nil { + return writeErr + } + if releaseErr != nil { + return fmt.Errorf("release packslip trust-store lock: %w", releaseErr) + } + return nil +} + +func (s *TrustStore) read() (trustDocument, error) { + data, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return trustDocument{Version: trustStoreVersion, Projects: make(map[string]*projectTrust)}, nil + } + if err != nil { + return trustDocument{}, fmt.Errorf("read packslip trust store: %w", err) + } + var doc trustDocument + if err := decodeStrict(data, &doc); err != nil { + return trustDocument{}, fmt.Errorf("decode packslip trust store: %w", err) + } + if doc.Version != trustStoreVersion || doc.Projects == nil { + return trustDocument{}, fmt.Errorf("unsupported or incomplete packslip trust-store format") + } + for project, state := range doc.Projects { + key, err := sourceidentity.Parse(sourceidentity.Packslip, project) + if err != nil || key.Reference != project || state == nil { + return trustDocument{}, fmt.Errorf("invalid project entry in packslip trust store: %q", project) + } + acceptedCount := 0 + for identityKey, list := range state.Lists { + if strings.TrimSpace(identityKey) == "" || (list.Accepted && list.Hash == "") { + return trustDocument{}, fmt.Errorf("invalid signed release-list state for %s", project) + } + if list.Accepted { + acceptedCount++ + } + } + if acceptedCount > 1 || state.ListSigner != nil && acceptedCount == 0 { + return trustDocument{}, fmt.Errorf("inconsistent accepted release-list state for %s", project) + } + } + return doc, nil +} + +func (s *TrustStore) write(doc trustDocument) error { + data, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return fmt.Errorf("encode packslip trust store: %w", err) + } + if err := atomicfile.WriteFile(s.path, data, 0o600); err != nil { + return fmt.Errorf("write packslip trust store: %w", err) + } + return nil +} + +func sameIdentity(left, right trustedIdentity) bool { + return left.Scheme == right.Scheme && left.Issuer == right.Issuer && left.Workflow == right.Workflow +} + +func identityTrust(scheme, issuer, signer string) (trustedIdentity, string, error) { + if scheme != "sigstore-oidc" || issuer != GitHubOIDCIssuer { + return trustedIdentity{}, "", fmt.Errorf("unsupported GitHub packslip signer identity") + } + workflow, err := workflowContinuityKey(signer) + if err != nil { + return trustedIdentity{}, "", err + } + identity := trustedIdentity{Scheme: scheme, Issuer: issuer, Workflow: workflow} + return identity, scheme + "|" + issuer + "|" + workflow, nil +} + +func workflowContinuityKey(signer string) (string, error) { + separator := strings.LastIndexByte(signer, '@') + if separator < 0 || separator == len(signer)-1 { + return "", fmt.Errorf("GitHub signer identity %q is not a GitHub workflow identity", signer) + } + identity := signer[:separator] + colon := strings.Index(identity, "://") + if colon < 0 || !strings.EqualFold(identity[:colon], "https") { + return "", fmt.Errorf("GitHub signer identity %q is not a GitHub workflow identity", signer) + } + path := identity[colon+3:] + segments := strings.SplitN(path, "/", 2) + if len(segments) != 2 || !strings.EqualFold(segments[0], "github.com") { + return "", fmt.Errorf("GitHub signer identity %q is not a GitHub workflow identity", signer) + } + parts := strings.Split(segments[1], "/") + if len(parts) < 5 || parts[0] == "" || parts[1] == "" || parts[2] != ".github" || parts[3] != "workflows" { + return "", fmt.Errorf("GitHub signer identity %q is not a workflow path", signer) + } + for _, part := range parts { + if part == "" || part == "." || part == ".." { + return "", fmt.Errorf("GitHub signer identity %q has an invalid path", signer) + } + } + return "https://github.com/" + strings.ToLower(parts[0]) + "/" + strings.ToLower(parts[1]) + "/" + strings.Join(parts[2:], "/"), nil +} diff --git a/internal/packslip/truststore_native_test.go b/internal/packslip/truststore_native_test.go new file mode 100644 index 00000000..10287312 --- /dev/null +++ b/internal/packslip/truststore_native_test.go @@ -0,0 +1,303 @@ +// 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 packslip + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +func testTrustedIdentity(t *testing.T, workflow string) (trustedIdentity, string) { + t.Helper() + identity, key, err := identityTrust("sigstore-oidc", GitHubOIDCIssuer, workflow) + require.NoError(t, err) + return identity, key +} + +func testTrustStore(t *testing.T) *TrustStore { + t.Helper() + store, err := NewTrustStoreAt(filepath.Join(t.TempDir(), "trust", "packslip.json")) + require.NoError(t, err) + return store +} + +func acceptTestRelease(t *testing.T, store *TrustStore, identity trustedIdentity, provenance map[string]bool, attested string) error { + t.Helper() + observed, err := store.observeList(testProject) + if err != nil { + return err + } + return store.acceptResolution(context.Background(), testProject, observed, nil, &releaseAcceptance{ + Signer: identity, Attested: attested, Provenance: provenance, + }) +} + +func acceptTestList(t *testing.T, store *TrustStore, identity trustedIdentity, identityKey string, sequence uint64, hash string) error { + t.Helper() + observed, err := store.observeList(testProject) + if err != nil { + return err + } + return store.acceptResolution(context.Background(), testProject, observed, &listAcceptance{ + Signer: identity, IdentityKey: identityKey, Sequence: sequence, Hash: hash, + }, nil) +} + +func seedTestProvenance(t *testing.T, store *TrustStore, identity trustedIdentity, provenance map[string]bool) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(store.path), 0o700)) + identityCopy := identity + doc := trustDocument{ + Version: trustStoreVersion, + Projects: map[string]*projectTrust{ + testProject: { + ReleaseSigner: &identityCopy, + AttestedBy: "vendor", + Provenance: provenance, + }, + }, + } + require.NoError(t, store.write(doc)) +} + +func TestTrustStoreAllowsGitHubRefChangesButRejectsWorkflowPathChanges(t *testing.T) { + store := testTrustStore(t) + first, _ := testTrustedIdentity(t, "https://github.com/acme/driver/.github/workflows/release.yml@refs/tags/v1.0.0") + refUpdate, _ := testTrustedIdentity(t, "https://github.com/acme/driver/.github/workflows/release.yml@refs/tags/v1.1.0") + pathUpdate, _ := testTrustedIdentity(t, "https://github.com/acme/driver/.github/workflows/publish.yml@refs/tags/v1.2.0") + require.NoError(t, acceptTestRelease(t, store, first, map[string]bool{"linux|x86_64|gnu||tar.gz": true}, "vendor")) + require.NoError(t, acceptTestRelease(t, store, refUpdate, map[string]bool{"linux|x86_64|gnu||tar.gz": true}, "vendor")) + require.ErrorContains(t, acceptTestRelease(t, store, pathUpdate, map[string]bool{"linux|x86_64|gnu||tar.gz": true}, "vendor"), "release signer changed") +} + +func TestGitHubSignerHostOwnerRepoAreCanonicalButWorkflowPathIsCaseSensitive(t *testing.T) { + policy, err := githubIdentityPolicy("GitHub.COM/ACME/DRIVER/Arrow/Flight") + require.NoError(t, err) + require.True(t, policyMatchesSigner(policy, "https://GITHUB.COM/AcMe/Driver/.github/workflows/release.yml@refs/tags/v1")) + + canonical, canonicalKey, err := identityTrust("sigstore-oidc", GitHubOIDCIssuer, "https://github.com/acme/driver/.github/workflows/release.yml@refs/tags/v1") + require.NoError(t, err) + mixed, mixedKey, err := identityTrust("sigstore-oidc", GitHubOIDCIssuer, "https://GITHUB.COM/ACME/DRIVER/.github/workflows/release.yml@refs/tags/v2") + require.NoError(t, err) + require.Equal(t, canonicalKey, mixedKey) + require.True(t, sameIdentity(canonical, mixed)) + + changedPath, changedPathKey, err := identityTrust("sigstore-oidc", GitHubOIDCIssuer, "https://GITHUB.COM/ACME/DRIVER/.github/workflows/Release.yml@refs/tags/v3") + require.NoError(t, err) + require.NotEqual(t, canonicalKey, changedPathKey) + require.False(t, sameIdentity(canonical, changedPath)) +} + +func TestTrustStoreRejectsAttestationAndProvenanceDowngrades(t *testing.T) { + store := testTrustStore(t) + identity, _ := testTrustedIdentity(t, "https://github.com/acme/driver/.github/workflows/release.yml@refs/heads/main") + initial := map[string]bool{"linux|x86_64|gnu||tar.gz": true, "darwin|aarch64|||tgz": false} + require.NoError(t, acceptTestRelease(t, store, identity, initial, "vendor")) + require.ErrorContains(t, acceptTestRelease(t, store, identity, initial, "repackager"), "vendor to repackager") + require.ErrorContains(t, acceptTestRelease(t, store, identity, map[string]bool{"linux|x86_64|gnu||tar.gz": false}, "vendor"), "provenance disappeared") +} + +func TestTrustStoreCanonicalizesPersistedProvenanceAliases(t *testing.T) { + store := testTrustStore(t) + identity, _ := testTrustedIdentity(t, "https://github.com/acme/driver/.github/workflows/release.yml@refs/heads/main") + seedTestProvenance(t, store, identity, map[string]bool{ + "linux|x86_64|gnu||tar.gz": true, + "darwin|aarch64|||tgz": true, + }) + require.NoError(t, acceptTestRelease(t, store, identity, map[string]bool{ + "linux|amd64|gnu||tar.gz": true, + "macos|arm64|||tgz": true, + }, "vendor")) + + doc, err := store.read() + require.NoError(t, err) + require.Equal(t, map[string]bool{ + "linux|amd64|gnu||tar.gz": true, + "macos|arm64|||tgz": true, + }, doc.Projects[testProject].Provenance) +} + +func TestTrustStoreCanonicalProvenanceDowngradeStillFails(t *testing.T) { + tests := []struct { + name string + candidate map[string]bool + }{ + {name: "false", candidate: map[string]bool{"linux|amd64|gnu||tar.gz": false}}, + {name: "missing", candidate: map[string]bool{}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + store := testTrustStore(t) + identity, _ := testTrustedIdentity(t, "https://github.com/acme/driver/.github/workflows/release.yml@refs/heads/main") + seedTestProvenance(t, store, identity, map[string]bool{"linux|x86_64|gnu||tar.gz": true}) + require.ErrorContains(t, acceptTestRelease(t, store, identity, test.candidate, "vendor"), "provenance disappeared") + }) + } +} + +func TestTrustStoreProvenanceAliasCollisionPreservesTrue(t *testing.T) { + store := testTrustStore(t) + identity, _ := testTrustedIdentity(t, "https://github.com/acme/driver/.github/workflows/release.yml@refs/heads/main") + seedTestProvenance(t, store, identity, map[string]bool{ + "linux|x86_64|gnu||tar.gz": true, + "linux|amd64|gnu||tar.gz": false, + }) + require.NoError(t, acceptTestRelease(t, store, identity, map[string]bool{ + "linux|amd64|gnu||tar.gz": true, + }, "vendor")) + + doc, err := store.read() + require.NoError(t, err) + require.Equal(t, map[string]bool{"linux|amd64|gnu||tar.gz": true}, doc.Projects[testProject].Provenance) +} + +func TestProvenanceSelectorKeyNormalizationPreservesLibcWildcardAndUnknownKeys(t *testing.T) { + require.Equal(t, "linux|amd64|||tar.gz", canonicalProvenanceSelectorKey("linux|x86_64|||tar.gz"), + "empty libc remains empty rather than becoming GNU") + require.Equal(t, "custom|mips64|unknown||tar.gz", canonicalProvenanceSelectorKey("custom|mips64|unknown||tar.gz")) + require.Equal(t, "malformed|key", canonicalProvenanceSelectorKey("malformed|key")) +} + +func TestTrustStorePersistsListSequencesAndRejectsRollbackOrSameSequenceReplacement(t *testing.T) { + store := testTrustStore(t) + identity, identityKey := testTrustedIdentity(t, "https://github.com/acme/driver/.github/workflows/release.yml@refs/heads/main") + require.NoError(t, acceptTestList(t, store, identity, identityKey, 8, "sha256:first")) + observed, err := store.observeList(testProject) + require.NoError(t, err) + require.True(t, observed.Accepted) + require.NoError(t, acceptTestList(t, store, identity, identityKey, 9, "sha256:second")) + require.ErrorContains(t, acceptTestList(t, store, identity, identityKey, 8, "sha256:first"), "sequence rollback") + require.ErrorContains(t, acceptTestList(t, store, identity, identityKey, 9, "sha256:changed"), "without increasing sequence") +} + +func TestTrustStoreRejectsStaleNoListResolutionAfterNewerYankIsAccepted(t *testing.T) { + store := testTrustStore(t) + oldObservation, err := store.observeList(testProject) + require.NoError(t, err) + require.False(t, oldObservation.Accepted) + + identity, identityKey := testTrustedIdentity(t, "https://github.com/acme/driver/.github/workflows/release.yml@refs/heads/main") + // This is the newer signed list accepted by a concurrent resolution that + // discovered the requested release had been yanked. + require.NoError(t, store.acceptResolution(context.Background(), testProject, oldObservation, &listAcceptance{ + Signer: identity, IdentityKey: identityKey, Sequence: 12, Hash: "sha256:newer-yanked-list", + }, nil)) + + // The delayed caller observed no list and resolved an older endpoint release. + // Its final transaction must compare against current state and reject it. + err = store.acceptResolution(context.Background(), testProject, oldObservation, nil, &releaseAcceptance{ + Signer: identity, Attested: "vendor", Provenance: map[string]bool{"linux|x86_64|gnu||tar.gz": true}, + }) + require.ErrorContains(t, err, "trust state changed during resolution") + + current, err := store.observeList(testProject) + require.NoError(t, err) + require.Equal(t, uint64(12), current.Sequence) + doc, err := store.read() + require.NoError(t, err) + require.Nil(t, doc.Projects[testProject].ReleaseSigner, "stale resolution must not accept release trust") +} + +func TestTrustStoreCommitsListAndReleaseAcceptanceAtomically(t *testing.T) { + store := testTrustStore(t) + firstIdentity, _ := testTrustedIdentity(t, "https://github.com/acme/driver/.github/workflows/release.yml@refs/heads/main") + require.NoError(t, acceptTestRelease(t, store, firstIdentity, map[string]bool{"linux|x86_64|gnu||tar.gz": true}, "vendor")) + + observed, err := store.observeList(testProject) + require.NoError(t, err) + changedIdentity, changedKey := testTrustedIdentity(t, "https://github.com/acme/driver/.github/workflows/publish.yml@refs/heads/main") + err = store.acceptResolution(context.Background(), testProject, observed, &listAcceptance{ + Signer: changedIdentity, IdentityKey: changedKey, Sequence: 5, Hash: "sha256:list", + }, &releaseAcceptance{ + Signer: changedIdentity, Attested: "vendor", Provenance: map[string]bool{"linux|x86_64|gnu||tar.gz": true}, + }) + require.ErrorContains(t, err, "differs from release signer") + + current, err := store.observeList(testProject) + require.NoError(t, err) + require.False(t, current.Accepted, "the list half must not be written if release trust fails") + doc, err := store.read() + require.NoError(t, err) + require.Equal(t, firstIdentity.Workflow, doc.Projects[testProject].ReleaseSigner.Workflow) +} + +func TestTrustStoreConcurrentProjectUpdatesDoNotLoseState(t *testing.T) { + store := testTrustStore(t) + identity, identityKey := testTrustedIdentity(t, "https://github.com/acme/driver/.github/workflows/release.yml@refs/heads/main") + const projects = 16 + var wait sync.WaitGroup + errors := make(chan error, projects) + for i := 0; i < projects; i++ { + project := "github.com/acme/driver" + strings.Repeat("x", i+1) + wait.Add(1) + go func() { + defer wait.Done() + observed, err := store.observeList(project) + if err == nil { + err = store.acceptResolution(context.Background(), project, observed, &listAcceptance{ + Signer: identity, IdentityKey: identityKey, Sequence: 1, Hash: "sha256:" + project, + }, nil) + } + errors <- err + }() + } + wait.Wait() + close(errors) + for err := range errors { + require.NoError(t, err) + } + for i := 0; i < projects; i++ { + project := "github.com/acme/driver" + strings.Repeat("x", i+1) + observed, err := store.observeList(project) + require.NoError(t, err) + require.True(t, observed.Accepted, "state for %s was lost", project) + } +} + +func TestTrustStoreCorruptionFailsClosedWithoutFirstUseFallback(t *testing.T) { + path := filepath.Join(t.TempDir(), "trust", "packslip.json") + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + corrupt := []byte(`{"version":1,"projects":{`) + require.NoError(t, os.WriteFile(path, corrupt, 0o600)) + store, err := NewTrustStoreAt(path) + require.NoError(t, err) + observed, err := store.observeList(testProject) + require.ErrorContains(t, err, "decode packslip trust store") + require.False(t, observed.Accepted) + identity, identityKey := testTrustedIdentity(t, "https://github.com/acme/driver/.github/workflows/release.yml@refs/heads/main") + require.ErrorContains(t, acceptTestList(t, store, identity, identityKey, 1, "sha256:first"), "decode packslip trust store") + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, corrupt, after) +} + +func TestTrustStoreKeepsStrictUnknownFieldRejection(t *testing.T) { + path := filepath.Join(t.TempDir(), "trust", "packslip.json") + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + data := []byte(`{"version":1,"projects":{},"future_field":true}`) + require.NoError(t, os.WriteFile(path, data, 0o600)) + store, err := NewTrustStoreAt(path) + require.NoError(t, err) + _, err = store.observeList(testProject) + require.ErrorContains(t, err, "unknown field") +} diff --git a/internal/packslip/types.go b/internal/packslip/types.go new file mode 100644 index 00000000..355b1d69 --- /dev/null +++ b/internal/packslip/types.go @@ -0,0 +1,104 @@ +// 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 packslip resolves GitHub-hosted packslip/v1 release statements into +// dbc's source-independent resolution model. +package packslip + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/columnar-tech/dbc/internal/packslipverify" + "github.com/columnar-tech/dbc/internal/resolution" +) + +const ( + ReleasePredicateType = "https://packslip.dev/release/v1" + ListPredicateType = "https://packslip.dev/releases/v1" + StatementType = "https://in-toto.io/Statement/v1" + GitHubOIDCIssuer = "https://token.actions.githubusercontent.com" +) + +var ( + ErrUnsupported = errors.New("packslip resolution is unsupported on this target") + ErrReleaseNotFound = errors.New("packslip release was not found") + ErrYankedRelease = errors.New("packslip release is yanked") + ErrAmbiguousArtifact = errors.New("packslip artifact selection is ambiguous") +) + +// PackslipSource identifies a project without tying the core model to a +// transport. GitHub is the first discovery implementation. +type PackslipSource struct { + Project string +} + +// Target is the canonical platform tuple used in a resolution snapshot. +type Target = resolution.Target + +// Request identifies one project release whose dbc installable artifacts are +// snapshotted for every concrete target advertised by the signed release. A +// nonempty DriverID is an expected value that must match the signed dbc +// declaration; an empty value adopts that declaration for direct installs. +type Request struct { + DriverID string + Version string +} + +// TrustStore retains the identities and signed-list state accepted by dbc. +// Its native implementation persists state under dbc's user config path. +type TrustStore struct { + path string +} + +type githubRelease struct { + TagName string `json:"tag_name"` + Draft bool `json:"draft"` + Assets []githubAsset `json:"assets"` +} + +type githubAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +// Config controls release discovery. Empty GitHub base URLs use the public +// GitHub endpoints; custom endpoints are primarily useful for enterprise +// deployments and deterministic tests. +type Config struct { + HTTPClient *http.Client + Verifier packslipverify.PackslipVerifier + TrustedRootJSON []byte + TrustStore *TrustStore + GitHubAPIBaseURL string + GitHubRawBaseURL string + Now func() time.Time +} + +// Resolver resolves authenticated release metadata into a release-level +// snapshot of all concrete targets supported by dbc. +type Resolver interface { + Resolve(context.Context, PackslipSource, Request) (resolution.ResolvedRelease, error) +} + +type resolutionError struct { + operation string + err error +} + +func (e *resolutionError) Error() string { return fmt.Sprintf("%s: %v", e.operation, e.err) } +func (e *resolutionError) Unwrap() error { return e.err } diff --git a/internal/packslip/wire.go b/internal/packslip/wire.go new file mode 100644 index 00000000..2a6e1e86 --- /dev/null +++ b/internal/packslip/wire.go @@ -0,0 +1,158 @@ +// 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 packslip + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" +) + +func decodeStrict(data []byte, destination any) error { + if err := rejectDuplicateKeys(data); err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + return err + } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("multiple JSON values") + } + return err + } + return nil +} + +// decodeWire keeps the structural protections required for packslip payloads +// while allowing future optional fields to be ignored by this consumer. +// Trust-store files intentionally continue to use decodeStrict. +func decodeWire(data []byte, destination any) error { + if err := rejectDuplicateKeys(data); err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(data)) + if err := decoder.Decode(destination); err != nil { + return err + } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("multiple JSON values") + } + return err + } + return nil +} + +func rejectDuplicateKeys(data []byte) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := scanJSONValue(decoder); err != nil { + return err + } + if _, err := decoder.Token(); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("multiple JSON values") + } + return err + } + return nil +} + +func scanJSONValue(decoder *json.Decoder) error { + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, ok := token.(json.Delim) + if !ok { + return nil + } + switch delimiter { + case '{': + seen := map[string]bool{} + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return errors.New("JSON object key is not a string") + } + if seen[key] { + return fmt.Errorf("duplicate JSON field %q", key) + } + seen[key] = true + if err := scanJSONValue(decoder); err != nil { + return err + } + } + _, err = decoder.Token() + return err + case '[': + for decoder.More() { + if err := scanJSONValue(decoder); err != nil { + return err + } + } + _, err = decoder.Token() + return err + default: + return fmt.Errorf("unexpected JSON delimiter %q", delimiter) + } +} + +// peekBundleDigest reads only the digest needed to invoke the cryptographic +// verifier. Its result is untrusted and is never used to establish identity or +// accept metadata; strict parsing happens only after Verify succeeds. +func peekBundleDigest(bundleJSON []byte) (string, string, []byte, error) { + var raw struct { + Envelope struct { + Payload string `json:"payload"` + PayloadType string `json:"payloadType"` + } `json:"dsseEnvelope"` + } + if err := json.Unmarshal(bundleJSON, &raw); err != nil { + return "", "", nil, fmt.Errorf("decode Sigstore bundle envelope: %w", err) + } + if raw.Envelope.PayloadType != "application/vnd.in-toto+json" { + return "", "", nil, fmt.Errorf("Sigstore bundle payload type %q is not in-toto JSON", raw.Envelope.PayloadType) + } + payload, err := base64.StdEncoding.DecodeString(raw.Envelope.Payload) + if err != nil { + return "", "", nil, fmt.Errorf("decode Sigstore bundle payload: %w", err) + } + var candidate struct { + Subject []subject `json:"subject"` + } + if err := json.Unmarshal(payload, &candidate); err != nil { + return "", "", nil, fmt.Errorf("peek Sigstore statement subject: %w", err) + } + for _, entry := range candidate.Subject { + if digest, ok := entry.Digest["sha256"]; ok && validHexDigest(digest, 64) { + return "sha256", digest, payload, nil + } + if digest, ok := entry.Digest["sha512"]; ok && validHexDigest(digest, 128) { + return "sha512", digest, payload, nil + } + } + return "", "", nil, errors.New("Sigstore statement has no supported SHA-256 or SHA-512 subject for verification") +} diff --git a/internal/packslipverify/testdata/bundle-provenance.json b/internal/packslipverify/testdata/bundle-provenance.json new file mode 100644 index 00000000..dba9b1ad --- /dev/null +++ b/internal/packslipverify/testdata/bundle-provenance.json @@ -0,0 +1 @@ +{"mediaType":"application/vnd.dev.sigstore.bundle+json;version=0.1","verificationMaterial":{"x509CertificateChain":{"certificates":[{"rawBytes":"MIIGnTCCBiKgAwIBAgIUAY4nsTCcZGNQgKt26IDI5lbzU/IwCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjMwNDE4MTc0NTExWhcNMjMwNDE4MTc1NTExWjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwEOO0UfhGUq2rXxy7jLTHY5VQXgNN5DmXXONKmoskPBECLY3l25HnymyzNpgMZyOnFJDvcDbi5+HjL5Yto6gKaOCBUEwggU9MA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUoVwtgKpSjSIsfmaolzLXjxFY0yYwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wYwYDVR0RAQH/BFkwV4ZVaHR0cHM6Ly9naXRodWIuY29tL3NpZ3N0b3JlL3NpZ3N0b3JlLWpzLy5naXRodWIvd29ya2Zsb3dzL3JlbGVhc2UueW1sQHJlZnMvaGVhZHMvbWFpbjA5BgorBgEEAYO/MAEBBCtodHRwczovL3Rva2VuLmFjdGlvbnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tMBIGCisGAQQBg78wAQIEBHB1c2gwNgYKKwYBBAGDvzABAwQoZGFlOGJkOGViNDMzYTQxNDdiNDY1NWMwMGZlNzNlMGYyMmJjMGZiMTAVBgorBgEEAYO/MAEEBAdSZWxlYXNlMCIGCisGAQQBg78wAQUEFHNpZ3N0b3JlL3NpZ3N0b3JlLWpzMB0GCisGAQQBg78wAQYED3JlZnMvaGVhZHMvbWFpbjA7BgorBgEEAYO/MAEIBC0MK2h0dHBzOi8vdG9rZW4uYWN0aW9ucy5naXRodWJ1c2VyY29udGVudC5jb20wZQYKKwYBBAGDvzABCQRXDFVodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUvc2lnc3RvcmUtanMvLmdpdGh1Yi93b3JrZmxvd3MvcmVsZWFzZS55bWxAcmVmcy9oZWFkcy9tYWluMDgGCisGAQQBg78wAQoEKgwoZGFlOGJkOGViNDMzYTQxNDdiNDY1NWMwMGZlNzNlMGYyMmJjMGZiMTAdBgorBgEEAYO/MAELBA8MDWdpdGh1Yi1ob3N0ZWQwNwYKKwYBBAGDvzABDAQpDCdodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUvc2lnc3RvcmUtanMwOAYKKwYBBAGDvzABDQQqDChkYWU4YmQ4ZWI0MzNhNDE0N2I0NjU1YzAwZmU3M2UwZjIyYmMwZmIxMB8GCisGAQQBg78wAQ4EEQwPcmVmcy9oZWFkcy9tYWluMBkGCisGAQQBg78wAQ8ECwwJNDk1NTc0NTU1MCsGCisGAQQBg78wARAEHQwbaHR0cHM6Ly9naXRodWIuY29tL3NpZ3N0b3JlMBgGCisGAQQBg78wAREECgwINzEwOTYzNTMwZQYKKwYBBAGDvzABEgRXDFVodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUvc2lnc3RvcmUtanMvLmdpdGh1Yi93b3JrZmxvd3MvcmVsZWFzZS55bWxAcmVmcy9oZWFkcy9tYWluMDgGCisGAQQBg78wARMEKgwoZGFlOGJkOGViNDMzYTQxNDdiNDY1NWMwMGZlNzNlMGYyMmJjMGZiMTAUBgorBgEEAYO/MAEUBAYMBHB1c2gwWgYKKwYBBAGDvzABFQRMDEpodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUvc2lnc3RvcmUtanMvYWN0aW9ucy9ydW5zLzQ3MzUzODQyNjUvYXR0ZW1wdHMvMTCBiQYKKwYBBAHWeQIEAgR7BHkAdwB1AN09MGrGxxEyYxkeHJlnNwKiSl643jyt/4eKcoAvKe6OAAABh5V4dEoAAAQDAEYwRAIgB9iqF/FYavg0QB87JLcRU/8m6SbN3ysYOxhk85VkRnoCIGemfDKeS1OaoFOu28SoQBohJaB0GozyyIIWgp3T6CRsMAoGCCqGSM49BAMDA2kAMGYCMQDyU//yA/5DuynXytqwHeF5aorTT2l83z1v1/eHoKtlw5eC0Id8jLUN2UzAA1D9IR0CMQDhltxC40MxjanEj1BSK/DWz2IVTt/VMOAkdMu/1qbhAMnMm6SG6N6KbYF4s2yYwT0="},{"rawBytes":"MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow="},{"rawBytes":"MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ"}]},"tlogEntries":[{"logIndex":"18300934","logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="},"kindVersion":{"kind":"intoto","version":"0.0.2"},"integratedTime":"1681839912","inclusionPromise":{"signedEntryTimestamp":"MEYCIQCQxXRPzxtA3rie/Gg8vErjJNfGRBwWtfyJZWekPepLIwIhAKCP6p9llDiaqkuOzjlGNfqWqHESGEiAGvS7RSNc6mLr"},"inclusionProof":null,"canonicalizedBody":"eyJhcGlWZXJzaW9uIjoiMC4wLjIiLCJraW5kIjoiaW50b3RvIiwic3BlYyI6eyJjb250ZW50Ijp7ImVudmVsb3BlIjp7InBheWxvYWRUeXBlIjoiYXBwbGljYXRpb24vdm5kLmluLXRvdG8ranNvbiIsInNpZ25hdHVyZXMiOlt7InB1YmxpY0tleSI6IkxTMHRMUzFDUlVkSlRpQkRSVkpVU1VaSlEwRlVSUzB0TFMwdENrMUpTVWR1VkVORFFtbExaMEYzU1VKQlowbFZRVmswYm5OVVEyTmFSMDVSWjB0ME1qWkpSRWsxYkdKNlZTOUpkME5uV1VsTGIxcEplbW93UlVGM1RYY0tUbnBGVmsxQ1RVZEJNVlZGUTJoTlRXTXliRzVqTTFKMlkyMVZkVnBIVmpKTlVqUjNTRUZaUkZaUlVVUkZlRlo2WVZka2VtUkhPWGxhVXpGd1ltNVNiQXBqYlRGc1drZHNhR1JIVlhkSWFHTk9UV3BOZDA1RVJUUk5WR013VGxSRmVGZG9ZMDVOYWsxM1RrUkZORTFVWXpGT1ZFVjRWMnBCUVUxR2EzZEZkMWxJQ2t0dldrbDZhakJEUVZGWlNVdHZXa2w2YWpCRVFWRmpSRkZuUVVWM1JVOVBNRlZtYUVkVmNUSnlXSGg1TjJwTVZFaFpOVlpSV0dkT1RqVkViVmhZVDA0S1MyMXZjMnRRUWtWRFRGa3piREkxU0c1NWJYbDZUbkJuVFZwNVQyNUdTa1IyWTBSaWFUVXJTR3BNTlZsMGJ6Wm5TMkZQUTBKVlJYZG5aMVU1VFVFMFJ3cEJNVlZrUkhkRlFpOTNVVVZCZDBsSVowUkJWRUpuVGxaSVUxVkZSRVJCUzBKblozSkNaMFZHUWxGalJFRjZRV1JDWjA1V1NGRTBSVVpuVVZWdlZuZDBDbWRMY0ZOcVUwbHpabTFoYjJ4NlRGaHFlRVpaTUhsWmQwaDNXVVJXVWpCcVFrSm5kMFp2UVZVek9WQndlakZaYTBWYVlqVnhUbXB3UzBaWGFYaHBORmtLV2tRNGQxbDNXVVJXVWpCU1FWRklMMEpHYTNkV05GcFdZVWhTTUdOSVRUWk1lVGx1WVZoU2IyUlhTWFZaTWpsMFRETk9jRm96VGpCaU0wcHNURE5PY0FwYU0wNHdZak5LYkV4WGNIcE1lVFZ1WVZoU2IyUlhTWFprTWpsNVlUSmFjMkl6WkhwTU0wcHNZa2RXYUdNeVZYVmxWekZ6VVVoS2JGcHVUWFpoUjFab0NscElUWFppVjBad1ltcEJOVUpuYjNKQ1owVkZRVmxQTDAxQlJVSkNRM1J2WkVoU2QyTjZiM1pNTTFKMllUSldkVXh0Um1wa1IyeDJZbTVOZFZveWJEQUtZVWhXYVdSWVRteGpiVTUyWW01U2JHSnVVWFZaTWpsMFRVSkpSME5wYzBkQlVWRkNaemM0ZDBGUlNVVkNTRUl4WXpKbmQwNW5XVXRMZDFsQ1FrRkhSQXAyZWtGQ1FYZFJiMXBIUm14UFIwcHJUMGRXYVU1RVRYcFpWRkY0VGtSa2FVNUVXVEZPVjAxM1RVZGFiRTU2VG14TlIxbDVUVzFLYWsxSFdtbE5WRUZXQ2tKbmIzSkNaMFZGUVZsUEwwMUJSVVZDUVdSVFdsZDRiRmxZVG14TlEwbEhRMmx6UjBGUlVVSm5OemgzUVZGVlJVWklUbkJhTTA0d1lqTktiRXd6VG5BS1dqTk9NR0l6U214TVYzQjZUVUl3UjBOcGMwZEJVVkZDWnpjNGQwRlJXVVZFTTBwc1dtNU5kbUZIVm1oYVNFMTJZbGRHY0dKcVFUZENaMjl5UW1kRlJRcEJXVTh2VFVGRlNVSkRNRTFMTW1nd1pFaENlazlwT0haa1J6bHlXbGMwZFZsWFRqQmhWemwxWTNrMWJtRllVbTlrVjBveFl6SldlVmt5T1hWa1IxWjFDbVJETldwaU1qQjNXbEZaUzB0M1dVSkNRVWRFZG5wQlFrTlJVbGhFUmxadlpFaFNkMk42YjNaTU1tUndaRWRvTVZscE5XcGlNakIyWXpKc2JtTXpVbllLWTIxVmRtTXliRzVqTTFKMlkyMVZkR0Z1VFhaTWJXUndaRWRvTVZscE9UTmlNMHB5V20xNGRtUXpUWFpqYlZaeldsZEdlbHBUTlRWaVYzaEJZMjFXYlFwamVUbHZXbGRHYTJONU9YUlpWMngxVFVSblIwTnBjMGRCVVZGQ1p6YzRkMEZSYjBWTFozZHZXa2RHYkU5SFNtdFBSMVpwVGtSTmVsbFVVWGhPUkdScENrNUVXVEZPVjAxM1RVZGFiRTU2VG14TlIxbDVUVzFLYWsxSFdtbE5WRUZrUW1kdmNrSm5SVVZCV1U4dlRVRkZURUpCT0UxRVYyUndaRWRvTVZscE1XOEtZak5PTUZwWFVYZE9kMWxMUzNkWlFrSkJSMFIyZWtGQ1JFRlJjRVJEWkc5a1NGSjNZM3B2ZGt3eVpIQmtSMmd4V1drMWFtSXlNSFpqTW14dVl6TlNkZ3BqYlZWMll6SnNibU16VW5aamJWVjBZVzVOZDA5QldVdExkMWxDUWtGSFJIWjZRVUpFVVZGeFJFTm9hMWxYVlRSWmJWRTBXbGRKTUUxNlRtaE9SRVV3Q2s0eVNUQk9hbFV4V1hwQmQxcHRWVE5OTWxWM1dtcEplVmx0VFhkYWJVbDRUVUk0UjBOcGMwZEJVVkZDWnpjNGQwRlJORVZGVVhkUVkyMVdiV041T1c4S1dsZEdhMk41T1hSWlYyeDFUVUpyUjBOcGMwZEJVVkZDWnpjNGQwRlJPRVZEZDNkS1RrUnJNVTVVWXpCT1ZGVXhUVU56UjBOcGMwZEJVVkZDWnpjNGR3cEJVa0ZGU0ZGM1ltRklVakJqU0UwMlRIazVibUZZVW05a1YwbDFXVEk1ZEV3elRuQmFNMDR3WWpOS2JFMUNaMGREYVhOSFFWRlJRbWMzT0hkQlVrVkZDa05uZDBsT2VrVjNUMVJaZWs1VVRYZGFVVmxMUzNkWlFrSkJSMFIyZWtGQ1JXZFNXRVJHVm05a1NGSjNZM3B2ZGt3eVpIQmtSMmd4V1drMWFtSXlNSFlLWXpKc2JtTXpVblpqYlZWMll6SnNibU16VW5aamJWVjBZVzVOZGt4dFpIQmtSMmd4V1drNU0ySXpTbkphYlhoMlpETk5kbU50Vm5OYVYwWjZXbE0xTlFwaVYzaEJZMjFXYldONU9XOWFWMFpyWTNrNWRGbFhiSFZOUkdkSFEybHpSMEZSVVVKbk56aDNRVkpOUlV0bmQyOWFSMFpzVDBkS2EwOUhWbWxPUkUxNkNsbFVVWGhPUkdScFRrUlpNVTVYVFhkTlIxcHNUbnBPYkUxSFdYbE5iVXBxVFVkYWFVMVVRVlZDWjI5eVFtZEZSVUZaVHk5TlFVVlZRa0ZaVFVKSVFqRUtZekpuZDFkbldVdExkMWxDUWtGSFJIWjZRVUpHVVZKTlJFVndiMlJJVW5kamVtOTJUREprY0dSSGFERlphVFZxWWpJd2RtTXliRzVqTTFKMlkyMVZkZ3BqTW14dVl6TlNkbU50VlhSaGJrMTJXVmRPTUdGWE9YVmplVGw1WkZjMWVreDZVVE5OZWxWNlQwUlJlVTVxVlhaWldGSXdXbGN4ZDJSSVRYWk5WRU5DQ21sUldVdExkMWxDUWtGSVYyVlJTVVZCWjFJM1FraHJRV1IzUWpGQlRqQTVUVWR5UjNoNFJYbFplR3RsU0Vwc2JrNTNTMmxUYkRZME0ycDVkQzgwWlVzS1kyOUJka3RsTms5QlFVRkNhRFZXTkdSRmIwRkJRVkZFUVVWWmQxSkJTV2RDT1dseFJpOUdXV0YyWnpCUlFqZzNTa3hqVWxVdk9HMDJVMkpPTTNseldRcFBlR2hyT0RWV2ExSnViME5KUjJWdFprUkxaVk14VDJGdlJrOTFNamhUYjFGQ2IyaEtZVUl3UjI5NmVYbEpTVmRuY0ROVU5rTlNjMDFCYjBkRFEzRkhDbE5OTkRsQ1FVMUVRVEpyUVUxSFdVTk5VVVI1VlM4dmVVRXZOVVIxZVc1WWVYUnhkMGhsUmpWaGIzSlVWREpzT0RONk1YWXhMMlZJYjB0MGJIYzFaVU1LTUVsa09HcE1WVTR5VlhwQlFURkVPVWxTTUVOTlVVUm9iSFI0UXpRd1RYaHFZVzVGYWpGQ1Uwc3ZSRmQ2TWtsV1ZIUXZWazFQUVd0a1RYVXZNWEZpYUFwQlRXNU5iVFpUUnpaT05rdGlXVVkwY3pKNVdYZFVNRDBLTFMwdExTMUZUa1FnUTBWU1ZFbEdTVU5CVkVVdExTMHRMUT09Iiwic2lnIjoiVFVWUlEwbEJXVkkwY0dKbVIwVjZjR0pDYWtwak9XMDRMMVpsUlRkeGRXUklPV1k1VFhGbmRHNTVhVTlWZUUxV1FXbENVM1puZVhWS2NFZE9UakZHY0ZoUlFqZEtZa1YyTUVwbmNVMTNaMVpUZFVGSk1saGlSRmRSUVcxbVFUMDkifV19LCJoYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiYzUyZWYzOGFlMjE5NzMyMGRhZDdkNjc3YzBhYzExMjFjYjQ1MTkwYjZiYjIzMzljNTI5YjVkNGZhZGFkOGE3NSJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6IjJjOTNlOTk2Mjc0ZWRiOTVjYzQxMzk1MzAwMDk3NjYyOGYxM2YxZWRmYmUyMDM4ZmZkZDgxZjA3ZmY3YWE0ODMifX19fQ=="}],"timestampVerificationData":null},"dsseEnvelope":{"payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInN1YmplY3QiOlt7Im5hbWUiOiJwa2c6bnBtL3NpZ3N0b3JlQDEuMy4wIiwiZGlnZXN0Ijp7InNoYTUxMiI6Ijc2MTc2ZmZhMzM4MDhiNTQ2MDJjN2MzNWRlNWM2ZTlhNGRlYjk2MDY2ZGJhNjUzM2Y1MGFjMjM0ZjRmMWY0YzZiMzUyNzUxNWRjMTdjMDZmYmUyODYwMDMwZjQxMGVlZTY5ZWEyMDA3OWJkM2EyYzZmM2RjZjNiMzI5YjEwNzUxIn19XSwicHJlZGljYXRlVHlwZSI6Imh0dHBzOi8vc2xzYS5kZXYvcHJvdmVuYW5jZS92MC4yIiwicHJlZGljYXRlIjp7ImJ1aWxkVHlwZSI6Imh0dHBzOi8vZ2l0aHViLmNvbS9ucG0vY2xpL2doYS92MiIsImJ1aWxkZXIiOnsiaWQiOiJodHRwczovL2dpdGh1Yi5jb20vYWN0aW9ucy9ydW5uZXIifSwiaW52b2NhdGlvbiI6eyJjb25maWdTb3VyY2UiOnsidXJpIjoiZ2l0K2h0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS9zaWdzdG9yZS1qc0ByZWZzL2hlYWRzL21haW4iLCJkaWdlc3QiOnsic2hhMSI6ImRhZThiZDhlYjQzM2E0MTQ3YjQ2NTVjMDBmZTczZTBmMjJiYzBmYjEifSwiZW50cnlQb2ludCI6Ii5naXRodWIvd29ya2Zsb3dzL3JlbGVhc2UueW1sIn0sInBhcmFtZXRlcnMiOnt9LCJlbnZpcm9ubWVudCI6eyJHSVRIVUJfRVZFTlRfTkFNRSI6InB1c2giLCJHSVRIVUJfUkVGIjoicmVmcy9oZWFkcy9tYWluIiwiR0lUSFVCX1JFUE9TSVRPUlkiOiJzaWdzdG9yZS9zaWdzdG9yZS1qcyIsIkdJVEhVQl9SRVBPU0lUT1JZX0lEIjoiNDk1NTc0NTU1IiwiR0lUSFVCX1JFUE9TSVRPUllfT1dORVJfSUQiOiI3MTA5NjM1MyIsIkdJVEhVQl9SVU5fQVRURU1QVCI6IjEiLCJHSVRIVUJfUlVOX0lEIjoiNDczNTM4NDI2NSIsIkdJVEhVQl9TSEEiOiJkYWU4YmQ4ZWI0MzNhNDE0N2I0NjU1YzAwZmU3M2UwZjIyYmMwZmIxIiwiR0lUSFVCX1dPUktGTE9XX1JFRiI6InNpZ3N0b3JlL3NpZ3N0b3JlLWpzLy5naXRodWIvd29ya2Zsb3dzL3JlbGVhc2UueW1sQHJlZnMvaGVhZHMvbWFpbiIsIkdJVEhVQl9XT1JLRkxPV19TSEEiOiJkYWU4YmQ4ZWI0MzNhNDE0N2I0NjU1YzAwZmU3M2UwZjIyYmMwZmIxIn19LCJtZXRhZGF0YSI6eyJidWlsZEludm9jYXRpb25JZCI6IjQ3MzUzODQyNjUtMSIsImNvbXBsZXRlbmVzcyI6eyJwYXJhbWV0ZXJzIjpmYWxzZSwiZW52aXJvbm1lbnQiOmZhbHNlLCJtYXRlcmlhbHMiOmZhbHNlfSwicmVwcm9kdWNpYmxlIjpmYWxzZX0sIm1hdGVyaWFscyI6W3sidXJpIjoiZ2l0K2h0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS9zaWdzdG9yZS1qc0ByZWZzL2hlYWRzL21haW4iLCJkaWdlc3QiOnsic2hhMSI6ImRhZThiZDhlYjQzM2E0MTQ3YjQ2NTVjMDBmZTczZTBmMjJiYzBmYjEifX1dfX0=","payloadType":"application/vnd.in-toto+json","signatures":[{"sig":"MEQCIAYR4pbfGEzpbBjJc9m8/VeE7qudH9f9MqgtnyiOUxMVAiBSvgyuJpGNN1FpXQB7JbEv0JgqMwgVSuAI2XbDWQAmfA==","keyid":""}]}} diff --git a/internal/packslipverify/testdata/trusted-root-public-good.json b/internal/packslipverify/testdata/trusted-root-public-good.json new file mode 100644 index 00000000..5ed62811 --- /dev/null +++ b/internal/packslipverify/testdata/trusted-root-public-good.json @@ -0,0 +1,114 @@ +{ + "mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1", + "tlogs": [ + { + "baseUrl": "https://rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-01-12T11:53:27.000Z" + } + }, + "logId": { + "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" + } + } + ], + "certificateAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ==" + } + ] + }, + "validFor": { + "start": "2021-03-07T03:20:29.000Z", + "end": "2022-12-31T23:59:59.999Z" + } + }, + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow=" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ" + } + ] + }, + "validFor": { + "start": "2022-04-13T20:06:15.000Z" + } + } + ], + "ctlogs": [ + { + "baseUrl": "https://ctfe.sigstore.dev/test", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3PyudDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-03-14T00:00:00.000Z", + "end": "2022-10-31T23:59:59.999Z" + } + }, + "logId": { + "keyId": "CGCS8ChS/2hF0dFrJ4ScRWcYrBY9wzjSbea8IgY2b3I=" + } + }, + { + "baseUrl": "https://ctfe.sigstore.dev/2022", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEcYXNKAaYalIJmBZ8yyezPjTqhxrKBpMnaocVtLJBI1eM3uXnQzQGAJdJ4gs9Fyw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2022-10-20T00:00:00.000Z" + } + }, + "logId": { + "keyId": "3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4=" + } + } + ], + "timestampAuthorities": [ + { + "subject": { + "organization": "GitHub, Inc.", + "commonName": "Internal Services Root" + }, + "certChain": { + "certificates": [ + { + "rawBytes": "MIIB3DCCAWKgAwIBAgIUchkNsH36Xa04b1LqIc+qr9DVecMwCgYIKoZIzj0EAwMwMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgaW50ZXJtZWRpYXRlMB4XDTIzMDQxNDAwMDAwMFoXDTI0MDQxMzAwMDAwMFowMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgVGltZXN0YW1waW5nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUD5ZNbSqYMd6r8qpOOEX9ibGnZT9GsuXOhr/f8U9FJugBGExKYp40OULS0erjZW7xV9xV52NnJf5OeDq4e5ZKqNWMFQwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMIMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUaW1RudOgVt0leqY0WKYbuPr47wAwCgYIKoZIzj0EAwMDaAAwZQIwbUH9HvD4ejCZJOWQnqAlkqURllvu9M8+VqLbiRK+zSfZCZwsiljRn8MQQRSkXEE5AjEAg+VxqtojfVfu8DhzzhCx9GKETbJHb19iV72mMKUbDAFmzZ6bQ8b54Zb8tidy5aWe" + }, + { + "rawBytes": "MIICEDCCAZWgAwIBAgIUX8ZO5QXP7vN4dMQ5e9sU3nub8OgwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDQxNDAwMDAwMFoXDTI4MDQxMjAwMDAwMFowMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEvMLY/dTVbvIJYANAuszEwJnQE1llftynyMKIMhh48HmqbVr5ygybzsLRLVKbBWOdZ21aeJz+gZiytZetqcyF9WlER5NEMf6JV7ZNojQpxHq4RHGoGSceQv/qvTiZxEDKo2YwZDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUaW1RudOgVt0leqY0WKYbuPr47wAwHwYDVR0jBBgwFoAU9NYYlobnAG4c0/qjxyH/lq/wz+QwCgYIKoZIzj0EAwMDaQAwZgIxAK1B185ygCrIYFlIs3GjswjnwSMG6LY8woLVdakKDZxVa8f8cqMs1DhcxJ0+09w95QIxAO+tBzZk7vjUJ9iJgD4R6ZWTxQWKqNm74jO99o+o9sv4FI/SZTZTFyMn0IJEHdNmyA==" + }, + { + "rawBytes": "MIIB9DCCAXqgAwIBAgIUa/JAkdUjK4JUwsqtaiRJGWhqLSowCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDQxNDAwMDAwMFoXDTMzMDQxMTAwMDAwMFowODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEf9jFAXxz4kx68AHRMOkFBhflDcMTvzaXz4x/FCcXjJ/1qEKon/qPIGnaURskDtyNbNDOpeJTDDFqt48iMPrnzpx6IZwqemfUJN4xBEZfza+pYt/iyod+9tZr20RRWSv/o0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBAjAdBgNVHQ4EFgQU9NYYlobnAG4c0/qjxyH/lq/wz+QwCgYIKoZIzj0EAwMDaAAwZQIxALZLZ8BgRXzKxLMMN9VIlO+e4hrBnNBgF7tz7Hnrowv2NetZErIACKFymBlvWDvtMAIwZO+ki6ssQ1bsZo98O8mEAf2NZ7iiCgDDU0Vwjeco6zyeh0zBTs9/7gV6AHNQ53xD" + } + ] + }, + "validFor": { + "start": "2023-04-14T00:00:00.000Z" + } + } + ] +} diff --git a/internal/packslipverify/verifier.go b/internal/packslipverify/verifier.go new file mode 100644 index 00000000..8e8dad0a --- /dev/null +++ b/internal/packslipverify/verifier.go @@ -0,0 +1,84 @@ +// 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 packslipverify provides the signature verification boundary used by +// Packslip release resolution. Platform-specific implementations are kept out +// of callers so unsupported targets fail explicitly. +package packslipverify + +import ( + "context" + "errors" + "net/http" +) + +var ErrUnsupported = errors.New("Sigstore bundle verification is unsupported on this target") + +// Config controls trusted-root retrieval. HTTPClient is used for Sigstore TUF +// requests, and CachePath selects the persistent TUF metadata cache. When +// caching is enabled and CachePath is empty, dbc uses its own user config cache +// directory. A TrustedRootJSON value is parsed when New is called and avoids +// TUF network and cache access during verification. +type Config struct { + HTTPClient *http.Client + CachePath string + DisableLocalCache bool + TrustedRootJSON []byte +} + +// IdentityPolicy pins the signing certificate to an OIDC issuer and either an +// exact certificate SAN or an anchored regular expression for that SAN. +type IdentityPolicy struct { + Issuer string + Subject string + SubjectRegex string +} + +// ArtifactDigest identifies the subject that the verified signed statement +// must cover. Only SHA-256 and SHA-512 are accepted. +type ArtifactDigest struct { + Algorithm string + Hex string +} + +// Subject is an artifact named by a verified in-toto statement. +type Subject struct { + Name string + Digests map[string]string +} + +// Statement contains the verified in-toto statement fields needed by release +// resolution. SignedPayload retains the original payload for format-specific +// validation by the caller. +type Statement struct { + Type string + PredicateType string + Subjects []Subject +} + +// VerifiedBundle is returned only after Sigstore cryptographic verification, +// signing identity checks, and the artifact digest policy have passed. +type VerifiedBundle struct { + Issuer string + Signer string + SignedPayload []byte + Statement *Statement +} + +// PackslipVerifier verifies a Sigstore bundle against an expected signer and +// artifact digest. Implementations must fail closed when verification is not +// available. +type PackslipVerifier interface { + Verify(context.Context, []byte, IdentityPolicy, ArtifactDigest) (*VerifiedBundle, error) +} diff --git a/internal/packslipverify/verifier_js.go b/internal/packslipverify/verifier_js.go new file mode 100644 index 00000000..4e65fd77 --- /dev/null +++ b/internal/packslipverify/verifier_js.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 js + +package packslipverify + +import ( + "context" + "fmt" +) + +type unsupportedVerifier struct{} + +// New returns a verifier that reports ErrUnsupported when Verify is called. +func New(Config) (PackslipVerifier, error) { + return unsupportedVerifier{}, nil +} + +func (unsupportedVerifier) Verify(context.Context, []byte, IdentityPolicy, ArtifactDigest) (*VerifiedBundle, error) { + return nil, fmt.Errorf("%w: Node and browser builds cannot verify Sigstore bundles", ErrUnsupported) +} + +var _ PackslipVerifier = unsupportedVerifier{} diff --git a/internal/packslipverify/verifier_native.go b/internal/packslipverify/verifier_native.go new file mode 100644 index 00000000..1b5ea55d --- /dev/null +++ b/internal/packslipverify/verifier_native.go @@ -0,0 +1,312 @@ +// 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 packslipverify + +import ( + "context" + "crypto" + "encoding/hex" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/columnar-tech/dbc/internal" + "github.com/columnar-tech/dbc/internal/fslock" + "github.com/sigstore/sigstore-go/pkg/bundle" + "github.com/sigstore/sigstore-go/pkg/root" + sigtuf "github.com/sigstore/sigstore-go/pkg/tuf" + "github.com/sigstore/sigstore-go/pkg/util" + "github.com/sigstore/sigstore-go/pkg/verify" + "github.com/theupdateframework/go-tuf/v2/metadata/fetcher" +) + +type nativeVerifier struct { + config Config + configuredTrustedRoot *root.TrustedRoot +} + +// New returns a native Sigstore bundle verifier. +func New(config Config) (PackslipVerifier, error) { + verifier := &nativeVerifier{config: config} + if config.TrustedRootJSON != nil { + trustedRoot, err := root.NewTrustedRootFromJSON(config.TrustedRootJSON) + if err != nil { + return nil, fmt.Errorf("parse configured Sigstore trusted root: %w", err) + } + verifier.configuredTrustedRoot = trustedRoot + verifier.config.TrustedRootJSON = nil + return verifier, nil + } + + if !config.DisableLocalCache { + if config.CachePath == "" { + configPath, err := internal.GetUserConfigPath() + if err != nil { + return nil, fmt.Errorf("find dbc user config directory for Sigstore cache: %w", err) + } + // Keep TUF state separate from other Sigstore tools so the lock and + // cache format have one owner and one synchronization policy. + config.CachePath = filepath.Join(configPath, "cache", "sigstore") + } + if !filepath.IsAbs(config.CachePath) { + return nil, fmt.Errorf("Sigstore TUF cache path must be absolute: %q", config.CachePath) + } + } + verifier.config = config + return verifier, nil +} + +func (v *nativeVerifier) Verify(ctx context.Context, bundleJSON []byte, identity IdentityPolicy, digest ArtifactDigest) (*VerifiedBundle, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if len(bundleJSON) == 0 { + return nil, errors.New("Sigstore bundle is empty") + } + if err := validateIdentity(identity); err != nil { + return nil, err + } + digestBytes, err := decodeDigest(digest) + if err != nil { + return nil, err + } + + var signedBundle bundle.Bundle + if err := signedBundle.UnmarshalJSON(bundleJSON); err != nil { + return nil, fmt.Errorf("decode Sigstore bundle: %w", err) + } + + trustedRoot, err := v.trustedRoot(ctx) + if err != nil { + return nil, fmt.Errorf("load Sigstore trusted root: %w", err) + } + + certificateIdentity, err := verify.NewShortCertificateIdentity(identity.Issuer, "", identity.Subject, identity.SubjectRegex) + if err != nil { + return nil, fmt.Errorf("invalid signing identity policy: %w", err) + } + + sigstoreVerifier, err := verify.NewVerifier( + trustedRoot, + verify.WithSignedCertificateTimestamps(1), + verify.WithObserverTimestamps(1), + verify.WithTransparencyLog(1), + ) + if err != nil { + return nil, fmt.Errorf("create Sigstore verifier: %w", err) + } + + result, err := sigstoreVerifier.Verify( + &signedBundle, + verify.NewPolicy( + verify.WithArtifactDigest(digest.Algorithm, digestBytes), + verify.WithCertificateIdentity(certificateIdentity), + ), + ) + if err != nil { + return nil, fmt.Errorf("verify Sigstore bundle: %w", err) + } + if result == nil || result.Signature == nil || result.Signature.Certificate == nil { + return nil, errors.New("Sigstore bundle did not verify with a signing certificate") + } + if err := ctx.Err(); err != nil { + return nil, err + } + + verified := &VerifiedBundle{ + Issuer: result.Signature.Certificate.Issuer, + Signer: result.Signature.Certificate.SubjectAlternativeName, + SignedPayload: signedBundle.GetDsseEnvelope().GetPayload(), + } + if result.Statement != nil { + statement := &Statement{ + Type: result.Statement.GetType(), + PredicateType: result.Statement.GetPredicateType(), + } + for _, subject := range result.Statement.GetSubject() { + statement.Subjects = append(statement.Subjects, Subject{ + Name: subject.GetName(), + Digests: subject.GetDigest(), + }) + } + verified.Statement = statement + } + return verified, nil +} + +// trustedRoot returns a configured immutable root or performs a fresh, +// caller-scoped TUF load. It retains no process-local TUF result. +func (v *nativeVerifier) trustedRoot(ctx context.Context) (*root.TrustedRoot, error) { + return v.trustedRootWithLoader(ctx, root.FetchTrustedRootWithOptions) +} + +// trustedRootWithLoader keeps the loader as a call argument so tests can +// exercise the real per-call cache/lock path without adding shared verifier +// state. Production always passes root.FetchTrustedRootWithOptions. +func (v *nativeVerifier) trustedRootWithLoader( + ctx context.Context, + load func(*sigtuf.Options) (*root.TrustedRoot, error), +) (*root.TrustedRoot, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if v.configuredTrustedRoot != nil { + return v.configuredTrustedRoot, nil + } + + opts := sigtuf.DefaultOptions() + opts.Context = ctx + opts.DisableLocalCache = v.config.DisableLocalCache + if !opts.DisableLocalCache { + opts.CachePath = v.config.CachePath + } + + client := v.config.HTTPClient + if client == nil { + client = http.DefaultClient + } + metadataFetcher := fetcher.NewDefaultFetcher() + metadataFetcher.SetHTTPUserAgent(util.ConstructUserAgent()) + metadataFetcher.SetHTTPClient(requestContextHTTPClient{ctx: ctx, client: client}) + opts.Fetcher = metadataFetcher + + return loadTrustedRootWithCache(ctx, opts, load) +} + +// loadTrustedRootWithCache serializes all TUF filesystem access for a cache +// path, including client construction and target retrieval. Each call uses a +// fresh TUF client; no process-local trusted-root state survives the call. +func loadTrustedRootWithCache( + ctx context.Context, + opts *sigtuf.Options, + load func(*sigtuf.Options) (*root.TrustedRoot, error), +) (*root.TrustedRoot, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if load == nil { + return nil, errors.New("Sigstore trusted root loader is nil") + } + opts.Context = ctx + if opts.DisableLocalCache { + return fetchTrustedRoot(ctx, opts, load) + } + if !filepath.IsAbs(opts.CachePath) { + return nil, fmt.Errorf("Sigstore TUF cache path must be absolute: %q", opts.CachePath) + } + if err := os.MkdirAll(opts.CachePath, 0o700); err != nil { + return nil, fmt.Errorf("create Sigstore TUF cache directory: %w", err) + } + lock, err := fslock.AcquireContext(ctx, filepath.Join(opts.CachePath, "tuf.lock")) + if err != nil { + return nil, fmt.Errorf("acquire Sigstore TUF cache lock: %w", err) + } + + trustedRoot, fetchErr := fetchTrustedRoot(ctx, opts, load) + releaseErr := lock.Release() + if fetchErr != nil { + if releaseErr != nil { + return nil, errors.Join(fetchErr, fmt.Errorf("release Sigstore TUF cache lock: %w", releaseErr)) + } + return nil, fetchErr + } + if releaseErr != nil { + return nil, fmt.Errorf("release Sigstore TUF cache lock: %w", releaseErr) + } + return trustedRoot, nil +} + +func fetchTrustedRoot( + ctx context.Context, + opts *sigtuf.Options, + load func(*sigtuf.Options) (*root.TrustedRoot, error), +) (*root.TrustedRoot, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + trustedRoot, err := load(opts) + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } + if err != nil { + return nil, err + } + if trustedRoot == nil { + return nil, errors.New("Sigstore trusted root loader returned no root") + } + return trustedRoot, nil +} + +// requestContextHTTPClient adapts go-tuf's context-free Fetcher interface to +// the caller's context without modifying the shared HTTP client or transport. +type requestContextHTTPClient struct { + ctx context.Context + client interface { + Do(*http.Request) (*http.Response, error) + } +} + +func (c requestContextHTTPClient) Do(req *http.Request) (*http.Response, error) { + if err := c.ctx.Err(); err != nil { + return nil, err + } + return c.client.Do(req.Clone(c.ctx)) +} + +func validateIdentity(identity IdentityPolicy) error { + if strings.TrimSpace(identity.Issuer) == "" { + return errors.New("signing identity issuer is required") + } + if (identity.Subject == "") == (identity.SubjectRegex == "") { + return errors.New("specify exactly one of signing identity subject or subject regex") + } + if identity.SubjectRegex != "" { + if !strings.HasPrefix(identity.SubjectRegex, "^") || !strings.HasSuffix(identity.SubjectRegex, "$") { + return errors.New("signing identity subject regex must be anchored with ^ and $") + } + if _, err := regexp.Compile(identity.SubjectRegex); err != nil { + return fmt.Errorf("invalid signing identity subject regex: %w", err) + } + } + return nil +} + +func decodeDigest(digest ArtifactDigest) ([]byte, error) { + var hash crypto.Hash + switch digest.Algorithm { + case "sha256": + hash = crypto.SHA256 + case "sha512": + hash = crypto.SHA512 + default: + return nil, fmt.Errorf("unsupported artifact digest algorithm %q", digest.Algorithm) + } + decoded, err := hex.DecodeString(digest.Hex) + if err != nil { + return nil, fmt.Errorf("invalid %s artifact digest: %w", digest.Algorithm, err) + } + if len(decoded) != hash.Size() { + return nil, fmt.Errorf("invalid %s artifact digest length: got %d bytes, want %d", digest.Algorithm, len(decoded), hash.Size()) + } + return decoded, nil +} + +var _ PackslipVerifier = (*nativeVerifier)(nil) diff --git a/internal/packslipverify/verifier_native_test.go b/internal/packslipverify/verifier_native_test.go new file mode 100644 index 00000000..3b166bd4 --- /dev/null +++ b/internal/packslipverify/verifier_native_test.go @@ -0,0 +1,432 @@ +// 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. + +// The signed bundle and trusted root fixtures come from +// github.com/sigstore/sigstore-go v1.3.0 examples. + +//go:build !js + +package packslipverify + +import ( + "context" + "embed" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/columnar-tech/dbc/internal" + "github.com/columnar-tech/dbc/internal/fslock" + "github.com/sigstore/sigstore-go/pkg/root" + sigtuf "github.com/sigstore/sigstore-go/pkg/tuf" +) + +//go:embed testdata/*.json +var bundleFixture embed.FS + +func readFixtureTrustedRoot(t *testing.T) *root.TrustedRoot { + t.Helper() + trustedRootJSON, err := bundleFixture.ReadFile("testdata/trusted-root-public-good.json") + if err != nil { + t.Fatal(err) + } + trustedRoot, err := root.NewTrustedRootFromJSON(trustedRootJSON) + if err != nil { + t.Fatal(err) + } + return trustedRoot +} + +func readFixtureTrustedRootJSON(t *testing.T) []byte { + t.Helper() + trustedRootJSON, err := bundleFixture.ReadFile("testdata/trusted-root-public-good.json") + if err != nil { + t.Fatal(err) + } + return trustedRootJSON +} + +func newFixtureVerifier(t *testing.T) PackslipVerifier { + t.Helper() + trustedRoot, err := bundleFixture.ReadFile("testdata/trusted-root-public-good.json") + if err != nil { + t.Fatal(err) + } + verifier, err := New(Config{TrustedRootJSON: trustedRoot}) + if err != nil { + t.Fatal(err) + } + return verifier +} + +func newCacheVerifier(t *testing.T, cachePath string, disableCache bool) *nativeVerifier { + t.Helper() + verifier, err := New(Config{CachePath: cachePath, DisableLocalCache: disableCache}) + if err != nil { + t.Fatal(err) + } + native, ok := verifier.(*nativeVerifier) + if !ok { + t.Fatalf("New returned %T, want *nativeVerifier", verifier) + } + return native +} + +func TestNewParsesConfiguredTrustedRootAndParallelVerifyNeedsNoNetwork(t *testing.T) { + if _, err := New(Config{TrustedRootJSON: []byte("{")}); err == nil { + t.Fatal("New accepted invalid trusted-root JSON") + } + + fixture, err := bundleFixture.ReadFile("testdata/bundle-provenance.json") + if err != nil { + t.Fatal(err) + } + var networkCalls atomic.Int32 + httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + networkCalls.Add(1) + return nil, errors.New("unexpected TUF network request") + })} + verifier, err := New(Config{ + TrustedRootJSON: readFixtureTrustedRootJSON(t), + HTTPClient: httpClient, + }) + if err != nil { + t.Fatal(err) + } + identity := IdentityPolicy{Issuer: fixtureIssuer, SubjectRegex: fixtureSubject} + digest := ArtifactDigest{Algorithm: "sha512", Hex: fixtureDigest} + results := make(chan error, 2) + for range 2 { + go func() { + _, err := verifier.Verify(context.Background(), fixture, identity, digest) + results <- err + }() + } + for range 2 { + if err := <-results; err != nil { + t.Errorf("parallel Verify: %v", err) + } + } + if got := networkCalls.Load(); got != 0 { + t.Fatalf("configured trusted root caused %d network requests", got) + } +} + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestRequestContextHTTPClientCancelsBlockedRequest(t *testing.T) { + requestStarted := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, req *http.Request) { + close(requestStarted) + <-req.Context().Done() + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + if err != nil { + t.Fatal(err) + } + result := make(chan error, 1) + go func() { + _, err := (requestContextHTTPClient{ctx: ctx, client: server.Client()}).Do(req) + result <- err + }() + select { + case <-requestStarted: + case <-time.After(time.Second): + t.Fatal("HTTP request did not reach the test server") + } + cancel() + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("HTTP error = %v, want context cancellation", err) + } + case <-time.After(time.Second): + t.Fatal("blocked HTTP request did not stop after caller cancellation") + } +} + +func TestCacheLockWaitHonorsContextWithoutStartingTUFFetch(t *testing.T) { + cachePath := t.TempDir() + lock, err := fslock.AcquireContext(context.Background(), filepath.Join(cachePath, "tuf.lock")) + if err != nil { + t.Fatal(err) + } + defer lock.Release() + + verifier := newCacheVerifier(t, cachePath, false) + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Millisecond) + defer cancel() + var calls atomic.Int32 + _, err = verifier.trustedRootWithLoader(ctx, func(*sigtuf.Options) (*root.TrustedRoot, error) { + calls.Add(1) + return readFixtureTrustedRoot(t), nil + }) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("trustedRoot error = %v, want caller deadline", err) + } + if got := calls.Load(); got != 0 { + t.Fatalf("TUF loader was called %d times while cache lock was held", got) + } +} + +func TestSeparateVerifiersSerializeTUFCacheAccess(t *testing.T) { + cachePath := t.TempDir() + trustedRoot := readFixtureTrustedRoot(t) + firstEntered := make(chan struct{}) + secondEntered := make(chan struct{}) + allowFirstToFinish := make(chan struct{}) + var releaseOnce sync.Once + defer releaseOnce.Do(func() { close(allowFirstToFinish) }) + var calls atomic.Int32 + var active atomic.Int32 + var maxActive atomic.Int32 + loader := func(*sigtuf.Options) (*root.TrustedRoot, error) { + call := calls.Add(1) + current := active.Add(1) + for previous := maxActive.Load(); current > previous && !maxActive.CompareAndSwap(previous, current); previous = maxActive.Load() { + } + defer active.Add(-1) + if call == 1 { + close(firstEntered) + <-allowFirstToFinish + } else { + close(secondEntered) + } + return trustedRoot, nil + } + firstVerifier := newCacheVerifier(t, cachePath, false) + secondVerifier := newCacheVerifier(t, cachePath, false) + type loadResult struct { + root *root.TrustedRoot + err error + } + firstResult := make(chan loadResult, 1) + go func() { + root, err := firstVerifier.trustedRootWithLoader(context.Background(), loader) + firstResult <- loadResult{root: root, err: err} + }() + select { + case <-firstEntered: + case <-time.After(time.Second): + t.Fatal("first TUF loader did not enter") + } + secondStarted := make(chan struct{}) + secondResult := make(chan loadResult, 1) + go func() { + close(secondStarted) + root, err := secondVerifier.trustedRootWithLoader(context.Background(), loader) + secondResult <- loadResult{root: root, err: err} + }() + <-secondStarted + select { + case <-secondEntered: + t.Fatal("second verifier entered TUF while the first held the cache lock") + case <-time.After(120 * time.Millisecond): + } + + releaseOnce.Do(func() { close(allowFirstToFinish) }) + for name, resultChannel := range map[string]<-chan loadResult{ + "first": firstResult, + "second": secondResult, + } { + select { + case result := <-resultChannel: + if result.err != nil || result.root != trustedRoot { + t.Errorf("%s verifier got root %p, error %v", name, result.root, result.err) + } + case <-time.After(time.Second): + t.Errorf("%s verifier did not finish", name) + } + } + if got := calls.Load(); got != 2 { + t.Fatalf("TUF loader calls = %d, want 2", got) + } + if got := maxActive.Load(); got != 1 { + t.Fatalf("maximum concurrent TUF loaders = %d, want 1", got) + } +} + +func TestDisableLocalCacheDoesNotSerializeTUFFetches(t *testing.T) { + trustedRoot := readFixtureTrustedRoot(t) + entered := make(chan struct{}, 2) + allowLoadsToFinish := make(chan struct{}) + var releaseOnce sync.Once + defer releaseOnce.Do(func() { close(allowLoadsToFinish) }) + var active atomic.Int32 + var maxActive atomic.Int32 + loader := func(*sigtuf.Options) (*root.TrustedRoot, error) { + current := active.Add(1) + for previous := maxActive.Load(); current > previous && !maxActive.CompareAndSwap(previous, current); previous = maxActive.Load() { + } + entered <- struct{}{} + <-allowLoadsToFinish + active.Add(-1) + return trustedRoot, nil + } + verifiers := []*nativeVerifier{ + newCacheVerifier(t, "", true), + newCacheVerifier(t, "", true), + } + results := make(chan error, len(verifiers)) + for _, verifier := range verifiers { + go func(verifier *nativeVerifier) { + _, err := verifier.trustedRootWithLoader(context.Background(), loader) + results <- err + }(verifier) + } + for range len(verifiers) { + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("cache-disabled TUF fetches were unexpectedly serialized") + } + } + releaseOnce.Do(func() { close(allowLoadsToFinish) }) + for range len(verifiers) { + if err := <-results; err != nil { + t.Errorf("cache-disabled TUF fetch: %v", err) + } + } + if got := maxActive.Load(); got != 2 { + t.Fatalf("maximum concurrent TUF loaders = %d, want 2", got) + } +} + +func TestTUFCacheFailureCanRetryOnNextCall(t *testing.T) { + cachePath := t.TempDir() + verifier := newCacheVerifier(t, cachePath, false) + trustedRoot := readFixtureTrustedRoot(t) + failure := errors.New("temporary TUF target failure") + var calls atomic.Int32 + loader := func(*sigtuf.Options) (*root.TrustedRoot, error) { + if calls.Add(1) == 1 { + return nil, failure + } + return trustedRoot, nil + } + if got, err := verifier.trustedRootWithLoader(context.Background(), loader); got != nil || !errors.Is(err, failure) { + t.Fatalf("first fetch got root %p, error %v; want transient failure", got, err) + } + if got, err := verifier.trustedRootWithLoader(context.Background(), loader); got != trustedRoot || err != nil { + t.Fatalf("second fetch got root %p, error %v; want successful retry", got, err) + } + if got := calls.Load(); got != 2 { + t.Fatalf("TUF loader calls = %d, want 2", got) + } +} + +func TestNewUsesDBCSpecificDefaultTUFCachePath(t *testing.T) { + configPath, err := internal.GetUserConfigPath() + if err != nil { + t.Fatal(err) + } + verifier, err := New(Config{}) + if err != nil { + t.Fatal(err) + } + native := verifier.(*nativeVerifier) + want := filepath.Join(configPath, "cache", "sigstore") + if native.config.CachePath != want { + t.Fatalf("default cache path = %q, want dbc-specific %q", native.config.CachePath, want) + } + if strings.Contains(native.config.CachePath, filepath.Join(".sigstore", "root")) { + t.Fatalf("default cache path is shared with other Sigstore tools: %q", native.config.CachePath) + } +} + +const ( + fixtureIssuer = "https://token.actions.githubusercontent.com" + fixtureSubject = `^https://github\.com/sigstore/sigstore-js/\.github/workflows/release\.yml@refs/heads/main$` + fixtureDigest = "76176ffa33808b54602c7c35de5c6e9a4deb96066dba6533f50ac234f4f1f4c6b3527515dc17c06fbe2860030f410eee69ea20079bd3a2c6f3dcf3b329b10751" +) + +func TestNativeVerifierVerifiesSignedBundle(t *testing.T) { + fixture, err := bundleFixture.ReadFile("testdata/bundle-provenance.json") + if err != nil { + t.Fatal(err) + } + + verifier := newFixtureVerifier(t) + result, err := verifier.Verify(context.Background(), fixture, IdentityPolicy{ + Issuer: fixtureIssuer, + SubjectRegex: fixtureSubject, + }, ArtifactDigest{Algorithm: "sha512", Hex: fixtureDigest}) + if err != nil { + t.Fatalf("verify signed fixture: %v", err) + } + if result.Issuer != fixtureIssuer { + t.Fatalf("verified issuer = %q, want %q", result.Issuer, fixtureIssuer) + } + wantSigner := "https://github.com/sigstore/sigstore-js/.github/workflows/release.yml@refs/heads/main" + if result.Signer != wantSigner { + t.Fatalf("verified signer = %q, want %q", result.Signer, wantSigner) + } + if result.Statement == nil || len(result.Statement.Subjects) != 1 { + t.Fatalf("verified statement = %#v, want one subject", result.Statement) + } + if got := result.Statement.Subjects[0].Digests["sha512"]; got != fixtureDigest { + t.Fatalf("verified subject digest = %q, want %q", got, fixtureDigest) + } + if !strings.Contains(string(result.SignedPayload), "https://in-toto.io/Statement/v0.1") { + t.Fatalf("verified payload does not contain the in-toto statement: %s", result.SignedPayload) + } +} + +func TestNativeVerifierRejectsWrongDigestAndIdentity(t *testing.T) { + fixture, err := bundleFixture.ReadFile("testdata/bundle-provenance.json") + if err != nil { + t.Fatal(err) + } + verifier := newFixtureVerifier(t) + identity := IdentityPolicy{Issuer: fixtureIssuer, SubjectRegex: fixtureSubject} + + if result, err := verifier.Verify(context.Background(), fixture, identity, ArtifactDigest{ + Algorithm: "sha512", + Hex: strings.Repeat("0", 128), + }); err == nil || result != nil { + t.Fatalf("wrong artifact digest returned result %#v, error %v", result, err) + } + + if result, err := verifier.Verify(context.Background(), fixture, IdentityPolicy{ + Issuer: fixtureIssuer, + SubjectRegex: `^https://github\.com/attacker/.*$`, + }, ArtifactDigest{Algorithm: "sha512", Hex: fixtureDigest}); err == nil || result != nil { + t.Fatalf("wrong signing identity returned result %#v, error %v", result, err) + } +} + +func TestNativeVerifierRejectsUnanchoredIdentityRegex(t *testing.T) { + verifier := newFixtureVerifier(t) + _, err := verifier.Verify(context.Background(), []byte("{}"), IdentityPolicy{ + Issuer: fixtureIssuer, + SubjectRegex: `sigstore-js`, + }, ArtifactDigest{Algorithm: "sha512", Hex: fixtureDigest}) + if err == nil || !strings.Contains(err.Error(), "must be anchored") { + t.Fatalf("error = %v, want unanchored identity policy error", err) + } +} diff --git a/internal/resolution/types.go b/internal/resolution/types.go new file mode 100644 index 00000000..09edca31 --- /dev/null +++ b/internal/resolution/types.go @@ -0,0 +1,387 @@ +// 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 resolution contains the shared, module-internal source resolution +// model. Keeping these types under internal prevents them from becoming part +// of dbc's public Go API while allowing the CLI and library to share them. +package resolution + +import ( + "fmt" + "net/url" + "strings" +) + +// ArtifactLocationKind identifies how an artifact location is interpreted. +type ArtifactLocationKind string + +const ( + ArtifactLocationURL ArtifactLocationKind = "url" + ArtifactLocationPath ArtifactLocationKind = "path" +) + +// ArtifactLocation identifies an archive by either a remote URL or a local +// filesystem path. Relative paths are resolved against the lockfile directory +// by an executor, never against the process working directory. +type ArtifactLocation struct { + Kind ArtifactLocationKind `toml:"kind" json:"kind"` + Value string `toml:"value" json:"value"` +} + +// ValidateArtifactLocation validates the shared artifact-location contract. +// Source adapters may apply stricter URL policies before constructing a URL +// location. +func ValidateArtifactLocation(location ArtifactLocation) error { + if location.Value == "" { + return fmt.Errorf("artifact location value is empty") + } + switch location.Kind { + case ArtifactLocationURL: + parsed, err := url.Parse(location.Value) + if err != nil || !parsed.IsAbs() || parsed.Opaque != "" || + (!strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https")) || + parsed.Hostname() == "" || parsed.User != nil || strings.Contains(location.Value, "#") { + return fmt.Errorf("artifact URL must be an absolute HTTP(S) URL with a host and without userinfo or fragment") + } + case ArtifactLocationPath: + if strings.IndexByte(location.Value, 0) >= 0 { + return fmt.Errorf("artifact path must not contain NUL") + } + if looksLikeArtifactURI(location.Value) { + return fmt.Errorf("artifact path must not use URI syntax") + } + default: + return fmt.Errorf("unsupported artifact location kind %q", location.Kind) + } + return nil +} + +func looksLikeArtifactURI(value string) bool { + // Keep Windows drive paths usable on every host while rejecting URI schemes + // and URLs accidentally placed in the path field. + if len(value) >= 3 && ((value[0] >= 'a' && value[0] <= 'z') || (value[0] >= 'A' && value[0] <= 'Z')) && + value[1] == ':' && (value[2] == '/' || value[2] == '\\') { + return false + } + if strings.Contains(value, "://") { + return true + } + parsed, err := url.Parse(value) + return err == nil && parsed.Scheme != "" +} + +// Target identifies one concrete operating-system and architecture target. +// Empty variant denotes the ordinary variant; it is never a wildcard. +type Target struct { + OS string `toml:"os" json:"os"` + Arch string `toml:"arch" json:"arch"` + LibC string `toml:"libc,omitempty" json:"libc,omitempty"` + Variant string `toml:"variant,omitempty" json:"variant,omitempty"` +} + +// CanonicalTarget applies the small set of established aliases at adapter +// boundaries. Unknown values are preserved rather than guessed. +func CanonicalTarget(target Target) Target { + switch target.OS { + case "darwin": + target.OS = "macos" + } + switch target.Arch { + case "x86_64": + target.Arch = "amd64" + case "aarch64": + target.Arch = "arm64" + } + if target.OS == "linux" && target.LibC == "" { + target.LibC = "gnu" + } + return target +} + +// TargetFromPlatformTuple is a one-way adapter from the CLI/registry tuple +// grammar to a canonical target: the tuple carries OS, architecture, the known +// gnu/musl libc tokens, and optional variant components. It does not infer an +// unknown libc or promise that arbitrary Target values can be encoded back into +// a tuple. Callers that need the original tuple for URL construction must keep +// it separately. +func TargetFromPlatformTuple(platform string) (Target, error) { + if platform == "" { + return Target{}, fmt.Errorf("platform tuple is empty") + } + parts := strings.Split(platform, "_") + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return Target{}, fmt.Errorf("platform tuple %q must contain OS and architecture", platform) + } + archEnd := 2 + arch := parts[1] + if len(parts) > 2 && parts[1] == "x86" && parts[2] == "64" { + arch = "x86_64" + archEnd = 3 + } + target := Target{OS: parts[0], Arch: arch} + for _, part := range parts[archEnd:] { + if part == "" { + return Target{}, fmt.Errorf("platform tuple %q contains an empty component", platform) + } + switch part { + case "gnu", "musl": + if target.LibC != "" { + return Target{}, fmt.Errorf("platform tuple %q contains multiple libc components", platform) + } + target.LibC = part + default: + if target.Variant == "" { + target.Variant = part + } else { + target.Variant += "_" + part + } + } + } + target = CanonicalTarget(target) + if err := ValidateConcreteTarget(target); err != nil { + return Target{}, fmt.Errorf("platform tuple %q: %w", platform, err) + } + return target, nil +} + +// ValidateConcreteTarget rejects selectors that cannot identify a concrete +// target. Token values are intentionally not restricted to a known platform +// inventory so new platforms can be represented without guessing semantics. +func ValidateConcreteTarget(target Target) error { + for _, selector := range []struct{ field, value string }{ + {field: "OS", value: target.OS}, + {field: "architecture", value: target.Arch}, + {field: "libc", value: target.LibC}, + {field: "variant", value: target.Variant}, + } { + field, value := selector.field, selector.value + if value == "" { + if field == "OS" || field == "architecture" { + return fmt.Errorf("target %s is empty", field) + } + continue + } + if !validTargetToken(value) { + return fmt.Errorf("target %s %q is invalid", field, value) + } + } + return nil +} + +func validTargetToken(value string) bool { + if value == "" || !((value[0] >= 'a' && value[0] <= 'z') || (value[0] >= '0' && value[0] <= '9')) { + return false + } + for _, ch := range value { + if (ch < 'a' || ch > 'z') && (ch < '0' || ch > '9') && ch != '_' && ch != '.' && ch != '-' { + return false + } + } + return true +} + +// SourceSpec identifies a source and its source-specific reference. Reference +// is interpreted by the resolver for Type; it may be a registry URL, project +// identity, or local path. +type SourceSpec struct { + Type string + Reference string +} + +// EvidenceKind identifies the kind of immutable metadata observed while +// resolving a release. +type EvidenceKind string + +const ( + EvidenceKindReleaseMetadata EvidenceKind = "release-metadata" + EvidenceKindReleaseIndex EvidenceKind = "release-index" +) + +// Evidence records one content-addressed source document observed during a +// resolution. Its location is typed independently from source identity. +type Evidence struct { + Kind EvidenceKind `toml:"kind" json:"kind"` + Location ArtifactLocation `toml:"location" json:"location"` + Hash string `toml:"hash" json:"hash"` +} + +// ResolvedRelease is a source-independent release snapshot. Artifacts may be a +// partial target set; callers must not infer that omitted targets exist. +type ResolvedRelease struct { + DriverID string + Version string + Source SourceSpec + Evidence []Evidence + Artifacts []Artifact +} + +// Artifact describes one downloadable archive for one concrete target. Hash +// may be absent before resolution but is required for a finalized snapshot. +// Size is optional metadata. +type Artifact struct { + Target Target + Format string + Location ArtifactLocation + Hash string + Size *int64 + HostRequirements HostRequirements +} + +// HostRequirements contains runtime requirements declared by a release. +type HostRequirements struct { + OSMin string + GLibCMin string + Libs []string + Bins []NamedRequirement +} + +// NamedRequirement is a host library or binary requirement and its minimum +// version, when one is specified. +type NamedRequirement struct { + Name string + Min string +} + +// ValidateArtifactMetadata validates the optional archive digest and size. +// The current metadata contract supports SHA-256 only and uses the canonical +// "sha256:" representation. +func ValidateArtifactMetadata(hash string, size *int64) error { + if hash != "" { + algorithm, digest, found := strings.Cut(hash, ":") + if !found { + return fmt.Errorf("artifact hash must include an algorithm prefix") + } + if algorithm != "sha256" { + return fmt.Errorf("unsupported artifact hash algorithm %q", algorithm) + } + if len(digest) != 64 { + return fmt.Errorf("sha256 artifact hash must contain 64 lowercase hexadecimal characters") + } + for _, ch := range digest { + if (ch < '0' || ch > '9') && (ch < 'a' || ch > 'f') { + return fmt.Errorf("sha256 artifact hash must contain 64 lowercase hexadecimal characters") + } + } + } + if size != nil && *size < 0 { + return fmt.Errorf("artifact size must not be negative") + } + return nil +} + +// ValidateEvidence checks that evidence records are typed, content-addressed, +// and unambiguous within one resolution snapshot. +func ValidateEvidence(evidence []Evidence) error { + type evidenceKey struct { + kind EvidenceKind + location ArtifactLocation + } + seen := make(map[evidenceKey]struct{}, len(evidence)) + hashesByLocation := make(map[ArtifactLocation]string, len(evidence)) + for i, item := range evidence { + if item.Kind != EvidenceKindReleaseMetadata && item.Kind != EvidenceKindReleaseIndex { + return fmt.Errorf("evidence %d has unsupported kind %q", i, item.Kind) + } + if err := ValidateArtifactLocation(item.Location); err != nil { + return fmt.Errorf("evidence %d has invalid location: %w", i, err) + } + if item.Hash == "" { + return fmt.Errorf("evidence %d has no hash", i) + } + if err := ValidateArtifactMetadata(item.Hash, nil); err != nil { + return fmt.Errorf("evidence %d has invalid hash: %w", i, err) + } + key := evidenceKey{kind: item.Kind, location: item.Location} + if _, ok := seen[key]; ok { + return fmt.Errorf("evidence contains a duplicate kind and location") + } + seen[key] = struct{}{} + if hash, ok := hashesByLocation[item.Location]; ok && hash != item.Hash { + return fmt.Errorf("evidence records sharing a location have conflicting hashes") + } + hashesByLocation[item.Location] = item.Hash + } + return nil +} + +// ValidateResolvedReleaseCandidate checks a resolved release before it is +// downloaded or used to form an install candidate. Archive metadata may be +// absent or contain a hash with an optional size; a size without a hash is +// invalid. +func ValidateResolvedReleaseCandidate(release ResolvedRelease) error { + if err := ValidateEvidence(release.Evidence); err != nil { + return fmt.Errorf("invalid evidence: %w", err) + } + if len(release.Artifacts) == 0 { + return fmt.Errorf("resolved release must contain at least one artifact") + } + + seenTargets := make(map[Target]struct{}, len(release.Artifacts)) + seenLocations := make(map[ArtifactLocation]Artifact, len(release.Artifacts)) + for i, artifact := range release.Artifacts { + if err := ValidateArtifactLocation(artifact.Location); err != nil { + return fmt.Errorf("artifact %d has invalid location: %w", i, err) + } + if canonical := CanonicalTarget(artifact.Target); canonical != artifact.Target { + return fmt.Errorf("artifact %d target is not canonical", i) + } + if err := ValidateConcreteTarget(artifact.Target); err != nil { + return fmt.Errorf("artifact %d has invalid target: %w", i, err) + } + if _, ok := seenTargets[artifact.Target]; ok { + return fmt.Errorf("resolved release contains duplicate target") + } + seenTargets[artifact.Target] = struct{}{} + if prior, ok := seenLocations[artifact.Location]; ok { + if !compatibleArtifactMetadata(prior, artifact) { + return fmt.Errorf("artifacts sharing a location have conflicting hash or size") + } + if prior.Hash == "" && artifact.Hash != "" || prior.Size == nil && artifact.Size != nil { + seenLocations[artifact.Location] = artifact + } + } else { + seenLocations[artifact.Location] = artifact + } + + if artifact.Hash == "" && artifact.Size != nil { + return fmt.Errorf("artifact %d has a size without a hash", i) + } + if err := ValidateArtifactMetadata(artifact.Hash, artifact.Size); err != nil { + return fmt.Errorf("artifact %d has invalid metadata: %w", i, err) + } + } + return nil +} + +// ValidateResolvedRelease checks the contract required before a release can +// become a lockfile snapshot. In addition to candidate validation, every +// artifact must have a finalized hash; size remains optional metadata. +func ValidateResolvedRelease(release ResolvedRelease) error { + if err := ValidateResolvedReleaseCandidate(release); err != nil { + return err + } + for i, artifact := range release.Artifacts { + if artifact.Hash == "" { + return fmt.Errorf("artifact %d has no finalized hash", i) + } + } + return nil +} + +func compatibleArtifactMetadata(left, right Artifact) bool { + if left.Hash != "" && right.Hash != "" && left.Hash != right.Hash { + return false + } + return left.Size == nil || right.Size == nil || *left.Size == *right.Size +} diff --git a/internal/resolution/types_test.go b/internal/resolution/types_test.go new file mode 100644 index 00000000..0bc65110 --- /dev/null +++ b/internal/resolution/types_test.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 resolution + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateResolvedRelease(t *testing.T) { + size := int64(12) + complete := ResolvedRelease{ + DriverID: "example-driver", + Version: "1.2.3", + Source: SourceSpec{ + Type: "packslip", + Reference: "github.com/example/example-driver", + }, + Evidence: []Evidence{{ + Kind: EvidenceKindReleaseMetadata, + Location: ArtifactLocation{Kind: ArtifactLocationURL, Value: "https://example.test/release.sigstore.json"}, + Hash: "sha256:" + strings.Repeat("a", 64), + }}, + Artifacts: []Artifact{{ + Target: Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, + Format: "tar.gz", + Location: ArtifactLocation{Kind: ArtifactLocationURL, Value: "https://example.test/driver.tar.gz"}, + Hash: "sha256:" + strings.Repeat("b", 64), + Size: &size, + HostRequirements: HostRequirements{ + OSMin: "1.0", + GLibCMin: "2.17", + Libs: []string{"libc.so.6"}, + Bins: []NamedRequirement{{Name: "java", Min: "17"}}, + }, + }}, + } + + require.NoError(t, ValidateResolvedRelease(complete)) + assert.Equal(t, "packslip", complete.Source.Type) + assert.Equal(t, "https://example.test/release.sigstore.json", complete.Evidence[0].Location.Value) + assert.Equal(t, "2.17", complete.Artifacts[0].HostRequirements.GLibCMin) +} + +func TestValidateEvidence(t *testing.T) { + metadata := Evidence{ + Kind: EvidenceKindReleaseMetadata, + Location: ArtifactLocation{Kind: ArtifactLocationURL, Value: "https://example.test/metadata"}, + Hash: "sha256:" + strings.Repeat("a", 64), + } + index := Evidence{ + Kind: EvidenceKindReleaseIndex, + Location: ArtifactLocation{Kind: ArtifactLocationURL, Value: "https://example.test/index"}, + Hash: "sha256:" + strings.Repeat("b", 64), + } + pathEvidence := Evidence{ + Kind: EvidenceKindReleaseIndex, + Location: ArtifactLocation{Kind: ArtifactLocationPath, Value: "./cache/release-index.json"}, + Hash: "sha256:" + strings.Repeat("c", 64), + } + assert.NoError(t, ValidateEvidence(nil), "sources without separate evidence may use an empty slice") + assert.NoError(t, ValidateEvidence([]Evidence{metadata, index})) + assert.NoError(t, ValidateEvidence([]Evidence{pathEvidence}), "evidence locations may be local paths") + + tests := []struct { + name string + evidence []Evidence + wantErr string + }{ + {name: "empty kind", evidence: []Evidence{{Location: metadata.Location, Hash: metadata.Hash}}, wantErr: "unsupported kind"}, + {name: "unknown kind", evidence: []Evidence{{Kind: "signature", Location: metadata.Location, Hash: metadata.Hash}}, wantErr: "unsupported kind"}, + {name: "empty location kind", evidence: []Evidence{{Kind: metadata.Kind, Location: ArtifactLocation{Value: metadata.Location.Value}, Hash: metadata.Hash}}, wantErr: "invalid location"}, + {name: "empty location value", evidence: []Evidence{{Kind: metadata.Kind, Location: ArtifactLocation{Kind: ArtifactLocationURL}, Hash: metadata.Hash}}, wantErr: "invalid location"}, + {name: "invalid location", evidence: []Evidence{{Kind: metadata.Kind, Location: ArtifactLocation{Kind: ArtifactLocationURL, Value: "file:///tmp/evidence"}, Hash: metadata.Hash}}, wantErr: "invalid location"}, + {name: "missing hash", evidence: []Evidence{{Kind: metadata.Kind, Location: metadata.Location}}, wantErr: "no hash"}, + {name: "invalid hash", evidence: []Evidence{{Kind: metadata.Kind, Location: metadata.Location, Hash: "sha512:abcd"}}, wantErr: "invalid hash"}, + {name: "duplicate exact", evidence: []Evidence{metadata, metadata}, wantErr: "duplicate kind and location"}, + {name: "duplicate conflicting hash", evidence: []Evidence{metadata, {Kind: metadata.Kind, Location: metadata.Location, Hash: index.Hash}}, wantErr: "duplicate kind and location"}, + {name: "same location conflicting across kinds", evidence: []Evidence{metadata, {Kind: index.Kind, Location: metadata.Location, Hash: index.Hash}}, wantErr: "conflicting hashes"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.ErrorContains(t, ValidateEvidence(test.evidence), test.wantErr) + }) + } + assert.NoError(t, ValidateEvidence([]Evidence{metadata, { + Kind: EvidenceKindReleaseIndex, Location: metadata.Location, Hash: metadata.Hash, + }}), "different kinds may share a typed location when their hashes agree") +} + +func TestValidateResolvedReleaseRequiresFinalizedArtifacts(t *testing.T) { + size := int64(12) + base := ResolvedRelease{Artifacts: []Artifact{{ + Target: Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, + Location: ArtifactLocation{Kind: ArtifactLocationURL, Value: "https://example.test/driver.tar.gz"}, + Hash: "sha256:" + strings.Repeat("a", 64), + Size: &size, + }}} + + tests := []struct { + name string + mutate func(*ResolvedRelease) + wantErr string + }{ + {name: "missing hash", mutate: func(r *ResolvedRelease) { r.Artifacts[0].Hash = "" }, wantErr: "has a size without a hash"}, + {name: "missing location", mutate: func(r *ResolvedRelease) { r.Artifacts[0].Location = ArtifactLocation{} }, wantErr: "invalid location"}, + {name: "duplicate target", mutate: func(r *ResolvedRelease) { + duplicate := r.Artifacts[0] + r.Artifacts = append(r.Artifacts, duplicate) + }, wantErr: "duplicate target"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + release := base + release.Artifacts = append([]Artifact(nil), base.Artifacts...) + tt.mutate(&release) + require.ErrorContains(t, ValidateResolvedRelease(release), tt.wantErr) + }) + } +} + +func TestValidateResolvedReleaseCandidateAllowsOptionalArchiveSize(t *testing.T) { + size := int64(12) + candidate := ResolvedRelease{Artifacts: []Artifact{{ + Target: Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, + Location: ArtifactLocation{Kind: ArtifactLocationURL, Value: "https://registry.example.test/driver.tar.gz"}, + }}} + + assert.NoError(t, ValidateResolvedReleaseCandidate(candidate)) + assert.ErrorContains(t, ValidateResolvedRelease(candidate), "no finalized hash") + candidate.Artifacts[0].Size = &size + assert.ErrorContains(t, ValidateResolvedReleaseCandidate(candidate), "size without a hash") + candidate.Artifacts[0].Size = nil + candidate.Artifacts[0].Hash = "sha256:" + strings.Repeat("a", 64) + assert.NoError(t, ValidateResolvedReleaseCandidate(candidate)) + assert.NoError(t, ValidateResolvedRelease(candidate)) + candidate.Artifacts[0].Size = &size + assert.NoError(t, ValidateResolvedReleaseCandidate(candidate)) + assert.NoError(t, ValidateResolvedRelease(candidate)) + assert.NoError(t, ValidateArtifactMetadata("", nil)) +} + +func TestTargetAliasesAndTupleAdapter(t *testing.T) { + tests := []struct { + platform string + want Target + }{ + {platform: "darwin_aarch64", want: Target{OS: "macos", Arch: "arm64"}}, + {platform: "linux_x86_64", want: Target{OS: "linux", Arch: "amd64", LibC: "gnu"}}, + {platform: "linux_amd64_musl_v3", want: Target{OS: "linux", Arch: "amd64", LibC: "musl", Variant: "v3"}}, + {platform: "plan9_oddarch", want: Target{OS: "plan9", Arch: "oddarch"}}, + } + for _, test := range tests { + t.Run(test.platform, func(t *testing.T) { + got, err := TargetFromPlatformTuple(test.platform) + assert.NoError(t, err) + assert.Equal(t, test.want, got) + }) + } +} + +func TestEmptyConcreteTargetIsNotAWildcard(t *testing.T) { + assert.Error(t, ValidateConcreteTarget(Target{})) + assert.NoError(t, ValidateConcreteTarget(Target{OS: "linux", Arch: "amd64", LibC: "gnu"}), + "empty variant identifies the ordinary concrete variant") +} + +func TestResolvedReleaseAllowsSharedLocationButRejectsConflictingMetadata(t *testing.T) { + size := int64(12) + base := Artifact{ + Target: Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, Location: ArtifactLocation{Kind: ArtifactLocationURL, Value: "https://example.test/shared.tar.gz"}, + Hash: "sha256:" + strings.Repeat("a", 64), Size: &size, + } + release := ResolvedRelease{Artifacts: []Artifact{base, { + Target: Target{OS: "macos", Arch: "arm64"}, Location: base.Location, Hash: base.Hash, Size: &size, + }}} + assert.NoError(t, ValidateResolvedRelease(release)) + release.Artifacts[1].Size = nil + assert.NoError(t, ValidateResolvedRelease(release), "a known size is compatible with an omitted size") + release.Artifacts[1].Size = &size + release.Artifacts[1].Hash = "sha256:" + strings.Repeat("b", 64) + assert.ErrorContains(t, ValidateResolvedRelease(release), "conflicting hash or size") +} + +func TestValidateArtifactLocation(t *testing.T) { + tests := []struct { + name string + location ArtifactLocation + wantErr string + }{ + {name: "http URL", location: ArtifactLocation{Kind: ArtifactLocationURL, Value: "http://example.test/a%2Fb?token=x%2Fy"}}, + {name: "HTTPS URL spelling preserved", location: ArtifactLocation{Kind: ArtifactLocationURL, Value: "HTTPS://Example.test/a%2Fb?token=x%2Fy"}}, + {name: "relative path", location: ArtifactLocation{Kind: ArtifactLocationPath, Value: "./packages/archive.tar.gz"}}, + {name: "absolute path", location: ArtifactLocation{Kind: ArtifactLocationPath, Value: "/var/cache/archive.tar.gz"}}, + {name: "Windows path", location: ArtifactLocation{Kind: ArtifactLocationPath, Value: `C:\cache\archive.tar.gz`}}, + {name: "empty kind", location: ArtifactLocation{Value: "https://example.test/a"}, wantErr: "unsupported artifact location kind"}, + {name: "unknown kind", location: ArtifactLocation{Kind: "other", Value: "anything"}, wantErr: "unsupported artifact location kind"}, + {name: "empty value", location: ArtifactLocation{Kind: ArtifactLocationPath}, wantErr: "value is empty"}, + {name: "relative URL", location: ArtifactLocation{Kind: ArtifactLocationURL, Value: "/archive.tar.gz"}, wantErr: "absolute HTTP(S)"}, + {name: "file URL", location: ArtifactLocation{Kind: ArtifactLocationURL, Value: "file:///tmp/archive.tar.gz"}, wantErr: "absolute HTTP(S)"}, + {name: "opaque URL", location: ArtifactLocation{Kind: ArtifactLocationURL, Value: "https:archive.tar.gz"}, wantErr: "absolute HTTP(S)"}, + {name: "userinfo URL", location: ArtifactLocation{Kind: ArtifactLocationURL, Value: "https://user:pass@example.test/archive"}, wantErr: "without userinfo"}, + {name: "fragment URL", location: ArtifactLocation{Kind: ArtifactLocationURL, Value: "https://example.test/archive#section"}, wantErr: "without userinfo"}, + {name: "empty fragment URL", location: ArtifactLocation{Kind: ArtifactLocationURL, Value: "https://example.test/archive#"}, wantErr: "without userinfo"}, + {name: "path NUL", location: ArtifactLocation{Kind: ArtifactLocationPath, Value: "archive\x00.tar.gz"}, wantErr: "NUL"}, + {name: "path URL", location: ArtifactLocation{Kind: ArtifactLocationPath, Value: "https://example.test/archive"}, wantErr: "URI syntax"}, + {name: "path file URI", location: ArtifactLocation{Kind: ArtifactLocationPath, Value: "file:///tmp/archive"}, wantErr: "URI syntax"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := ValidateArtifactLocation(test.location) + if test.wantErr != "" { + assert.ErrorContains(t, err, test.wantErr) + return + } + assert.NoError(t, err) + }) + } +} + +func TestResolvedReleaseLocationIdentityIncludesKind(t *testing.T) { + urlLocation := ArtifactLocation{Kind: ArtifactLocationURL, Value: "https://example.test/archive"} + pathLocation := ArtifactLocation{Kind: ArtifactLocationPath, Value: urlLocation.Value} + assert.NotEqual(t, urlLocation, pathLocation, "location identity includes its kind") +} diff --git a/internal/sourceidentity/sourceidentity.go b/internal/sourceidentity/sourceidentity.go new file mode 100644 index 00000000..526de651 --- /dev/null +++ b/internal/sourceidentity/sourceidentity.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. + +// Package sourceidentity defines the canonical identities of declared driver +// sources. It has no dependencies outside the standard library so source +// adapters can share identity rules without importing one another. +package sourceidentity + +import ( + "errors" + "fmt" + "net/url" + "strings" +) + +// Kind is the type of a declared driver source. +type Kind string + +const ( + Registry Kind = "registry" + Packslip Kind = "packslip" + Path Kind = "path" +) + +// Key is a comparable source identity. Reference contains the canonical form +// of the source-specific reference and is suitable for direct equality checks +// and map keys. +type Key struct { + Kind Kind + Reference string +} + +// Parse validates and canonicalizes a source reference into its identity key. +// It never substitutes malformed input with a best-effort raw-string key. +func Parse(kind Kind, reference string) (Key, error) { + switch kind { + case Registry: + return parseRegistry(reference) + case Packslip: + return parsePackslip(reference) + case Path: + if reference == "" { + return Key{}, errors.New("path source has no path") + } + return Key{Kind: Path, Reference: reference}, nil + default: + return Key{}, fmt.Errorf("unsupported driver source type %q", kind) + } +} + +func parseRegistry(reference string) (Key, error) { + if reference == "" { + return Key{}, errors.New("registry source has no URL") + } + + parsed, err := url.Parse(reference) + if err != nil { + return Key{}, fmt.Errorf("invalid registry URL %q: %w", reference, err) + } + if parsed.Host == "" || parsed.Hostname() == "" { + return Key{}, fmt.Errorf("invalid registry URL %q: missing host", reference) + } + if !strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https") { + return Key{}, fmt.Errorf("invalid registry URL %q: scheme must be http or https", reference) + } + + canonical := *parsed + canonical.Scheme = strings.ToLower(canonical.Scheme) + canonical.Host = strings.ToLower(canonical.Host) + + // Trim only literal trailing slash bytes in the escaped path. In particular, + // an encoded slash such as %2F remains part of the path segment and must not + // become equivalent to a path separator. + escapedPath := strings.TrimRight(canonical.EscapedPath(), "/") + path, err := url.PathUnescape(escapedPath) + if err != nil { + return Key{}, fmt.Errorf("invalid registry URL %q: %w", reference, err) + } + canonical.Path = path + if (&url.URL{Path: path}).EscapedPath() == escapedPath { + canonical.RawPath = "" + } else { + canonical.RawPath = escapedPath + } + + // Fragments are client-side only and do not affect the HTTP request. Keep + // RawQuery and ForceQuery untouched: both are part of the configured URL. + canonical.Fragment = "" + canonical.RawFragment = "" + return Key{Kind: Registry, Reference: canonical.String()}, nil +} + +func parsePackslip(reference string) (Key, error) { + if reference != strings.TrimSpace(reference) || strings.Contains(reference, "://") || strings.HasSuffix(reference, "/") { + return Key{}, fmt.Errorf("packslip project must be a GitHub host path without a URL scheme: %q", reference) + } + parts := strings.Split(reference, "/") + if len(parts) < 3 || !strings.EqualFold(parts[0], "github.com") { + return Key{}, fmt.Errorf("packslip project must use github.com/owner/repo[/tool...]: %q", reference) + } + for i, part := range parts { + if part == "" || part == "." || part == ".." { + return Key{}, fmt.Errorf("packslip project contains an invalid path segment: %q", reference) + } + for _, ch := range part { + if !(ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' || strings.ContainsRune("._-", ch)) { + return Key{}, fmt.Errorf("packslip project contains an invalid path segment: %q", reference) + } + } + if i < 3 { + parts[i] = strings.ToLower(part) + } + } + return Key{Kind: Packslip, Reference: strings.Join(parts, "/")}, nil +} diff --git a/internal/sourceidentity/sourceidentity_test.go b/internal/sourceidentity/sourceidentity_test.go new file mode 100644 index 00000000..6d5ccc2d --- /dev/null +++ b/internal/sourceidentity/sourceidentity_test.go @@ -0,0 +1,82 @@ +// 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 sourceidentity + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRegistryIdentity(t *testing.T) { + tests := []struct { + name string + a string + b string + equal bool + }{ + {name: "scheme and host case normalize", a: "HTTPS://R.Example.test/a", b: "https://r.example.test/a", equal: true}, + {name: "literal trailing slash normalizes", a: "https://r.example.test/a///", b: "https://r.example.test/a", equal: true}, + {name: "fragment is excluded", a: "https://r.example.test/a#one", b: "https://r.example.test/a#two", equal: true}, + {name: "query is preserved", a: "https://r.example.test/?tenant=a", b: "https://r.example.test/?tenant=b"}, + {name: "query order is preserved", a: "https://r.example.test/?a=1&b=2", b: "https://r.example.test/?b=2&a=1"}, + {name: "userinfo is preserved", a: "https://user-a:pw@r.example.test", b: "https://user-b:pw@r.example.test"}, + {name: "empty force query is preserved", a: "https://r.example.test?", b: "https://r.example.test"}, + {name: "escaped separator differs from literal separator", a: "https://r.example.test/a%2Fb", b: "https://r.example.test/a/b"}, + {name: "escaped trailing separator is not a literal trailing slash", a: "https://r.example.test/a%2F", b: "https://r.example.test/a"}, + {name: "path segments are not cleaned", a: "https://r.example.test/a/../b", b: "https://r.example.test/b"}, + {name: "default port is preserved", a: "https://r.example.test:443", b: "https://r.example.test"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + a, err := Parse(Registry, test.a) + require.NoError(t, err) + b, err := Parse(Registry, test.b) + require.NoError(t, err) + assert.Equal(t, test.equal, a == b) + }) + } +} + +func TestParseValidatesEachSourceKind(t *testing.T) { + tests := []struct { + name string + kind Kind + reference string + wantKey Key + wantErr string + }{ + {name: "registry", kind: Registry, reference: "https://registry.example.test/", wantKey: Key{Kind: Registry, Reference: "https://registry.example.test"}}, + {name: "registry requires HTTP(S)", kind: Registry, reference: "ftp://registry.example.test", wantErr: "scheme must be http or https"}, + {name: "registry requires a host", kind: Registry, reference: "https:///registry", wantErr: "missing host"}, + {name: "packslip host owner repo case folds", kind: Packslip, reference: "GitHub.COM/Acme/Driver/Tools/Tool", wantKey: Key{Kind: Packslip, Reference: "github.com/acme/driver/Tools/Tool"}}, + {name: "packslip rejects a URL", kind: Packslip, reference: "https://github.com/acme/driver", wantErr: "without a URL scheme"}, + {name: "packslip rejects malformed segment", kind: Packslip, reference: "github.com/acme//driver", wantErr: "invalid path segment"}, + {name: "path preserves exact string", kind: Path, reference: "../packages/./driver.tgz", wantKey: Key{Kind: Path, Reference: "../packages/./driver.tgz"}}, + {name: "path requires a value", kind: Path, wantErr: "path source has no path"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + key, err := Parse(test.kind, test.reference) + if test.wantErr != "" { + require.ErrorContains(t, err, test.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, test.wantKey, key) + }) + } +} diff --git a/internal/sourceresolution/opener.go b/internal/sourceresolution/opener.go new file mode 100644 index 00000000..65b10a59 --- /dev/null +++ b/internal/sourceresolution/opener.go @@ -0,0 +1,155 @@ +// 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 + +package sourceresolution + +import ( + "context" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "sync" + + "github.com/columnar-tech/dbc/internal/resolution" +) + +// URLFetcher retrieves one URL artifact. Callers may adapt an authenticated +// client such as dbc.Client.Download without coupling this package to its API. +type URLFetcher func(context.Context, *url.URL) (io.ReadCloser, error) + +// OpenedArtifact owns a private temporary copy of an artifact. Close removes +// only that temporary copy; local source paths and their parent directories +// are never cleanup targets. +type OpenedArtifact struct { + File *os.File + tempDir string + once sync.Once + err error +} + +func (artifact *OpenedArtifact) Close() error { + if artifact == nil { + return nil + } + artifact.once.Do(func() { + if artifact.File != nil { + artifact.err = artifact.File.Close() + } + if artifact.tempDir != "" { + if err := os.RemoveAll(artifact.tempDir); err != nil { + artifact.err = errors.Join(artifact.err, err) + } + } + }) + return artifact.err +} + +// OpenArtifact validates and copies a URL or path artifact to a private +// temporary file so either source can be consumed identically. URL retrieval +// uses the caller-provided fetcher; callers can adapt Client.Download to retain +// dbc authentication and HTTP error behavior. Relative path locations are +// resolved against the absolute project directory. +func OpenArtifact(ctx context.Context, fetchURL URLFetcher, location resolution.ArtifactLocation, baseDir string) (*OpenedArtifact, error) { + if err := resolution.ValidateArtifactLocation(location); err != nil { + return nil, fmt.Errorf("invalid artifact location: %w", err) + } + if ctx == nil { + return nil, errors.New("artifact open requires a context") + } + tempDir, err := os.MkdirTemp("", "dbc-artifact-") + if err != nil { + return nil, fmt.Errorf("create artifact snapshot directory: %w", err) + } + cleanup := func() { _ = os.RemoveAll(tempDir) } + tempFile, err := os.CreateTemp(tempDir, "artifact-*") + if err != nil { + cleanup() + return nil, fmt.Errorf("create artifact snapshot file: %w", err) + } + closeOnError := func(err error) (*OpenedArtifact, error) { + _ = tempFile.Close() + cleanup() + return nil, err + } + + var source io.ReadCloser + switch location.Kind { + case resolution.ArtifactLocationURL: + if fetchURL == nil { + return closeOnError(errors.New("URL artifact requires a URL fetcher")) + } + parsed, err := url.Parse(location.Value) + if err != nil { + return closeOnError(fmt.Errorf("parse artifact URL: %w", err)) + } + source, err = fetchURL(ctx, parsed) + if err != nil { + return closeOnError(err) + } + case resolution.ArtifactLocationPath: + resolvedPath, err := resolveDeclaredPath(location.Value, baseDir) + if err != nil { + return closeOnError(err) + } + file, err := os.Open(resolvedPath) + if err != nil { + return closeOnError(fmt.Errorf("open local artifact %q: %w", location.Value, err)) + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + return closeOnError(fmt.Errorf("inspect local artifact %q: %w", location.Value, err)) + } + if !info.Mode().IsRegular() { + _ = file.Close() + return closeOnError(fmt.Errorf("local artifact %q is not a regular file", location.Value)) + } + source = file + default: + return closeOnError(fmt.Errorf("unsupported artifact location kind %q", location.Kind)) + } + defer source.Close() + if _, err := io.Copy(tempFile, contextReader{ctx: ctx, reader: source}); err != nil { + return closeOnError(fmt.Errorf("snapshot artifact: %w", err)) + } + if err := ctx.Err(); err != nil { + return closeOnError(err) + } + if err := tempFile.Sync(); err != nil { + return closeOnError(fmt.Errorf("sync artifact snapshot: %w", err)) + } + if _, err := tempFile.Seek(0, io.SeekStart); err != nil { + return closeOnError(fmt.Errorf("rewind artifact snapshot: %w", err)) + } + return &OpenedArtifact{File: tempFile, tempDir: tempDir}, nil +} + +func resolveDeclaredPath(declared, baseDir string) (string, error) { + if filepath.IsAbs(declared) { + return filepath.Clean(declared), nil + } + if !filepath.IsAbs(baseDir) { + return "", errors.New("relative artifact path requires an absolute project base directory") + } + return filepath.Clean(filepath.Join(baseDir, declared)), nil +} + +type contextReader struct { + ctx context.Context + reader io.Reader +} + +func (reader contextReader) Read(buffer []byte) (int, error) { + if err := reader.ctx.Err(); err != nil { + return 0, err + } + return reader.reader.Read(buffer) +} diff --git a/internal/sourceresolution/opener_test.go b/internal/sourceresolution/opener_test.go new file mode 100644 index 00000000..39b79f0c --- /dev/null +++ b/internal/sourceresolution/opener_test.go @@ -0,0 +1,123 @@ +// 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 + +package sourceresolution + +import ( + "context" + "errors" + "io" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/columnar-tech/dbc/internal/resolution" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpenArtifactPathSnapshotsWithoutCleaningSource(t *testing.T) { + projectDir := t.TempDir() + sourceDir := filepath.Join(projectDir, "packages") + require.NoError(t, os.Mkdir(sourceDir, 0o700)) + sourcePath := filepath.Join(sourceDir, "driver.tar.gz") + contents := []byte("local archive bytes") + require.NoError(t, os.WriteFile(sourcePath, contents, 0o600)) + + opened, err := OpenArtifact(context.Background(), nil, resolution.ArtifactLocation{ + Kind: resolution.ArtifactLocationPath, Value: "./packages/driver.tar.gz", + }, projectDir) + require.NoError(t, err) + tempPath := opened.File.Name() + got, err := io.ReadAll(opened.File) + require.NoError(t, err) + assert.Equal(t, contents, got) + require.NoError(t, opened.Close()) + + assert.NoFileExists(t, tempPath) + assert.FileExists(t, sourcePath) + assert.DirExists(t, sourceDir) +} + +func TestOpenArtifactPathDoesNotUseCurrentWorkingDirectory(t *testing.T) { + projectDir := t.TempDir() + cwd := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "archive.tgz"), []byte("from project"), 0o600)) + require.NoError(t, os.Mkdir(filepath.Join(cwd, "project"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(cwd, "project", "archive.tgz"), []byte("from cwd"), 0o600)) + t.Chdir(cwd) + + opened, err := OpenArtifact(context.Background(), nil, resolution.ArtifactLocation{ + Kind: resolution.ArtifactLocationPath, Value: "archive.tgz", + }, projectDir) + require.NoError(t, err) + defer opened.Close() + got, err := io.ReadAll(opened.File) + require.NoError(t, err) + assert.Equal(t, "from project", string(got)) +} + +func TestOpenArtifactURLUsesFetcherAndPreservesErrors(t *testing.T) { + var requests []string + fetchFailure := errors.New("HTTP 404 Not Found: missing payload") + fetchURL := func(_ context.Context, parsed *url.URL) (io.ReadCloser, error) { + requests = append(requests, parsed.String()) + if parsed.Path == "/missing" { + return nil, fetchFailure + } + return io.NopCloser(strings.NewReader("downloaded package")), nil + } + + opened, err := OpenArtifact(context.Background(), fetchURL, resolution.ArtifactLocation{ + Kind: resolution.ArtifactLocationURL, Value: "https://example.test/package.tar.gz", + }, "") + require.NoError(t, err) + got, err := io.ReadAll(opened.File) + require.NoError(t, err) + assert.Equal(t, "downloaded package", string(got)) + tempPath := opened.File.Name() + require.NoError(t, opened.Close()) + assert.NoFileExists(t, tempPath) + assert.Equal(t, []string{"https://example.test/package.tar.gz"}, requests) + + _, err = OpenArtifact(context.Background(), fetchURL, resolution.ArtifactLocation{ + Kind: resolution.ArtifactLocationURL, Value: "https://example.test/missing", + }, "") + require.ErrorIs(t, err, fetchFailure) + assert.Equal(t, []string{"https://example.test/package.tar.gz", "https://example.test/missing"}, requests) +} + +func TestOpenArtifactValidatesLocationBeforeAccess(t *testing.T) { + for _, location := range []resolution.ArtifactLocation{ + {Kind: resolution.ArtifactLocationURL, Value: "file:///tmp/archive.tar.gz"}, + {Kind: resolution.ArtifactLocationURL, Value: "https://user@example.test/archive"}, + {Kind: resolution.ArtifactLocationURL, Value: "https://example.test/archive#fragment"}, + {Kind: resolution.ArtifactLocationPath, Value: "archive\x00.tar.gz"}, + {Kind: resolution.ArtifactLocationPath, Value: "https://example.test/archive"}, + } { + _, err := OpenArtifact(context.Background(), nil, location, t.TempDir()) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "invalid artifact location") + } +} + +func TestOpenArtifactPathRejectsMissingAndNonRegularFiles(t *testing.T) { + projectDir := t.TempDir() + _, err := OpenArtifact(context.Background(), nil, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: "missing.tgz"}, projectDir) + require.ErrorIs(t, err, os.ErrNotExist) + require.NoError(t, os.Mkdir(filepath.Join(projectDir, "directory"), 0o700)) + _, err = OpenArtifact(context.Background(), nil, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: "directory"}, projectDir) + require.ErrorContains(t, err, "not a regular file") +} + +func TestOpenArtifactPathRequiresAbsoluteBaseForRelativePath(t *testing.T) { + _, err := OpenArtifact(context.Background(), nil, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: "archive.tgz"}, "relative") + require.ErrorContains(t, err, "absolute project base directory") +} diff --git a/internal/sourceresolution/plan.go b/internal/sourceresolution/plan.go new file mode 100644 index 00000000..b40ad2bf --- /dev/null +++ b/internal/sourceresolution/plan.go @@ -0,0 +1,152 @@ +// 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 sourceresolution + +import ( + "errors" + "fmt" + + "github.com/columnar-tech/dbc/internal/resolution" +) + +// PlanOutcome describes the I/O-free action needed for one requirement. +type PlanOutcome uint8 + +const ( + PlanOutcomeUnspecified PlanOutcome = iota + PlanResolve + PlanReplay + PlanRefreshRequired + PlanLockedArtifactMissing + PlanReject +) + +// Plan is an immutable decision for one requirement and optional existing +// source-neutral release snapshot. Invalid existing input produces PlanReject +// with an explanation available from Err. +type Plan struct { + outcome PlanOutcome + reason string + release *resolution.ResolvedRelease + artifact *resolution.Artifact +} + +// Outcome returns the action selected by the plan. +func (plan Plan) Outcome() PlanOutcome { return plan.outcome } + +// Err returns the validation error for PlanReject outcomes. +func (plan Plan) Err() error { + if plan.outcome != PlanReject || plan.reason == "" { + return nil + } + return errors.New(plan.reason) +} + +// Release returns a copy of the validated existing snapshot retained by a +// replay or an unlocked refresh plan. Resolve plans never expose stale source, +// version, artifact, or evidence data. +func (plan Plan) Release() (resolution.ResolvedRelease, bool) { + if plan.release == nil { + return resolution.ResolvedRelease{}, false + } + return cloneResolvedRelease(*plan.release), true +} + +// Artifact returns the selected concrete target artifact for a replay plan. +// Other outcomes do not expose an artifact for execution. +func (plan Plan) Artifact() (resolution.Artifact, bool) { + if plan.artifact == nil { + return resolution.Artifact{}, false + } + return cloneArtifact(*plan.artifact), true +} + +// Plan classifies the existing release for the requirement's target. When +// locked is true, an otherwise reusable snapshot with no artifact for the +// target produces PlanLockedArtifactMissing for translation to the CLI's +// locked-mode error. A source, driver, or version mismatch in a valid snapshot +// produces PlanResolve with no reference to the old snapshot. +func (requirement Requirement) Plan(existing *resolution.ResolvedRelease, locked bool) Plan { + if err := requirement.validate(); err != nil { + return Plan{outcome: PlanReject, reason: "invalid requirement: " + err.Error()} + } + if existing == nil { + return Plan{outcome: PlanResolve} + } + if err := validateExistingIdentity(*existing); err != nil { + return Plan{outcome: PlanReject, reason: "invalid existing release identity: " + err.Error()} + } + if err := requirement.validateReleaseIdentity(*existing); err != nil { + return Plan{outcome: PlanResolve} + } + if err := resolution.ValidateResolvedRelease(*existing); err != nil { + return Plan{outcome: PlanReject, reason: "invalid existing release: " + err.Error()} + } + artifact, ok := artifactForTarget(existing.Artifacts, requirement.target) + if !ok { + if locked { + return Plan{outcome: PlanLockedArtifactMissing} + } + release := cloneResolvedRelease(*existing) + return Plan{outcome: PlanRefreshRequired, release: &release} + } + release := cloneResolvedRelease(*existing) + selected := cloneArtifact(artifact) + return Plan{outcome: PlanReplay, release: &release, artifact: &selected} +} + +func validateExistingIdentity(release resolution.ResolvedRelease) error { + if release.DriverID == "" { + return errors.New("driver ID is empty") + } + if err := validateCanonicalVersion(release.Version); err != nil { + return fmt.Errorf("invalid version: %w", err) + } + if _, err := resolvedSourceKey(release.Source); err != nil { + return fmt.Errorf("invalid source identity: %w", err) + } + return nil +} + +func cloneResolvedRelease(release resolution.ResolvedRelease) resolution.ResolvedRelease { + clone := release + clone.Evidence = append([]resolution.Evidence(nil), release.Evidence...) + clone.Artifacts = make([]resolution.Artifact, len(release.Artifacts)) + for i, artifact := range release.Artifacts { + clone.Artifacts[i] = cloneArtifact(artifact) + } + return clone +} + +func cloneArtifact(artifact resolution.Artifact) resolution.Artifact { + artifact.Size = cloneInt64(artifact.Size) + artifact.HostRequirements = cloneHostRequirements(artifact.HostRequirements) + return artifact +} + +func cloneInt64(value *int64) *int64 { + if value == nil { + return nil + } + clone := *value + return &clone +} + +func cloneHostRequirements(requirements resolution.HostRequirements) resolution.HostRequirements { + clone := requirements + clone.Libs = append([]string(nil), requirements.Libs...) + clone.Bins = append([]resolution.NamedRequirement(nil), requirements.Bins...) + return clone +} diff --git a/internal/sourceresolution/requirement.go b/internal/sourceresolution/requirement.go new file mode 100644 index 00000000..09caaf39 --- /dev/null +++ b/internal/sourceresolution/requirement.go @@ -0,0 +1,416 @@ +// 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 sourceresolution + +import ( + "errors" + "fmt" + + "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc/internal/resolution" + "github.com/columnar-tech/dbc/internal/sourceidentity" +) + +// SourceSelectionMode identifies how a requirement chooses its source. +type SourceSelectionMode uint8 + +const ( + SourceSelectionInvalid SourceSelectionMode = iota + DefaultRegistry + ExplicitSource +) + +// SourceSelection chooses the default registry policy or one canonical, +// explicit source identity. Its fields are private so callers must use the +// validating constructors. +type SourceSelection struct { + mode SourceSelectionMode + key sourceidentity.Key +} + +// DefaultRegistrySelection selects any registry source accepted by the +// configured default-registry policy. +func DefaultRegistrySelection() SourceSelection { + return SourceSelection{mode: DefaultRegistry} +} + +// ExplicitSourceSelection binds a requirement to one canonical source key. +func ExplicitSourceSelection(key sourceidentity.Key) (SourceSelection, error) { + canonical, err := sourceidentity.Parse(key.Kind, key.Reference) + if err != nil { + return SourceSelection{}, fmt.Errorf("invalid explicit source identity: %w", err) + } + if canonical != key { + return SourceSelection{}, errors.New("explicit source identity is not canonical") + } + return SourceSelection{mode: ExplicitSource, key: key}, nil +} + +// Mode reports whether this selection uses the default registry or an +// explicitly named source. +func (selection SourceSelection) Mode() SourceSelectionMode { + return selection.mode +} + +// Key returns the explicit source key, if this is an ExplicitSource selection. +func (selection SourceSelection) Key() (sourceidentity.Key, bool) { + if selection.mode != ExplicitSource { + return sourceidentity.Key{}, false + } + return selection.key, true +} + +// PrereleasePolicy controls whether a registry requirement can match versions +// whose SemVer prerelease field is non-empty. +type PrereleasePolicy uint8 + +const ( + PrereleaseForbidden PrereleasePolicy = iota + PrereleaseAllowed +) + +// VersionMode identifies the source-specific version rule in a requirement. +type VersionMode uint8 + +const ( + VersionModeInvalid VersionMode = iota + RegistryConstraint + PackslipExact + PathExact + PathMetadataDerived +) + +// VersionRequirement is a validated source-specific version rule. Its fields +// are private so source and version semantics cannot be changed independently +// after a Requirement is constructed. +type VersionRequirement struct { + mode VersionMode + constraint string + parsed *semver.Constraints + exact string + prerelease PrereleasePolicy +} + +// RegistryVersionRequirement builds a registry constraint requirement. An +// empty constraint means any version allowed by prereleasePolicy. +func RegistryVersionRequirement(constraint string, prereleasePolicy PrereleasePolicy) (VersionRequirement, error) { + if prereleasePolicy != PrereleaseForbidden && prereleasePolicy != PrereleaseAllowed { + return VersionRequirement{}, fmt.Errorf("unsupported prerelease policy %d", prereleasePolicy) + } + var parsed *semver.Constraints + if constraint != "" { + var err error + parsed, err = semver.NewConstraint(constraint) + if err != nil { + return VersionRequirement{}, fmt.Errorf("invalid registry version constraint %q: %w", constraint, err) + } + parsed.IncludePrerelease = prereleasePolicy == PrereleaseAllowed + } + return VersionRequirement{ + mode: RegistryConstraint, constraint: constraint, parsed: parsed, + prerelease: prereleasePolicy, + }, nil +} + +// PackslipVersionRequirement binds a Packslip source to one exact canonical +// SemVer 2.0.0 version. Equality later uses the complete string, including +// build metadata. +func PackslipVersionRequirement(version string) (VersionRequirement, error) { + if err := validateCanonicalVersion(version); err != nil { + return VersionRequirement{}, fmt.Errorf("invalid exact Packslip version: %w", err) + } + return VersionRequirement{mode: PackslipExact, exact: version}, nil +} + +// PathVersionRequirement binds a path source to one exact canonical SemVer +// version. +func PathVersionRequirement(version string) (VersionRequirement, error) { + if err := validateCanonicalVersion(version); err != nil { + return VersionRequirement{}, fmt.Errorf("invalid exact path version: %w", err) + } + return VersionRequirement{mode: PathExact, exact: version}, nil +} + +// PathMetadataVersionRequirement represents a path source whose version is +// taken from the package metadata when the archive is inspected. +func PathMetadataVersionRequirement() VersionRequirement { + return VersionRequirement{mode: PathMetadataDerived} +} + +// SameReleaseVersion compares two already-resolved versions according to the +// identity rules of their source. Registry versions retain SemVer precedence +// equality; Packslip and path releases use their complete canonical strings, +// including build metadata. +func SameReleaseVersion(source sourceidentity.Kind, left, right *semver.Version) bool { + if left == nil || right == nil { + return false + } + switch source { + case sourceidentity.Registry: + return left.Equal(right) + case sourceidentity.Packslip, sourceidentity.Path: + leftString := left.String() + rightString := right.String() + if validateCanonicalVersion(leftString) != nil || validateCanonicalVersion(rightString) != nil { + return false + } + return leftString == rightString + default: + return false + } +} + +// Mode reports the version rule used by this requirement. +func (version VersionRequirement) Mode() VersionMode { + return version.mode +} + +// Constraint returns the registry constraint, including an empty string for +// an unconstrained registry requirement. +func (version VersionRequirement) Constraint() (string, bool) { + if version.mode != RegistryConstraint { + return "", false + } + return version.constraint, true +} + +// ExactVersion returns an exact Packslip or path version, when present. +func (version VersionRequirement) ExactVersion() (string, bool) { + if version.mode != PackslipExact && version.mode != PathExact { + return "", false + } + return version.exact, true +} + +// PrereleasePolicy returns the configured registry prerelease policy. +func (version VersionRequirement) PrereleasePolicy() (PrereleasePolicy, bool) { + if version.mode != RegistryConstraint { + return PrereleaseForbidden, false + } + return version.prerelease, true +} + +// Requirement binds one driver ID, source selection, version rule, and +// concrete target. It is immutable after construction. +type Requirement struct { + driverID string + source SourceSelection + version VersionRequirement + target resolution.Target +} + +// NewRequirement validates the source/version combination and target before +// creating a planning requirement. +func NewRequirement(driverID string, source SourceSelection, version VersionRequirement, target resolution.Target) (Requirement, error) { + if driverID == "" { + return Requirement{}, errors.New("driver ID is required") + } + if source.mode != DefaultRegistry && source.mode != ExplicitSource { + return Requirement{}, errors.New("source selection is invalid") + } + if source.mode == ExplicitSource { + canonical, err := sourceidentity.Parse(source.key.Kind, source.key.Reference) + if err != nil || canonical != source.key { + return Requirement{}, errors.New("explicit source identity is invalid or non-canonical") + } + } + if err := validateSourceVersionPair(source, version); err != nil { + return Requirement{}, err + } + target = resolution.CanonicalTarget(target) + if err := resolution.ValidateConcreteTarget(target); err != nil { + return Requirement{}, fmt.Errorf("invalid requirement target: %w", err) + } + return Requirement{driverID: driverID, source: source, version: version, target: target}, nil +} + +func (requirement Requirement) validate() error { + if requirement.driverID == "" { + return errors.New("driver ID is required") + } + if requirement.source.mode != DefaultRegistry && requirement.source.mode != ExplicitSource { + return errors.New("source selection is invalid") + } + if requirement.source.mode == ExplicitSource { + canonical, err := sourceidentity.Parse(requirement.source.key.Kind, requirement.source.key.Reference) + if err != nil || canonical != requirement.source.key { + return errors.New("explicit source identity is invalid or non-canonical") + } + } + if err := validateSourceVersionPair(requirement.source, requirement.version); err != nil { + return err + } + switch requirement.version.mode { + case RegistryConstraint: + if requirement.version.constraint != "" && requirement.version.parsed == nil { + return errors.New("registry constraint is invalid") + } + case PackslipExact, PathExact: + if err := validateCanonicalVersion(requirement.version.exact); err != nil { + return fmt.Errorf("invalid exact version: %w", err) + } + case PathMetadataDerived: + default: + return errors.New("version requirement is invalid") + } + if resolution.CanonicalTarget(requirement.target) != requirement.target { + return errors.New("requirement target is not canonical") + } + if err := resolution.ValidateConcreteTarget(requirement.target); err != nil { + return fmt.Errorf("invalid requirement target: %w", err) + } + return nil +} + +func validateSourceVersionPair(source SourceSelection, version VersionRequirement) error { + var sourceKind sourceidentity.Kind + if source.mode == ExplicitSource { + sourceKind = source.key.Kind + } else { + sourceKind = sourceidentity.Registry + } + switch { + case sourceKind == sourceidentity.Registry && version.mode == RegistryConstraint: + return nil + case sourceKind == sourceidentity.Packslip && version.mode == PackslipExact: + return nil + case sourceKind == sourceidentity.Path && (version.mode == PathExact || version.mode == PathMetadataDerived): + return nil + default: + return fmt.Errorf("version mode %d is incompatible with source kind %q", version.mode, sourceKind) + } +} + +// DriverID returns the required resolved driver ID. +func (requirement Requirement) DriverID() string { return requirement.driverID } + +// Source returns the validated source selection. +func (requirement Requirement) Source() SourceSelection { return requirement.source } + +// Version returns the validated source-specific version rule. +func (requirement Requirement) Version() VersionRequirement { return requirement.version } + +// Target returns the canonical concrete target. +func (requirement Requirement) Target() resolution.Target { return requirement.target } + +// AcceptsVersion reports whether an already-resolved canonical version +// satisfies this requirement's version rule. It is primarily useful to +// migrate legacy lock data that does not contain a complete release snapshot. +func (requirement Requirement) AcceptsVersion(version string) bool { + return requirement.validate() == nil && requirement.version.matches(version) +} + +// ValidateResolverResult checks a resolver result before it can be used to +// build an install candidate. It validates source, driver, version, and +// non-finalized release metadata, then returns the artifact for this +// requirement's target so callers do not select it again. A caller persisting +// a lock must still pass the completed release to resolution.ValidateResolvedRelease. +func (requirement Requirement) ValidateResolverResult(release resolution.ResolvedRelease) (resolution.Artifact, error) { + if err := requirement.validateReleaseIdentity(release); err != nil { + return resolution.Artifact{}, err + } + if err := resolution.ValidateResolvedReleaseCandidate(release); err != nil { + return resolution.Artifact{}, fmt.Errorf("invalid resolved release: %w", err) + } + artifact, ok := artifactForTarget(release.Artifacts, requirement.target) + if !ok { + return resolution.Artifact{}, fmt.Errorf("resolved release has no artifact for target %v", requirement.target) + } + return cloneArtifact(artifact), nil +} + +func (requirement Requirement) validateReleaseIdentity(release resolution.ResolvedRelease) error { + if requirement.driverID == "" || requirement.version.mode == VersionModeInvalid { + return errors.New("requirement is invalid") + } + if release.DriverID != requirement.driverID { + return fmt.Errorf("resolved driver ID %q does not match required driver ID %q", release.DriverID, requirement.driverID) + } + if !requirement.matchesSource(release.Source) { + return fmt.Errorf("resolved source %q identity %q does not match requirement", release.Source.Type, release.Source.Reference) + } + if !requirement.version.matches(release.Version) { + return fmt.Errorf("resolved version %q does not match requirement", release.Version) + } + return nil +} + +func artifactForTarget(artifacts []resolution.Artifact, target resolution.Target) (resolution.Artifact, bool) { + for _, artifact := range artifacts { + if artifact.Target == target { + return artifact, true + } + } + return resolution.Artifact{}, false +} + +func (requirement Requirement) matchesSource(source resolution.SourceSpec) bool { + key, err := resolvedSourceKey(source) + if err != nil { + return false + } + switch requirement.source.mode { + case DefaultRegistry: + return key.Kind == sourceidentity.Registry + case ExplicitSource: + return key == requirement.source.key + default: + return false + } +} + +func resolvedSourceKey(source resolution.SourceSpec) (sourceidentity.Key, error) { + kind := sourceidentity.Kind(source.Type) + switch kind { + case sourceidentity.Registry, sourceidentity.Packslip, sourceidentity.Path: + return sourceidentity.Parse(kind, source.Reference) + default: + return sourceidentity.Key{}, fmt.Errorf("unsupported resolved source type %q", source.Type) + } +} + +func (version VersionRequirement) matches(value string) bool { + parsed, err := semver.StrictNewVersion(value) + if err != nil || parsed.String() != value { + return false + } + switch version.mode { + case RegistryConstraint: + if version.parsed != nil { + // An explicit prerelease comparator is itself an opt-in. Preserve + // SemVer constraint matching here; the policy flag governs implicit + // prerelease selection when no constraint was supplied. + return version.parsed.Check(parsed) + } + return parsed.Prerelease() == "" || version.prerelease == PrereleaseAllowed + case PackslipExact, PathExact: + return value == version.exact + case PathMetadataDerived: + return true + default: + return false + } +} + +func validateCanonicalVersion(value string) error { + parsed, err := semver.StrictNewVersion(value) + if err != nil { + return fmt.Errorf("version %q must be an exact SemVer 2.0.0 version: %w", value, err) + } + if parsed.String() != value { + return fmt.Errorf("version %q is not canonical SemVer 2.0.0", value) + } + return nil +} diff --git a/internal/sourceresolution/requirement_test.go b/internal/sourceresolution/requirement_test.go new file mode 100644 index 00000000..8212994b --- /dev/null +++ b/internal/sourceresolution/requirement_test.go @@ -0,0 +1,354 @@ +// 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 sourceresolution + +import ( + "strings" + "testing" + + "github.com/columnar-tech/dbc/internal/resolution" + "github.com/columnar-tech/dbc/internal/sourceidentity" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + planTarget = resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu"} + otherPlanTarget = resolution.Target{OS: "macos", Arch: "arm64"} +) + +func TestNewRequirementValidatesSourceAndVersionModes(t *testing.T) { + registryVersion, err := RegistryVersionRequirement("", PrereleaseForbidden) + require.NoError(t, err) + _, err = NewRequirement("driver", DefaultRegistrySelection(), registryVersion, planTarget) + require.NoError(t, err) + + packslipVersion, err := PackslipVersionRequirement("1.2.3") + require.NoError(t, err) + _, err = NewRequirement("driver", DefaultRegistrySelection(), packslipVersion, planTarget) + require.ErrorContains(t, err, "incompatible") + + key, err := sourceidentity.Parse(sourceidentity.Packslip, "GitHub.com/Example/Driver") + require.NoError(t, err) + selection, err := ExplicitSourceSelection(key) + require.NoError(t, err) + _, err = NewRequirement("driver", selection, registryVersion, planTarget) + require.ErrorContains(t, err, "incompatible") + _, err = NewRequirement("driver", selection, packslipVersion, planTarget) + require.NoError(t, err) + + _, err = NewRequirement("", selection, packslipVersion, planTarget) + require.ErrorContains(t, err, "driver ID") + _, err = NewRequirement("driver", selection, packslipVersion, resolution.Target{OS: "linux"}) + require.ErrorContains(t, err, "architecture") +} + +func TestExplicitSourceSelectionRequiresCanonicalKey(t *testing.T) { + _, err := ExplicitSourceSelection(sourceidentity.Key{Kind: sourceidentity.Packslip, Reference: "GitHub.com/Example/Driver"}) + require.ErrorContains(t, err, "not canonical") + + key, err := sourceidentity.Parse(sourceidentity.Packslip, "GitHub.com/Example/Driver") + require.NoError(t, err) + selection, err := ExplicitSourceSelection(key) + require.NoError(t, err) + got, ok := selection.Key() + require.True(t, ok) + assert.Equal(t, key, got) +} + +func TestRegistryVersionRequirementAppliesConstraintAndPrereleasePolicy(t *testing.T) { + for _, test := range []struct { + name string + policy PrereleasePolicy + version string + wantMatch bool + }{ + {name: "stable allowed", policy: PrereleaseForbidden, version: "1.5.0", wantMatch: true}, + {name: "prerelease filtered", policy: PrereleaseForbidden, version: "1.5.0-beta.1"}, + {name: "prerelease allowed", policy: PrereleaseAllowed, version: "1.5.0-beta.1", wantMatch: true}, + {name: "outside constraint", policy: PrereleaseAllowed, version: "2.0.0"}, + } { + t.Run(test.name, func(t *testing.T) { + version, err := RegistryVersionRequirement(">=1.0.0,<2.0.0", test.policy) + require.NoError(t, err) + require.Equal(t, test.wantMatch, version.matches(test.version)) + }) + } + _, err := RegistryVersionRequirement("not a constraint", PrereleaseForbidden) + require.Error(t, err) +} + +func TestRegistryConstraintCanExplicitlySelectPrerelease(t *testing.T) { + constraint := ">=1.2.3-beta.1,<1.2.3" + for _, test := range []struct { + name string + policy PrereleasePolicy + wantMatch bool + }{ + {name: "default policy", policy: PrereleaseForbidden, wantMatch: true}, + {name: "allowed", policy: PrereleaseAllowed, wantMatch: true}, + } { + t.Run(test.name, func(t *testing.T) { + requirement, err := RegistryVersionRequirement(constraint, test.policy) + require.NoError(t, err) + assert.Equal(t, test.wantMatch, requirement.matches("1.2.3-beta.2")) + }) + } +} + +func TestPackslipExactVersionIncludesBuildMetadata(t *testing.T) { + version, err := PackslipVersionRequirement("1.2.3+build.7") + require.NoError(t, err) + assert.True(t, version.matches("1.2.3+build.7")) + assert.False(t, version.matches("1.2.3+build.8")) + _, err = PackslipVersionRequirement("v1.2.3") + require.Error(t, err, "the requirement accepts only canonical versions") +} + +func TestPathVersionRequirements(t *testing.T) { + exact, err := PathVersionRequirement("1.2.3+build.1") + require.NoError(t, err) + assert.True(t, exact.matches("1.2.3+build.1")) + assert.False(t, exact.matches("1.2.3+build.2")) + + derived := PathMetadataVersionRequirement() + assert.True(t, derived.matches("1.2.3+build.2")) + assert.False(t, derived.matches("invalid")) +} + +func TestRequirementValidateResolvedReleaseChecksSourceDriverVersionAndStructure(t *testing.T) { + requirement := mustRequirement(t, "driver", DefaultRegistrySelection(), mustRegistryVersion(t, "=1.2.3", PrereleaseForbidden), planTarget) + release := validPlanRelease("registry", "https://registry.example.test", "1.2.3", "driver", planTarget) + selected, err := requirement.ValidateResolverResult(release) + require.NoError(t, err) + assert.Equal(t, planTarget, selected.Target) + + legacyRegistryCandidate := cloneResolvedRelease(release) + legacyRegistryCandidate.Artifacts[0].Hash = "" + legacyRegistryCandidate.Artifacts[0].Size = nil + _, err = requirement.ValidateResolverResult(legacyRegistryCandidate) + require.NoError(t, err, "registry candidates can be validated before archive hashing") + + tests := []struct { + name string + change func(*resolution.ResolvedRelease) + want string + }{ + {name: "driver", change: func(r *resolution.ResolvedRelease) { r.DriverID = "other" }, want: "driver ID"}, + {name: "source type", change: func(r *resolution.ResolvedRelease) { r.Source.Type = "packslip" }, want: "source"}, + {name: "source kind", change: func(r *resolution.ResolvedRelease) { + r.Source = resolution.SourceSpec{Type: "path", Reference: "./driver.tar.gz"} + }, want: "source"}, + {name: "version", change: func(r *resolution.ResolvedRelease) { r.Version = "1.2.4" }, want: "version"}, + {name: "artifact structure", change: func(r *resolution.ResolvedRelease) { r.Artifacts[0].Hash = "invalid" }, want: "hash"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + candidate := cloneResolvedRelease(release) + test.change(&candidate) + _, err := requirement.ValidateResolverResult(candidate) + require.ErrorContains(t, err, test.want) + }) + } +} + +func TestRequirementPlanOutcomesAndStaleReleaseIsolation(t *testing.T) { + requirement := mustRequirement(t, "driver", DefaultRegistrySelection(), mustRegistryVersion(t, "=1.2.3", PrereleaseForbidden), planTarget) + existing := validPlanRelease("registry", "https://registry.example.test", "1.2.3", "driver", planTarget) + + plan := requirement.Plan(&existing, false) + assert.Equal(t, PlanReplay, plan.Outcome(), "a valid lock replay does not require a resolver") + replayed, ok := plan.Release() + require.True(t, ok) + assert.Equal(t, existing, replayed) + selectedArtifact, ok := plan.Artifact() + require.True(t, ok) + assert.Equal(t, planTarget, selectedArtifact.Target) + // The plan gives callers an independent snapshot. + replayed.Artifacts[0].Hash = "changed" + stillReplay, _ := plan.Release() + assert.NotEqual(t, "changed", stillReplay.Artifacts[0].Hash) + selectedArtifact.Hash = "changed" + stillSelected, _ := plan.Artifact() + assert.NotEqual(t, "changed", stillSelected.Hash) + + for _, test := range []struct { + name string + change func(*resolution.ResolvedRelease) + }{ + {name: "wrong source", change: func(r *resolution.ResolvedRelease) { + r.Source = resolution.SourceSpec{Type: "path", Reference: "./driver.tar.gz"} + r.Artifacts[0].Hash = "" + r.Artifacts[0].Size = nil + }}, + {name: "wrong version", change: func(r *resolution.ResolvedRelease) { r.Version = "1.2.4" }}, + {name: "wrong driver", change: func(r *resolution.ResolvedRelease) { r.DriverID = "other" }}, + } { + t.Run(test.name, func(t *testing.T) { + stale := cloneResolvedRelease(existing) + test.change(&stale) + plan := requirement.Plan(&stale, false) + assert.Equal(t, PlanResolve, plan.Outcome()) + _, hasRelease := plan.Release() + assert.False(t, hasRelease, "stale artifact and version data must not leak into resolution") + }) + } + + missingTargetRelease := validPlanRelease("registry", "https://registry.example.test", "1.2.3", "driver", otherPlanTarget) + refreshPlan := requirement.Plan(&missingTargetRelease, false) + assert.Equal(t, PlanRefreshRequired, refreshPlan.Outcome()) + retained, ok := refreshPlan.Release() + require.True(t, ok) + assert.Equal(t, missingTargetRelease, retained) + + lockedPlan := requirement.Plan(&missingTargetRelease, true) + assert.Equal(t, PlanLockedArtifactMissing, lockedPlan.Outcome()) + _, ok = lockedPlan.Release() + assert.False(t, ok) + assert.NoError(t, lockedPlan.Err()) + + _, err := requirement.ValidateResolverResult(missingTargetRelease) + require.ErrorContains(t, err, "no artifact for target") + + invalidSnapshot := cloneResolvedRelease(existing) + invalidSnapshot.Artifacts[0].Hash = "" + invalidPlan := requirement.Plan(&invalidSnapshot, false) + assert.Equal(t, PlanReject, invalidPlan.Outcome()) + assert.ErrorContains(t, invalidPlan.Err(), "size without a hash") + _, ok = invalidPlan.Release() + assert.False(t, ok) +} + +func TestDefaultRegistryRejectsNonRegistryRelease(t *testing.T) { + requirement := mustRequirement(t, "driver", DefaultRegistrySelection(), mustRegistryVersion(t, "", PrereleaseForbidden), planTarget) + release := validPlanRelease("path", "./driver.tar.gz", "1.2.3", "driver", planTarget) + _, err := requirement.ValidateResolverResult(release) + assert.ErrorContains(t, err, "source") + assert.Equal(t, PlanResolve, requirement.Plan(&release, false).Outcome()) +} + +func TestDefaultRegistryAcceptsAnyRegistryIdentity(t *testing.T) { + requirement := mustRequirement(t, "driver", DefaultRegistrySelection(), mustRegistryVersion(t, "=1.2.3", PrereleaseForbidden), planTarget) + release := validPlanRelease("registry", "https://another-registry.example.test", "1.2.3", "driver", planTarget) + _, err := requirement.ValidateResolverResult(release) + require.NoError(t, err) +} + +func TestExplicitSourceAcceptsOnlyExactCanonicalKey(t *testing.T) { + key, err := sourceidentity.Parse(sourceidentity.Packslip, "GitHub.com/Example/Driver") + require.NoError(t, err) + selection, err := ExplicitSourceSelection(key) + require.NoError(t, err) + requirement := mustRequirement(t, "driver", selection, mustPackslipVersion(t, "1.2.3+meta"), planTarget) + + matching := validPlanRelease("packslip", "github.com/example/driver", "1.2.3+meta", "driver", planTarget) + _, err = requirement.ValidateResolverResult(matching) + assert.NoError(t, err) + + differentSource := cloneResolvedRelease(matching) + differentSource.Source.Reference = "github.com/example/other" + _, err = requirement.ValidateResolverResult(differentSource) + assert.ErrorContains(t, err, "source") + + differentBuild := cloneResolvedRelease(matching) + differentBuild.Version = "1.2.3+other" + _, err = requirement.ValidateResolverResult(differentBuild) + assert.ErrorContains(t, err, "version") + + snapshot := cloneResolvedRelease(matching) + _, err = requirement.ValidateResolverResult(snapshot) + assert.NoError(t, err, "source metadata is valid") + for _, locked := range []bool{false, true} { + plan := requirement.Plan(&snapshot, locked) + assert.Equal(t, PlanReplay, plan.Outcome()) + _, hasArtifact := plan.Artifact() + assert.True(t, hasArtifact) + } + newVersionRequirement := mustRequirement(t, "driver", selection, mustPackslipVersion(t, "1.2.4"), planTarget) + assert.Equal(t, PlanResolve, newVersionRequirement.Plan(&snapshot, false).Outcome(), + "a stale version is resolved") +} + +func TestReplayIsSourceNeutral(t *testing.T) { + for _, source := range []sourceidentity.Kind{sourceidentity.Registry, sourceidentity.Packslip, sourceidentity.Path} { + reference := "https://registry.example.test" + if source == sourceidentity.Packslip { + reference = "github.com/example/driver" + } + if source == sourceidentity.Path { + reference = "./driver.tar.gz" + } + key, err := sourceidentity.Parse(source, reference) + require.NoError(t, err) + selection, err := ExplicitSourceSelection(key) + require.NoError(t, err) + version := mustRegistryVersion(t, "=1.2.3", PrereleaseForbidden) + if source == sourceidentity.Packslip { + version = mustPackslipVersion(t, "1.2.3") + } + if source == sourceidentity.Path { + version, err = PathVersionRequirement("1.2.3") + require.NoError(t, err) + } + requirement := mustRequirement(t, "driver", selection, version, planTarget) + release := validPlanRelease(string(source), reference, "1.2.3", "driver", planTarget) + _, err = requirement.ValidateResolverResult(release) + require.NoError(t, err) + for _, locked := range []bool{false, true} { + plan := requirement.Plan(&release, locked) + assert.Equal(t, PlanReplay, plan.Outcome()) + selected, ok := plan.Artifact() + require.True(t, ok) + assert.Equal(t, release.Artifacts[0], selected) + } + } +} + +func mustRequirement(t *testing.T, driverID string, source SourceSelection, version VersionRequirement, target resolution.Target) Requirement { + t.Helper() + requirement, err := NewRequirement(driverID, source, version, target) + require.NoError(t, err) + return requirement +} + +func mustRegistryVersion(t *testing.T, constraint string, policy PrereleasePolicy) VersionRequirement { + t.Helper() + version, err := RegistryVersionRequirement(constraint, policy) + require.NoError(t, err) + return version +} + +func mustPackslipVersion(t *testing.T, value string) VersionRequirement { + t.Helper() + version, err := PackslipVersionRequirement(value) + require.NoError(t, err) + return version +} + +func validPlanRelease(sourceType, reference, version, driverID string, target resolution.Target) resolution.ResolvedRelease { + size := int64(1) + return resolution.ResolvedRelease{ + DriverID: driverID, + Version: version, + Source: resolution.SourceSpec{Type: sourceType, Reference: reference}, + Artifacts: []resolution.Artifact{{ + Target: target, + Format: "tar.gz", + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/driver.tar.gz"}, + Hash: "sha256:" + strings.Repeat("a", 64), + Size: &size, + }}, + } +} diff --git a/internal/sourceresolution/resolver.go b/internal/sourceresolution/resolver.go new file mode 100644 index 00000000..c68e9156 --- /dev/null +++ b/internal/sourceresolution/resolver.go @@ -0,0 +1,196 @@ +// 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 + +package sourceresolution + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/packslip" + "github.com/columnar-tech/dbc/internal/resolution" + "github.com/columnar-tech/dbc/internal/sourceidentity" +) + +// Request is the small amount of project context needed to resolve one source. +// BaseDir is the absolute directory containing dbc.toml/dbc.lock. Platform is +// the current config platform tuple used to validate the target. +type Request struct { + DriverID string + Version string // Optional for local path sources; an omitted version is read from package metadata. + Target resolution.Target + Platform string + BaseDir string +} + +// ResolvePackslip passes the declared project source to the existing signed +// Packslip resolver and checks that its result still matches the request. +func ResolvePackslip(ctx context.Context, resolver packslip.Resolver, project, driverID, version string) (resolution.ResolvedRelease, error) { + if ctx == nil { + return resolution.ResolvedRelease{}, errors.New("packslip resolution requires a context") + } + if project == "" { + return resolution.ResolvedRelease{}, errors.New("packslip resolution requires a project") + } + if resolver == nil { + return resolution.ResolvedRelease{}, errors.New("packslip resolver is nil") + } + declaredKey, err := sourceidentity.Parse(sourceidentity.Packslip, project) + if err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("invalid packslip project: %w", err) + } + requestedVersion, err := semver.StrictNewVersion(version) + if err != nil || requestedVersion.String() != version { + return resolution.ResolvedRelease{}, fmt.Errorf("requested packslip version %q must be an exact SemVer 2.0.0 version", version) + } + + release, err := resolver.Resolve(ctx, packslip.PackslipSource{Project: project}, packslip.Request{DriverID: driverID, Version: version}) + if err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("resolve packslip source: %w", err) + } + if release.DriverID == "" { + return resolution.ResolvedRelease{}, errors.New("packslip release has no signed dbc driver ID") + } + if driverID != "" && release.DriverID != driverID { + return resolution.ResolvedRelease{}, fmt.Errorf("packslip release driver ID %q does not match requested driver %q", release.DriverID, driverID) + } + if release.Version != version { + return resolution.ResolvedRelease{}, fmt.Errorf("packslip release version %q does not match requested version %q", release.Version, version) + } + lockedKey, identityErr := sourceidentity.Parse(sourceidentity.Packslip, release.Source.Reference) + if release.Source.Type != string(sourceidentity.Packslip) || identityErr != nil || lockedKey != declaredKey { + return resolution.ResolvedRelease{}, fmt.Errorf("packslip release source identity %q does not match declared project %q", release.Source.Reference, declaredKey.Reference) + } + if err := resolution.ValidateResolvedRelease(release); err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("packslip resolver returned an invalid release: %w", err) + } + for i, artifact := range release.Artifacts { + if artifact.Size == nil { + return resolution.ResolvedRelease{}, fmt.Errorf("packslip artifact %d must declare archive size", i) + } + } + return release, nil +} + +// ResolvePath inspects a local package archive and returns a release containing +// only the current target. An omitted requested version is derived from package +// metadata. The declared path remains unchanged in source identity and artifact +// location; filesystem access is relative to BaseDir. +func ResolvePath(ctx context.Context, declaredPath string, request Request) (resolution.ResolvedRelease, error) { + if request.DriverID == "" { + return resolution.ResolvedRelease{}, errors.New("path resolution requires a driver ID") + } + if request.Version != "" { + version, err := semver.StrictNewVersion(request.Version) + if err != nil || version.String() != request.Version { + return resolution.ResolvedRelease{}, fmt.Errorf("requested path package version %q must be an exact SemVer 2.0.0 version", request.Version) + } + } + target := resolution.CanonicalTarget(request.Target) + if err := resolution.ValidateConcreteTarget(target); err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("invalid current target: %w", err) + } + if request.Platform == "" { + return resolution.ResolvedRelease{}, errors.New("path resolution requires the current platform tuple") + } + platformTarget, err := resolution.TargetFromPlatformTuple(request.Platform) + if err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("invalid current platform tuple: %w", err) + } + if platformTarget != target { + return resolution.ResolvedRelease{}, fmt.Errorf("current target %v does not match platform tuple %q", target, request.Platform) + } + if !filepath.IsAbs(request.BaseDir) { + return resolution.ResolvedRelease{}, errors.New("project base directory must be absolute") + } + location := resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: declaredPath} + if err := resolution.ValidateArtifactLocation(location); err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("invalid path source: %w", err) + } + opened, err := OpenArtifact(ctx, nil, location, request.BaseDir) + if err != nil { + return resolution.ResolvedRelease{}, err + } + defer opened.Close() + + archiveHash, archiveSize, err := hashFile(opened.File) + if err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("hash local package archive: %w", err) + } + manifest, err := config.InspectPackageMetadata(opened.File) + if err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("inspect local package archive: %w", err) + } + if manifest.Version == nil { + return resolution.ResolvedRelease{}, errors.New("local package metadata has no version") + } + resolvedVersion := manifest.Version.String() + if request.Version != "" && resolvedVersion != request.Version { + return resolution.ResolvedRelease{}, fmt.Errorf("local package version %q does not match requested version %q", resolvedVersion, request.Version) + } + // Legacy MANIFEST packages predate package IDs and platform declarations. + // The existing installer supplies the requested runtime ID and current + // platform for that format, so the local source adapter uses the same rule. + format, err := packageFormat(declaredPath) + if err != nil { + return resolution.ResolvedRelease{}, err + } + size := archiveSize + release := resolution.ResolvedRelease{ + DriverID: request.DriverID, + Version: resolvedVersion, + Source: resolution.SourceSpec{Type: "path", Reference: declaredPath}, + Artifacts: []resolution.Artifact{{ + Target: target, + Format: format, + Location: location, + Hash: archiveHash, + Size: &size, + }}, + } + if err := resolution.ValidateResolvedRelease(release); err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("invalid local package release: %w", err) + } + return release, nil +} + +func packageFormat(path string) (string, error) { + lower := strings.ToLower(path) + switch { + case strings.HasSuffix(lower, ".tar.gz"): + return "tar.gz", nil + case strings.HasSuffix(lower, ".tgz"): + return "tgz", nil + default: + return "", fmt.Errorf("local package archive %q must use .tar.gz or .tgz format", path) + } +} + +func hashFile(file *os.File) (string, int64, error) { + if _, err := file.Seek(0, io.SeekStart); err != nil { + return "", 0, err + } + hash := sha256.New() + size, err := io.Copy(hash, file) + if err != nil { + return "", size, err + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return "", size, err + } + return "sha256:" + hex.EncodeToString(hash.Sum(nil)), size, nil +} diff --git a/internal/sourceresolution/resolver_test.go b/internal/sourceresolution/resolver_test.go new file mode 100644 index 00000000..3423b718 --- /dev/null +++ b/internal/sourceresolution/resolver_test.go @@ -0,0 +1,289 @@ +// 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 + +package sourceresolution + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/columnar-tech/dbc/internal/packslip" + "github.com/columnar-tech/dbc/internal/resolution" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type packslipResolverStub struct { + release resolution.ResolvedRelease + err error + source packslip.PackslipSource + request packslip.Request +} + +func (stub *packslipResolverStub) Resolve(_ context.Context, source packslip.PackslipSource, request packslip.Request) (resolution.ResolvedRelease, error) { + stub.source, stub.request = source, request + return stub.release, stub.err +} + +func TestResolvePackslipUsesDeclarationAndValidatesRelease(t *testing.T) { + release := resolvedPackslipRelease("github.com/example/driver", "1.2.3", "example") + resolver := &packslipResolverStub{release: release} + got, err := ResolvePackslip(context.Background(), resolver, "GitHub.com/Example/Driver", "example", "1.2.3") + require.NoError(t, err) + assert.Equal(t, release, got) + assert.Equal(t, "GitHub.com/Example/Driver", resolver.source.Project) + assert.Equal(t, packslip.Request{DriverID: "example", Version: "1.2.3"}, resolver.request) +} + +func TestResolvePackslipCanAdoptSignedDriverID(t *testing.T) { + release := resolvedPackslipRelease("github.com/example/driver", "1.2.3", "signed-driver") + resolver := &packslipResolverStub{release: release} + got, err := ResolvePackslip(context.Background(), resolver, "github.com/example/driver", "", "1.2.3") + require.NoError(t, err) + assert.Equal(t, "signed-driver", got.DriverID) + assert.Empty(t, resolver.request.DriverID, "an empty ID is an adoption request") +} + +func TestResolvePackslipRejectsMismatchedMetadata(t *testing.T) { + tests := []struct { + name string + change func(*resolution.ResolvedRelease) + want string + }{ + {name: "driver ID", change: func(r *resolution.ResolvedRelease) { r.DriverID = "other" }, want: "driver ID"}, + {name: "version", change: func(r *resolution.ResolvedRelease) { r.Version = "1.2.4" }, want: "version"}, + {name: "source type", change: func(r *resolution.ResolvedRelease) { r.Source.Type = "registry" }, want: "source identity"}, + {name: "source identity", change: func(r *resolution.ResolvedRelease) { r.Source.Reference = "github.com/other/driver" }, want: "source identity"}, + {name: "artifact hash", change: func(r *resolution.ResolvedRelease) { r.Artifacts[0].Hash = "" }, want: "size without a hash"}, + {name: "artifact size", change: func(r *resolution.ResolvedRelease) { r.Artifacts[0].Size = nil }, want: "must declare archive size"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + release := resolvedPackslipRelease("github.com/example/driver", "1.2.3", "example") + test.change(&release) + _, err := ResolvePackslip(context.Background(), &packslipResolverStub{release: release}, "github.com/example/driver", "example", "1.2.3") + require.ErrorContains(t, err, test.want) + }) + } +} + +func TestResolvePackslipPreservesUnsupportedError(t *testing.T) { + want := fmt.Errorf("%w: unsupported runtime", packslip.ErrUnsupported) + resolver := &packslipResolverStub{err: want} + _, err := ResolvePackslip(context.Background(), resolver, "github.com/example/driver", "example", "1.2.3") + require.ErrorIs(t, err, packslip.ErrUnsupported) +} + +func TestResolvePackslipRequiresExactSemver(t *testing.T) { + resolver := &packslipResolverStub{} + _, err := ResolvePackslip(context.Background(), resolver, "github.com/example/driver", "example", ">=1.0.0") + require.ErrorContains(t, err, "exact SemVer") + assert.Empty(t, resolver.request.Version, "invalid requests must not reach the resolver") +} + +func TestResolvePathUsesProjectBaseAndPreservesDeclaration(t *testing.T) { + projectDir := t.TempDir() + cwd := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(projectDir, "packages"), 0o700)) + archive := testPackageArchive(t, "MANIFEST", legacyMetadata("1.2.3")) + archivePath := filepath.Join(projectDir, "packages", "driver.tar.gz") + require.NoError(t, os.WriteFile(archivePath, archive, 0o600)) + t.Chdir(cwd) + + declaration := "./packages/driver.tar.gz" + release, err := ResolvePath(context.Background(), declaration, Request{ + DriverID: "example", Version: "1.2.3", Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, + Platform: "linux_amd64", BaseDir: projectDir, + }) + require.NoError(t, err) + require.Equal(t, "path", release.Source.Type) + assert.Equal(t, declaration, release.Source.Reference) + require.Len(t, release.Artifacts, 1) + artifact := release.Artifacts[0] + assert.Equal(t, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: declaration}, artifact.Location) + assert.Equal(t, "tar.gz", artifact.Format) + assert.Equal(t, int64(len(archive)), *artifact.Size) + assert.Equal(t, digest(archive), artifact.Hash) + assert.Equal(t, resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, artifact.Target) +} + +func TestResolvePathAdaptsLegacyPackageToRequestedIdentityAndTarget(t *testing.T) { + projectDir := t.TempDir() + archive := testPackageArchive(t, "MANIFEST", []byte("manifest_version = 1\nname = 'Legacy Driver'\nversion = '1.2.3'\n\n[Driver]\nshared = 'libexample.so'\n")) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "legacy.tgz"), archive, 0o600)) + + release, err := ResolvePath(context.Background(), "legacy.tgz", Request{ + DriverID: "example", Version: "1.2.3", Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, + Platform: "linux_amd64", BaseDir: projectDir, + }) + require.NoError(t, err) + assert.Equal(t, "example", release.DriverID) + assert.Equal(t, resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, release.Artifacts[0].Target) +} + +func TestResolvePathDetectsMissingAndMutatedArchives(t *testing.T) { + projectDir := t.TempDir() + path := filepath.Join(projectDir, "driver.tar.gz") + firstBytes := testPackageArchive(t, "MANIFEST", legacyMetadata("1.2.3")) + secondBytes := testPackageArchive(t, "MANIFEST", legacyMetadata("1.2.3")) + secondBytes = append(secondBytes, 0) + require.NoError(t, os.WriteFile(path, firstBytes, 0o600)) + request := Request{DriverID: "example", Version: "1.2.3", Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, Platform: "linux_amd64", BaseDir: projectDir} + first, err := ResolvePath(context.Background(), "driver.tar.gz", request) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, secondBytes, 0o600)) + second, err := ResolvePath(context.Background(), "driver.tar.gz", request) + require.NoError(t, err) + assert.NotEqual(t, first.Artifacts[0].Hash, second.Artifacts[0].Hash) + assert.NotEqual(t, *first.Artifacts[0].Size, *second.Artifacts[0].Size) + + require.NoError(t, os.Remove(path)) + _, err = ResolvePath(context.Background(), "driver.tar.gz", request) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func resolvedPackslipRelease(project, version, driverID string) resolution.ResolvedRelease { + size := int64(17) + return resolution.ResolvedRelease{ + DriverID: driverID, + Version: version, + Source: resolution.SourceSpec{Type: "packslip", Reference: project}, + Artifacts: []resolution.Artifact{{ + Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, Format: "tar.gz", + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/driver.tar.gz"}, + Hash: "sha256:" + strings.Repeat("a", 64), Size: &size, + }}, + } +} + +func legacyMetadata(version string) []byte { + return []byte(fmt.Sprintf(`manifest_version = 1 +name = 'Example Driver' +version = %q +[Driver] +entrypoint = 'ExampleInit' +[Files] +driver = 'driver.so' +`, version)) +} + +func testPackageArchive(t *testing.T, metadataName string, metadata []byte) []byte { + t.Helper() + var buffer bytes.Buffer + zipper := gzip.NewWriter(&buffer) + writer := tar.NewWriter(zipper) + require.NoError(t, writer.WriteHeader(&tar.Header{Name: metadataName, Mode: 0o600, Size: int64(len(metadata)), Typeflag: tar.TypeReg})) + _, err := writer.Write(metadata) + require.NoError(t, err) + require.NoError(t, writer.Close()) + require.NoError(t, zipper.Close()) + return buffer.Bytes() +} + +func digest(contents []byte) string { + sum := sha256.Sum256(contents) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func TestResolvePathRequiresDeclaredVersion(t *testing.T) { + projectDir := t.TempDir() + archive := testPackageArchive(t, "MANIFEST", legacyMetadata("1.2.3")) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "driver.tar.gz"), archive, 0o600)) + _, err := ResolvePath(context.Background(), "driver.tar.gz", Request{ + DriverID: "example", Version: "1.2.4", Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, + Platform: "linux_amd64", BaseDir: projectDir, + }) + require.ErrorContains(t, err, "does not match requested version") +} + +func TestResolvePathDerivesOmittedVersionFromPackageMetadata(t *testing.T) { + projectDir := t.TempDir() + archive := testPackageArchive(t, "MANIFEST", legacyMetadata("1.2.3")) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "driver.tar.gz"), archive, 0o600)) + + release, err := ResolvePath(context.Background(), "driver.tar.gz", Request{ + DriverID: "example", Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, + Platform: "linux_amd64", BaseDir: projectDir, + }) + require.NoError(t, err) + assert.Equal(t, "1.2.3", release.Version) +} + +func TestResolvePathRejectsNonArchiveExtension(t *testing.T) { + projectDir := t.TempDir() + archive := testPackageArchive(t, "MANIFEST", legacyMetadata("1.2.3")) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "driver.zip"), archive, 0o600)) + _, err := ResolvePath(context.Background(), "driver.zip", Request{ + DriverID: "example", Version: "1.2.3", Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, + Platform: "linux_amd64", BaseDir: projectDir, + }) + require.ErrorContains(t, err, "must use .tar.gz or .tgz") +} + +func TestResolvePathRequiresAbsoluteProjectBase(t *testing.T) { + _, err := ResolvePath(context.Background(), "driver.tar.gz", Request{ + DriverID: "example", Version: "1.2.3", Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, + Platform: "linux_amd64", BaseDir: "relative", + }) + require.ErrorContains(t, err, "must be absolute") +} + +func TestResolvePathRequiresPlatformTargetConsistency(t *testing.T) { + projectDir := t.TempDir() + archive := testPackageArchive(t, "MANIFEST", legacyMetadata("1.2.3")) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "driver.tar.gz"), archive, 0o600)) + _, err := ResolvePath(context.Background(), "driver.tar.gz", Request{ + DriverID: "example", Version: "1.2.3", Target: resolution.Target{OS: "macos", Arch: "arm64"}, + Platform: "linux_amd64", BaseDir: projectDir, + }) + require.ErrorContains(t, err, "does not match platform tuple") +} + +func TestResolvePathSnapshotsCanonicalTargetAliases(t *testing.T) { + projectDir := t.TempDir() + archive := testPackageArchive(t, "MANIFEST", []byte("manifest_version = 1\nname = 'Legacy Driver'\nversion = '1.2.3'\n\n[Driver]\nshared = 'libexample.so'\n")) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "legacy.tgz"), archive, 0o600)) + + tests := []struct { + name string + target resolution.Target + platform string + want resolution.Target + }{ + { + name: "Darwin and x86_64 aliases", + target: resolution.Target{OS: "darwin", Arch: "x86_64"}, platform: "darwin_x86_64", + want: resolution.Target{OS: "macos", Arch: "amd64"}, + }, + { + name: "Linux defaults to GNU libc", + target: resolution.Target{OS: "linux", Arch: "amd64"}, platform: "linux_amd64", + want: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + release, err := ResolvePath(context.Background(), "legacy.tgz", Request{ + DriverID: "example", Version: "1.2.3", Target: test.target, + Platform: test.platform, BaseDir: projectDir, + }) + require.NoError(t, err) + require.Len(t, release.Artifacts, 1) + assert.Equal(t, test.want, release.Artifacts[0].Target) + }) + } +} diff --git a/package_signature.go b/package_signature.go new file mode 100644 index 00000000..707e9ee9 --- /dev/null +++ b/package_signature.go @@ -0,0 +1,59 @@ +// 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 dbc + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/columnar-tech/dbc/config" +) + +// VerifyPackageSignature verifies a legacy package's library signature using +// the embedded Columnar public key while the extracted package is still in its +// private staging directory. Packages without a library file do not use library +// signatures. +func VerifyPackageSignature(stagingDir string, manifest config.Manifest) error { + if manifest.Files.Driver == "" { + return nil + } + + library, err := os.Open(filepath.Join(stagingDir, manifest.Files.Driver)) + if err != nil { + return fmt.Errorf("could not open driver file: %w", err) + } + defer library.Close() + + signatureName := manifest.Files.Signature + if signatureName == "" { + signatureName = manifest.Files.Driver + ".sig" + } + signature, err := os.Open(filepath.Join(stagingDir, signatureName)) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("signature file '%s' for driver is missing", signatureName) + } + return fmt.Errorf("failed to open signature file: %w", err) + } + defer signature.Close() + + if err := SignedByColumnar(library, signature); err != nil { + return fmt.Errorf("signature verification failed: %w", err) + } + return nil +} diff --git a/packages/npm-wasm/boot.cjs b/packages/npm-wasm/boot.cjs index 35585909..5755fbad 100644 --- a/packages/npm-wasm/boot.cjs +++ b/packages/npm-wasm/boot.cjs @@ -17,6 +17,88 @@ const fs = require("fs"); const path = require("path"); +const SINGLE_PATH_FS_METHODS = new Set([ + "open", + "mkdir", + "readdir", + "stat", + "lstat", + "unlink", + "rmdir", + "chmod", + "chown", + "lchown", + "utimes", + "truncate", + "readlink", +]); + +const TWO_PATH_FS_METHODS = new Set(["rename", "link", "symlink"]); + +function goPathToWindowsFS(value) { + if (typeof value !== "string") return value; + const match = /^\/([A-Za-z]:)(?:\/(.*))?$/.exec(value); + if (!match) return value; + return `${match[1]}/${match[2] || ""}`; +} + +function goPathToPublicWindowsPath(value, platform) { + if (platform !== "win32" || typeof value !== "string") return value; + const decoded = goPathToWindowsFS(value); + return decoded === value ? value : path.win32.normalize(decoded); +} + +function restoreManifestPath(manifest, platform = process.platform) { + if (!manifest || typeof manifest !== "object" || typeof manifest.driverPath !== "string") return manifest; + const driverPath = goPathToPublicWindowsPath(manifest.driverPath, platform); + return driverPath === manifest.driverPath ? manifest : { ...manifest, driverPath }; +} + +function restoreInstalledPaths(drivers, platform = process.platform) { + if (platform !== "win32" || !Array.isArray(drivers)) return drivers; + return drivers.map((driver) => { + if (!driver || typeof driver !== "object" || typeof driver.filePath !== "string") return driver; + const filePath = goPathToPublicWindowsPath(driver.filePath, platform); + return filePath === driver.filePath ? driver : { ...driver, filePath }; + }); +} + +function windowsPathToGo(value) { + if (typeof value !== "string") return value; + const forwardSlashes = value.replace(/\\/g, "/"); + // UNC and device-namespace targets are outside the supported path contract; + // preserve their spelling rather than turning them into drive paths. + if (/^\/\//.test(forwardSlashes)) return value; + if (/^[A-Za-z]:\//.test(forwardSlashes)) return `/${forwardSlashes}`; + return forwardSlashes; +} + +function createGoFSAdapter(fsModule, platform) { + if (platform !== "win32") return fsModule; + return new Proxy(fsModule, { + get(target, property) { + const method = Reflect.get(target, property, target); + if (typeof method !== "function") return method; + + let pathIndexes; + if (SINGLE_PATH_FS_METHODS.has(property)) pathIndexes = [0]; + else if (TWO_PATH_FS_METHODS.has(property)) pathIndexes = [0, 1]; + else return method; + + return (...args) => { + for (const index of pathIndexes) args[index] = goPathToWindowsFS(args[index]); + if (property === "readlink" && typeof args[1] === "function") { + const callback = args[1]; + args[1] = function (error, targetPath, ...rest) { + return callback.call(this, error, error ? targetPath : windowsPathToGo(targetPath), ...rest); + }; + } + return Reflect.apply(method, target, args); + }; + }, + }); +} + // 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 @@ -24,11 +106,22 @@ const path = require("path"); // only a few vars: $HOME (+ $XDG_*) for os.UserHomeDir/UserConfigDir/UserCacheDir, // and $TMPDIR for os.TempDir() (it ignores %TEMP%/%TMP% and falls back to /tmp, // which does not exist on a Windows host). Windows exposes the home/temp dirs as -// %USERPROFILE%/%TEMP%, so map them onto $HOME/$TMPDIR and convert backslashes to -// the forward slashes the wasm filesystem layer expects. +// %USERPROFILE%/%TEMP%, so map them onto $HOME/$TMPDIR and encode Windows paths +// using the same Unix-absolute drive spelling as explicit API locations. function curateGoEnv(env, platform) { const isWin = platform === "win32"; - const norm = (p) => (isWin ? String(p).replace(/\\/g, "/") : String(p)); + const norm = (p) => { + if (!isWin) return String(p); + const windowsPath = String(p).replace(/\\/g, "/"); + if (/^(?:\\\\|\/\/)/.test(String(p))) { + throw new Error("UNC and Windows device namespace paths are not supported by dbc-wasm"); + } + const absolutePath = path.win32.resolve(windowsPath); + if (/^\\\\/.test(absolutePath) || !/^[A-Za-z]:\\/.test(absolutePath)) { + throw new Error(`could not resolve Windows environment path to an absolute drive path: ${String(p)}`); + } + return `/${absolutePath.replace(/\\/g, "/")}`; + }; const out = {}; const home = env.HOME || env.USERPROFILE; if (home !== undefined) out.HOME = norm(home); @@ -37,6 +130,11 @@ function curateGoEnv(env, platform) { for (const k of ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"]) { if (env[k] !== undefined) out[k] = env[k]; } + if (isWin) { + for (const k of ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"]) { + if (out[k] !== undefined && out[k] !== "") out[k] = norm(out[k]); + } + } return out; } @@ -49,13 +147,17 @@ function curateGoEnv(env, platform) { // // Throws an unprefixed Error on failure; callers add the `dbc-wasm:` namespace. async function bootRuntime() { + const goFS = createGoFSAdapter(globalThis.fs || fs, process.platform); + // The upstream wasm_exec.js is browser-oriented. Under Node we must supply // the real fs/process (and webcrypto on Node 18) BEFORE loading it, or Go's // filesystem syscalls return "not implemented on js". if (!globalThis.crypto) globalThis.crypto = require("crypto").webcrypto; - if (!globalThis.fs) globalThis.fs = fs; if (!globalThis.process) globalThis.process = process; + // Keep the adapter as the Go runtime's global filesystem for its lifetime. + // It forwards to the original module without mutating it. + globalThis.fs = goFS; require("./wasm_exec.js"); // defines globalThis.Go const go = new globalThis.Go(); @@ -73,3 +175,9 @@ async function bootRuntime() { module.exports = bootRuntime; module.exports.curateGoEnv = curateGoEnv; +module.exports.createGoFSAdapter = createGoFSAdapter; +module.exports.goPathToWindowsFS = goPathToWindowsFS; +module.exports.goPathToPublicWindowsPath = goPathToPublicWindowsPath; +module.exports.restoreManifestPath = restoreManifestPath; +module.exports.restoreInstalledPaths = restoreInstalledPaths; +module.exports.windowsPathToGo = windowsPathToGo; diff --git a/packages/npm-wasm/index.cjs b/packages/npm-wasm/index.cjs index 152b78c6..97d1b38e 100644 --- a/packages/npm-wasm/index.cjs +++ b/packages/npm-wasm/index.cjs @@ -16,6 +16,7 @@ const path = require("path"); const bootRuntime = require("./boot.cjs"); +const { restoreInstalledPaths, restoreManifestPath } = bootRuntime; const PLATFORM_MAP = { linux: "linux", darwin: "macos", win32: "windows", freebsd: "freebsd" }; const ARCH_MAP = { x64: "amd64", arm64: "arm64", ia32: "x86" }; @@ -42,13 +43,29 @@ function hostPlatformTuple() { function normalizeLocation(loc) { // Only on Windows: backslash is a legal filename character on POSIX, so POSIX - // locations must pass through untouched. On Windows, convert backslashes to - // forward slashes (Go's js/wasm filepath uses Unix semantics; Node fs accepts - // forward-slash drive paths) and make a drive-relative path absolute. + // locations must pass through untouched. Go's js/wasm filepath uses Unix + // semantics, so encode Windows drive paths as /D:/... after resolving them + // with Node's Windows path rules. The Go filesystem adapter decodes this form + // back to D:/... immediately before calling Node fs. if (process.platform !== "win32") return String(loc); - let p = String(loc).replace(/\\/g, "/"); - p = p.replace(/^([A-Za-z]:)(?![/])/, "$1/"); - return p; + const input = String(loc); + if (input === "") return input; + if (/^(?:\\\\|\/\/)/.test(input)) { + throw new Error("UNC and Windows device namespace paths are not supported by dbc-wasm"); + } + if (/^\/[A-Za-z]:(?:\/|$)/.test(input)) return input.replace(/\\/g, "/"); + + let windowsPath = input.replace(/\\/g, "/"); + // Preserve the existing drive-relative behavior: C:drivers means C:/drivers. + windowsPath = windowsPath.replace(/^([A-Za-z]:)(?!\/)/, "$1/"); + const absolutePath = path.win32.resolve(process.cwd(), windowsPath); + if (/^\\\\/.test(absolutePath)) { + throw new Error("UNC and Windows device namespace paths are not supported by dbc-wasm"); + } + if (!/^[A-Za-z]:\\/.test(absolutePath)) { + throw new Error(`could not resolve Windows location to an absolute drive path: ${input}`); + } + return `/${absolutePath.replace(/\\/g, "/")}`; } let runtimePromise; @@ -195,11 +212,13 @@ function buildClient({ call, handle, hasInstall, close }) { // dbcInstall takes the client handle (it needs the instance's registry // config); dbcUninstall/dbcList are pure filesystem ops and intentionally // do not — per-instance config does not apply to them. - api.install = (name, location) => parse("dbcInstall", [handle, name, normalizeLocation(location)]); + api.install = async (name, location) => + restoreManifestPath(await parse("dbcInstall", [handle, name, normalizeLocation(location)])); api.uninstall = async (name, location) => { await call("dbcUninstall", [name, normalizeLocation(location)]); }; - api.listInstalled = (location) => parse("dbcList", [normalizeLocation(location)]); + api.listInstalled = async (location) => + restoreInstalledPaths(await parse("dbcList", [normalizeLocation(location)])); } return api; } diff --git a/packages/npm-wasm/test/normalize.test.cjs b/packages/npm-wasm/test/normalize.test.cjs index 4709d9b0..9cccb415 100644 --- a/packages/npm-wasm/test/normalize.test.cjs +++ b/packages/npm-wasm/test/normalize.test.cjs @@ -15,8 +15,19 @@ "use strict"; const assert = require("assert"); +const path = require("path"); const { normalizeLocation } = require("../index.cjs"); -const { curateGoEnv } = require("../boot.cjs"); +const { + createGoFSAdapter, + curateGoEnv, + goPathToWindowsFS, + goPathToPublicWindowsPath, + restoreInstalledPaths, + restoreManifestPath, + windowsPathToGo, +} = require("../boot.cjs"); + +const HOST_PLATFORM = process.platform; function withPlatform(platform, fn) { const orig = Object.getOwnPropertyDescriptor(process, "platform"); @@ -36,20 +47,28 @@ withPlatform("linux", () => { } }); -// Windows: backslashes -> forward slashes; drive-relative -> absolute. +// Windows: drive paths are encoded as Unix-absolute for Go js/wasm. Node-side +// paths are resolved before encoding, and the Go fs adapter decodes them later. withPlatform("win32", () => { const cases = [ - ["/tmp/drivers", "/tmp/drivers"], - ["C:\\drivers", "C:/drivers"], - ["C:/drivers", "C:/drivers"], - ["C:\\a\\b\\c", "C:/a/b/c"], - ["C:drivers", "C:/drivers"], - ["C:", "C:/"], - ["d:\\Lower", "d:/Lower"], + ["C:\\drivers", "/C:/drivers"], + ["C:/drivers", "/C:/drivers"], + ["C:\\a\\b\\c", "/C:/a/b/c"], + ["C:drivers", "/C:/drivers"], + ["C:", "/C:/"], + ["d:\\Lower", "/d:/Lower"], + ["D:/a/dbc/.dbc-wasm-smoke-abc", "/D:/a/dbc/.dbc-wasm-smoke-abc"], ]; for (const [input, want] of cases) { assert.strictEqual(normalizeLocation(input), want, `win32 ${JSON.stringify(input)}`); } + assert.throws(() => normalizeLocation("\\\\server\\share\\drivers"), /UNC/); + assert.throws(() => normalizeLocation("\\\\?\\D:\\drivers"), /UNC/); + + if (HOST_PLATFORM === "win32") { + assert.strictEqual(normalizeLocation(".\\drivers"), `/${process.cwd().replace(/\\/g, "/")}/drivers`); + assert.strictEqual(normalizeLocation("/tmp/drivers"), `/${path.win32.resolve("/tmp/drivers").replace(/\\/g, "/")}`); + } }); console.log("normalizeLocation: POSIX passthrough + Windows transform passed"); @@ -74,8 +93,8 @@ const ALLOWED_ENV_KEYS = ["HOME", "TMPDIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", }, "win32" ); - assert.strictEqual(win.HOME, "C:/Users/me", "win HOME from USERPROFILE"); - assert.strictEqual(win.TMPDIR, "C:/Users/me/AppData/Local/Temp", "win TMPDIR from TEMP"); + assert.strictEqual(win.HOME, "/C:/Users/me", "win HOME from USERPROFILE"); + assert.strictEqual(win.TMPDIR, "/C:/Users/me/AppData/Local/Temp", "win TMPDIR from TEMP"); assert.deepStrictEqual(Object.keys(win).sort(), ["HOME", "TMPDIR"], "win env limited to mapped vars"); for (const k of Object.keys(win)) assert(ALLOWED_ENV_KEYS.includes(k), `win unexpected key ${k}`); } @@ -83,7 +102,18 @@ const ALLOWED_ENV_KEYS = ["HOME", "TMPDIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", // Windows: an explicit TMPDIR wins over TEMP/TMP. { const win = curateGoEnv({ TMPDIR: "X:\\explicit", TEMP: "C:\\temp" }, "win32"); - assert.strictEqual(win.TMPDIR, "X:/explicit", "win TMPDIR precedence"); + assert.strictEqual(win.TMPDIR, "/X:/explicit", "win TMPDIR precedence"); +} + +// Empty Windows XDG values remain empty instead of becoming the process cwd. +{ + const win = curateGoEnv( + { XDG_CONFIG_HOME: "", XDG_DATA_HOME: "", XDG_CACHE_HOME: "" }, + "win32" + ); + assert.strictEqual(win.XDG_CONFIG_HOME, "", "empty XDG_CONFIG_HOME stays empty"); + assert.strictEqual(win.XDG_DATA_HOME, "", "empty XDG_DATA_HOME stays empty"); + assert.strictEqual(win.XDG_CACHE_HOME, "", "empty XDG_CACHE_HOME stays empty"); } // POSIX: HOME wins over USERPROFILE; XDG_* forwarded; PATH dropped; backslashes @@ -109,4 +139,133 @@ const ALLOWED_ENV_KEYS = ["HOME", "TMPDIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", // POSIX backslash passthrough: a home path containing a backslash is not mangled. assert.strictEqual(curateGoEnv({ HOME: "/home/a\\b" }, "linux").HOME, "/home/a\\b", "posix backslash passthrough"); +// Windows path codec: drive roots and drive paths are converted between the +// Go js/wasm Unix-absolute spelling and Node's native Windows spelling. Other +// relative and POSIX paths are preserved. +assert.strictEqual(goPathToWindowsFS("/C:"), "C:/", "decode C drive root"); +assert.strictEqual(goPathToWindowsFS("/D:/"), "D:/", "decode D drive root"); +assert.strictEqual(goPathToWindowsFS("/D:/a/dbc"), "D:/a/dbc", "decode D drive path"); +assert.strictEqual(goPathToWindowsFS("relative/path"), "relative/path", "preserve relative path"); +assert.strictEqual(goPathToWindowsFS("/tmp/drivers"), "/tmp/drivers", "preserve POSIX path"); +assert.strictEqual(windowsPathToGo("C:\\"), "/C:/", "encode C drive root"); +assert.strictEqual(windowsPathToGo("D:\\a\\dbc"), "/D:/a/dbc", "encode D drive path"); +assert.strictEqual(windowsPathToGo("..\\drivers"), "../drivers", "preserve relative target"); +assert.strictEqual(windowsPathToGo("/tmp/drivers"), "/tmp/drivers", "preserve POSIX target"); +assert.strictEqual(windowsPathToGo("\\\\server\\share\\driver"), "\\\\server\\share\\driver", "preserve unsupported UNC target spelling"); + +// Public install/list responses convert only Go-encoded Windows filesystem +// paths. Other manifest fields and relative/POSIX paths retain their values. +{ + const manifest = { + id: "test-driver-1", + version: "1.1.0", + source: "registry", + driverPath: "/D:/repo/.dbc-package-test-driver-1/lib/test.so", + }; + assert.deepStrictEqual(restoreManifestPath(manifest, "win32"), { + ...manifest, + driverPath: "D:\\repo\\.dbc-package-test-driver-1\\lib\\test.so", + }); + assert.strictEqual(restoreManifestPath(manifest, "linux"), manifest, "POSIX manifest is unchanged"); + assert.strictEqual( + goPathToPublicWindowsPath("relative/path", "win32"), + "relative/path", + "relative path is unchanged" + ); + assert.strictEqual( + goPathToPublicWindowsPath("/tmp/driver.so", "win32"), + "/tmp/driver.so", + "POSIX path is unchanged" + ); + + const installed = [ + { id: "windows", filePath: "/C:/drivers/windows.so", version: "1.0.0" }, + { id: "posix", filePath: "/tmp/drivers/posix.so", version: "1.0.0" }, + { id: "relative", filePath: "drivers/relative.so", version: "1.0.0" }, + ]; + assert.deepStrictEqual(restoreInstalledPaths(installed, "win32"), [ + { ...installed[0], filePath: "C:\\drivers\\windows.so" }, + installed[1], + installed[2], + ]); + assert.strictEqual(restoreInstalledPaths(installed, "linux"), installed, "POSIX list is unchanged"); +} + +// The adapter converts only filesystem path arguments. File descriptors, data, +// options, and ordinary callbacks keep their original values. +{ + const calls = []; + const names = [ + "open", "mkdir", "readdir", "stat", "lstat", "unlink", "rmdir", "chmod", + "chown", "lchown", "utimes", "truncate", "readlink", "rename", "link", "symlink", + ]; + const fakeFS = { constants: { sentinel: true } }; + for (const name of names) { + fakeFS[name] = (...args) => { + calls.push({ name, args }); + if (name === "readlink") args[1](null, "D:\\target\\driver", "extra"); + return name; + }; + } + const originalStat = fakeFS.stat; + const adapter = createGoFSAdapter(fakeFS, "win32"); + assert.strictEqual(fakeFS.stat, originalStat, "adapter does not mutate original fs module"); + const callback = () => {}; + const path = "/D:/a/dbc"; + + adapter.open(path, 17, 0o600, callback); + adapter.mkdir(path, 0o700, callback); + adapter.readdir(path, callback); + adapter.stat(path, callback); + adapter.lstat(path, callback); + adapter.unlink(path, callback); + adapter.rmdir(path, callback); + adapter.chmod(path, 0o700, callback); + adapter.chown(path, 1, 2, callback); + adapter.lchown(path, 1, 2, callback); + adapter.utimes(path, 3, 4, callback); + adapter.truncate(path, 5, callback); + let readlinkTarget; + adapter.readlink(path, (error, target, extra) => { + assert.strictEqual(error, null); + readlinkTarget = target; + assert.strictEqual(extra, "extra"); + }); + adapter.rename("/C:/from", "/D:/to", callback); + adapter.link("/C:/from", "/D:/to", callback); + adapter.symlink("/C:/target", "/D:/link", callback); + + assert.strictEqual(readlinkTarget, "/D:/target/driver", "encode absolute readlink target"); + assert.deepStrictEqual(calls.map(({ name }) => name), names); + const twoPathArgs = { + rename: ["C:/from", "D:/to"], + link: ["C:/from", "D:/to"], + symlink: ["C:/target", "D:/link"], + }; + for (const { name, args } of calls) { + if (name === "rename" || name === "link" || name === "symlink") { + assert.deepStrictEqual(args.slice(0, 2), twoPathArgs[name], `${name} path conversion`); + assert.strictEqual(args[2], callback, `${name} callback identity`); + } else { + assert.strictEqual(args[0], "D:/a/dbc", `${name} path conversion`); + if (name !== "readlink") assert.strictEqual(args[args.length - 1], callback, `${name} callback identity`); + } + } + assert.strictEqual(calls[0].args[1], 17, "open flags unchanged"); + assert.strictEqual(calls[0].args[2], 0o600, "open permissions unchanged"); + const data = new Uint8Array([1, 2, 3]); + const writeCallback = () => {}; + fakeFS.write = (...args) => calls.push({ name: "write", args }); + const originalWrite = fakeFS.write; + assert.strictEqual(adapter.write, originalWrite, "fd/data method is not wrapped"); + adapter.write(17, data, 1, 2, 3, writeCallback); + assert.strictEqual(calls.at(-1).args[0], 17, "write fd unchanged"); + assert.strictEqual(calls.at(-1).args[1], data, "write data unchanged"); + assert.strictEqual(calls.at(-1).args[5], writeCallback, "write callback unchanged"); + const posixFS = createGoFSAdapter(fakeFS, "linux"); + assert.strictEqual(posixFS, fakeFS, "POSIX fs is not wrapped"); +} + +console.log("Windows Go fs path codec + adapter passed"); + console.log("curateGoEnv: Windows TMPDIR/HOME mapping + POSIX passthrough + env bounded passed"); diff --git a/packages/npm-wasm/test/smoke.cjs b/packages/npm-wasm/test/smoke.cjs index 60a8b466..06282886 100644 --- a/packages/npm-wasm/test/smoke.cjs +++ b/packages/npm-wasm/test/smoke.cjs @@ -17,14 +17,13 @@ const assert = require("assert"); const fs = require("fs"); const http = require("http"); -const os = require("os"); const path = require("path"); 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 tarData = fs.readFileSync(path.join(REPO_ROOT, "cmd/dbc/testdata/test-driver-1.1.tar.gz")); const server = http.createServer((req, res) => { if (req.url.startsWith("/index.yaml")) { @@ -59,11 +58,15 @@ async function main() { const resolved = await dbc.resolve("test-driver-1", "linux_amd64"); assert(resolved.versions.length > 0, "resolve returned no versions"); - const installDir = fs.mkdtempSync(path.join(os.tmpdir(), "dbc-wasm-smoke-")); - const manifest = await dbc.install("test-driver-1", installDir); + // Keep the install fixture on the same drive as Node's working directory. + // The Go js/wasm runtime uses slash-based paths, so a Windows temp directory + // on another drive is interpreted as a relative path and becomes malformed. + const installDir = fs.mkdtempSync(path.join(process.cwd(), ".dbc-wasm-smoke-")); + const installLocation = path.relative(process.cwd(), installDir); + const manifest = await dbc.install("test-driver-1", installLocation); assert(manifest.driverPath && fs.existsSync(manifest.driverPath), "installed driver missing on disk"); - const installed = await dbc.listInstalled(installDir); + const installed = await dbc.listInstalled(installLocation); assert( installed.length === 1 && installed[0].id === "test-driver-1", `listInstalled mismatch: got ${JSON.stringify(installed)}; installDir entries: ${JSON.stringify(fs.readdirSync(installDir, { recursive: true }))}` @@ -74,8 +77,8 @@ async function main() { 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); + await dbc.uninstall("test-driver-1", installLocation); + const after = await dbc.listInstalled(installLocation); assert(after.length === 0, "driver still listed after uninstall"); // Regression guard (roborev 6562): in-process loadDbc() must namespace diff --git a/packages/npm-wasm/test/worker.test.cjs b/packages/npm-wasm/test/worker.test.cjs index 8279ae95..05e36be5 100644 --- a/packages/npm-wasm/test/worker.test.cjs +++ b/packages/npm-wasm/test/worker.test.cjs @@ -17,14 +17,13 @@ const assert = require("assert"); const fs = require("fs"); const http = require("http"); -const os = require("os"); const path = require("path"); 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 tarData = fs.readFileSync(path.join(REPO_ROOT, "cmd/dbc/testdata/test-driver-1.1.tar.gz")); const server = http.createServer((req, res) => { if (req.url.startsWith("/index.yaml")) { @@ -56,11 +55,15 @@ async function main() { const search = await dbc.search(""); assert(search.drivers.length > 0, "search returned no drivers"); - const installDir = fs.mkdtempSync(path.join(os.tmpdir(), "dbc-wasm-worker-")); - const manifest = await dbc.install("test-driver-1", installDir); + // Keep the install fixture on the same drive as Node's working directory. + // The Go js/wasm runtime uses slash-based paths, so a Windows temp directory + // on another drive is interpreted as a relative path and becomes malformed. + const installDir = fs.mkdtempSync(path.join(process.cwd(), ".dbc-wasm-worker-")); + const installLocation = path.relative(process.cwd(), installDir); + const manifest = await dbc.install("test-driver-1", installLocation); assert(manifest.driverPath && fs.existsSync(manifest.driverPath), "installed driver missing on disk"); - const installed = await dbc.listInstalled(installDir); + const installed = await dbc.listInstalled(installLocation); assert(installed.length === 1 && installed[0].id === "test-driver-1", "listInstalled mismatch"); const so = findFile(installDir, ".so"); @@ -68,8 +71,8 @@ async function main() { 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"); + await dbc.uninstall("test-driver-1", installLocation); + assert((await dbc.listInstalled(installLocation)).length === 0, "driver still listed after uninstall"); await dbc.close(); diff --git a/registry_artifact.go b/registry_artifact.go new file mode 100644 index 00000000..ccd2ff00 --- /dev/null +++ b/registry_artifact.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. + +package dbc + +import ( + "fmt" + "net/url" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc/internal/resolution" + "github.com/go-faster/yaml" +) + +func resolveRegistryPackageURL(d Driver, version *semver.Version, pkg registryPackage) (*url.URL, error) { + if pkg.URL != "" { + uri, err := url.Parse(pkg.URL) + if err != nil { + return nil, fmt.Errorf("invalid package URL %q: %w", pkg.URL, err) + } + if uri.IsAbs() { + return uri, nil + } + if d.Registry == nil || d.Registry.BaseURL == nil { + return nil, fmt.Errorf("cannot resolve package URL for %s: driver has no registry URL", d.Title) + } + return d.Registry.BaseURL.JoinPath(pkg.URL), nil + } + + if d.Registry == nil || d.Registry.BaseURL == nil { + return nil, fmt.Errorf("cannot resolve package URL for %s: driver has no registry URL", d.Title) + } + if version == nil { + return nil, fmt.Errorf("cannot resolve package URL for %s: release has no version", d.Title) + } + + platform := pkg.PlatformTuple + return d.Registry.BaseURL.JoinPath(d.Path, version.String(), + d.Path+"_"+platform+"-"+version.String()+".tar.gz"), nil +} + +// resolvedRelease adapts registry metadata into the source-independent model. +// It resolves omitted URLs for every declared platform before returning the +// release, so lock conversion never sees a mix of resolved and implicit URLs. +func (p pkginfo) resolvedRelease(d Driver) (resolution.ResolvedRelease, error) { + if p.Version == nil { + return resolution.ResolvedRelease{}, fmt.Errorf("cannot resolve release for %s: release has no version", d.Title) + } + if strings.TrimSpace(d.Path) == "" { + return resolution.ResolvedRelease{}, fmt.Errorf("cannot record registry source identity for release: driver ID is empty") + } + if d.Registry == nil || d.Registry.BaseURL == nil { + return resolution.ResolvedRelease{}, fmt.Errorf("cannot record registry source identity for %s: registry BaseURL is missing", d.Path) + } + baseURL := d.Registry.BaseURL + if !baseURL.IsAbs() || + (!strings.EqualFold(baseURL.Scheme, "http") && !strings.EqualFold(baseURL.Scheme, "https")) || + baseURL.Hostname() == "" { + return resolution.ResolvedRelease{}, fmt.Errorf("cannot record registry source identity for %s: registry BaseURL must be an absolute HTTP(S) URL with a host", d.Path) + } + + release := resolution.ResolvedRelease{ + DriverID: d.Path, + Version: p.Version.String(), + Source: resolution.SourceSpec{ + Type: "registry", + Reference: baseURL.String(), + }, + Artifacts: make([]resolution.Artifact, 0, len(p.Packages)), + } + for _, pkg := range p.Packages { + artifact, err := pkg.resolveArtifact() + if err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("invalid artifact for platform %q: %w", pkg.PlatformTuple, err) + } + uri, err := resolveRegistryPackageURL(d, p.Version, pkg) + if err != nil { + return resolution.ResolvedRelease{}, err + } + artifact.Location = resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: uri.String()} + if err := resolution.ValidateArtifactLocation(artifact.Location); err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("invalid artifact URL for platform %q: %w", pkg.PlatformTuple, err) + } + release.Artifacts = append(release.Artifacts, artifact) + } + return release, nil +} + +func (p registryPackage) resolveArtifact() (resolution.Artifact, error) { + hash, hashPresent, err := optionalString(p.Hash, "hash") + if err != nil { + return resolution.Artifact{}, err + } + if hashPresent && hash == "" { + return resolution.Artifact{}, fmt.Errorf("artifact hash must not be empty") + } + + if err := resolution.ValidateArtifactMetadata(hash, nil); err != nil { + return resolution.Artifact{}, err + } + target, err := resolution.TargetFromPlatformTuple(p.PlatformTuple) + if err != nil { + return resolution.Artifact{}, err + } + return resolution.Artifact{ + Target: target, + Hash: hash, + }, nil +} + +func optionalString(node yaml.Node, field string) (string, bool, error) { + if node.Kind == 0 { + return "", false, nil + } + if node.Kind != yaml.ScalarNode || node.Tag != "!!str" { + return "", true, fmt.Errorf("artifact %s must be a string", field) + } + return node.Value, true, nil +} + +func validateRegistryMetadata(drivers []Driver) error { + for _, driver := range drivers { + for _, release := range driver.PkgInfo { + for _, pkg := range release.Packages { + if _, err := pkg.resolveArtifact(); err != nil { + return fmt.Errorf("invalid artifact metadata for driver %q version %s platform %q: %w", + driver.Path, release.Version, pkg.PlatformTuple, err) + } + } + } + } + return nil +} diff --git a/registry_artifact_test.go b/registry_artifact_test.go new file mode 100644 index 00000000..e61afd8c --- /dev/null +++ b/registry_artifact_test.go @@ -0,0 +1,302 @@ +// 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 dbc + +import ( + "os" + "strings" + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc/internal/resolution" + "github.com/go-faster/yaml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRegistryArtifactMetadataFixture(t *testing.T) { + data, err := os.ReadFile("testdata/registry-index-artifact-metadata.yaml") + require.NoError(t, err) + + var index struct { + Drivers []Driver `yaml:"drivers"` + } + require.NoError(t, yaml.NewDecoder(strings.NewReader(string(data))).Decode(&index)) + require.Len(t, index.Drivers, 1) + require.NoError(t, validateRegistryMetadata(index.Drivers)) + + driver := index.Drivers[0] + driver.Registry = &Registry{BaseURL: mustParseURL("https://registry.example.test")} + pkg, err := driver.GetPackage(nil, "linux_amd64", false) + require.NoError(t, err) + assert.Equal(t, "sha256:0000000000000000000000000000000000000000000000000000000000000000", pkg.ArtifactHash) + assert.Nil(t, pkg.ArtifactSize, "registry size metadata is ignored") + + // The fixture intentionally describes only one target platform. + _, err = driver.GetPackage(nil, "windows_amd64", false) + require.Error(t, err) +} + +func TestRegistryArtifactMetadataOptional(t *testing.T) { + pkg := decodeRegistryPackage(t, "platform: linux_amd64\n") + artifact, err := pkg.resolveArtifact() + require.NoError(t, err) + assert.Empty(t, artifact.Hash) + assert.Nil(t, artifact.Size) + + pkg = decodeRegistryPackage(t, "platform: linux_amd64\nfuture_metadata: anything\n") + artifact, err = pkg.resolveArtifact() + require.NoError(t, err) + assert.Empty(t, artifact.Hash) + assert.Nil(t, artifact.Size, "unknown registry keys are ignored") + + pkg = decodeRegistryPackage(t, "platform: linux_amd64\nhash: sha256:"+strings.Repeat("a", 64)+"\n") + artifact, err = pkg.resolveArtifact() + require.NoError(t, err) + assert.Equal(t, "sha256:"+strings.Repeat("a", 64), artifact.Hash) + assert.Nil(t, artifact.Size) +} + +func TestResolveRegistryPackageURLStateTable(t *testing.T) { + types := []struct { + name string + pkg registryPackage + }{ + {name: "absolute", pkg: registryPackage{URL: "https://packages.example.test/driver.tar.gz"}}, + {name: "relative", pkg: registryPackage{URL: "driver.tar.gz"}}, + {name: "implicit", pkg: registryPackage{PlatformTuple: "linux_amd64"}}, + } + registries := []struct { + name string + registry *Registry + valid bool + }{ + {name: "nil"}, + {name: "empty", registry: &Registry{}}, + {name: "valid", registry: &Registry{BaseURL: mustParseURL("https://registry.example.test")}, valid: true}, + } + versions := []struct { + name string + version *semver.Version + }{ + {name: "nil"}, + {name: "present", version: semver.MustParse("1.2.3")}, + } + + for _, urlType := range types { + for _, registry := range registries { + for _, version := range versions { + name := urlType.name + "/registry=" + registry.name + "/version=" + version.name + t.Run(name, func(t *testing.T) { + wantSuccess := urlType.name == "absolute" || + (urlType.name == "relative" && registry.valid) || + (urlType.name == "implicit" && registry.valid && version.version != nil) + driver := Driver{Title: "Example Driver", Path: "example-driver", Registry: registry.registry} + uri, err := resolveRegistryPackageURL(driver, version.version, urlType.pkg) + if !wantSuccess { + require.Error(t, err) + return + } + require.NoError(t, err) + wantURL := "https://packages.example.test/driver.tar.gz" + if urlType.name == "relative" { + wantURL = "https://registry.example.test/driver.tar.gz" + } + if urlType.name == "implicit" { + wantURL = "https://registry.example.test/example-driver/1.2.3/example-driver_linux_amd64-1.2.3.tar.gz" + } + assert.Equal(t, wantURL, uri.String()) + }) + } + } + } +} + +func TestResolvedRegistryReleaseRequiresSourceIdentity(t *testing.T) { + absoluteRelease := pkginfo{ + Version: semver.MustParse("1.2.3"), + Packages: []registryPackage{{ + PlatformTuple: "linux_amd64", + URL: "https://packages.example.test/driver.tar.gz", + }}, + } + tests := []struct { + name string + driver Driver + wantErr string + missingVersion bool + }{ + {name: "missing driver ID", driver: Driver{Title: "Example Driver", Registry: &Registry{BaseURL: mustParseURL("https://registry.example.test")}}, wantErr: "driver ID is empty"}, + {name: "missing registry", driver: Driver{Path: "example-driver"}, wantErr: "registry BaseURL is missing"}, + {name: "missing registry BaseURL", driver: Driver{Path: "example-driver", Registry: &Registry{}}, wantErr: "registry BaseURL is missing"}, + {name: "invalid registry identity", driver: Driver{Path: "example-driver", Registry: &Registry{BaseURL: mustParseURL("file:///tmp/registry")}}, wantErr: "absolute HTTP(S) URL with a host"}, + {name: "missing version", driver: Driver{Path: "example-driver", Registry: &Registry{BaseURL: mustParseURL("https://registry.example.test")}}, wantErr: "release has no version", missingVersion: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + release := absoluteRelease + if tt.missingVersion { + release.Version = nil + } + _, err := release.resolvedRelease(tt.driver) + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func TestResolvedRegistryReleaseKeepsSourceAndArtifactHostsSeparate(t *testing.T) { + release := pkginfo{ + Version: semver.MustParse("1.2.3"), + Packages: []registryPackage{{ + PlatformTuple: "linux_amd64", + URL: "https://packages.example.test/driver.tar.gz", + }}, + } + driver := Driver{Path: "example-driver", Registry: &Registry{BaseURL: mustParseURL("https://registry.example.test")}} + + resolved, err := release.resolvedRelease(driver) + require.NoError(t, err) + assert.Equal(t, "https://registry.example.test", resolved.Source.Reference) + require.Len(t, resolved.Artifacts, 1) + assert.Equal(t, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://packages.example.test/driver.tar.gz"}, resolved.Artifacts[0].Location) +} + +func TestResolvedRegistryReleaseRejectsInvalidResolvedArtifactURLs(t *testing.T) { + tests := []struct { + name string + url string + }{ + {name: "file URL", url: "file:///tmp/archive.tar.gz"}, + {name: "userinfo URL", url: "https://user@example.test/archive.tar.gz"}, + {name: "fragment URL", url: "https://example.test/archive.tar.gz#part"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + release := pkginfo{ + Version: semver.MustParse("1.2.3"), + Packages: []registryPackage{{PlatformTuple: "linux_amd64", URL: test.url}}, + } + driver := Driver{Path: "example-driver", Registry: &Registry{BaseURL: mustParseURL("https://registry.example.test")}} + _, err := release.resolvedRelease(driver) + assert.ErrorContains(t, err, "invalid artifact URL") + }) + } +} + +func TestResolvedRegistryReleaseSourceIdentityIncludesRegistry(t *testing.T) { + release := pkginfo{ + Version: semver.MustParse("1.2.3"), + Packages: []registryPackage{{PlatformTuple: "linux_amd64", URL: "https://packages.example.test/driver.tar.gz"}}, + } + first, err := release.resolvedRelease(Driver{Path: "example-driver", Registry: &Registry{BaseURL: mustParseURL("https://registry-one.example.test")}}) + require.NoError(t, err) + second, err := release.resolvedRelease(Driver{Path: "example-driver", Registry: &Registry{BaseURL: mustParseURL("https://registry-two.example.test")}}) + require.NoError(t, err) + assert.NotEqual(t, first.Source.Reference, second.Source.Reference) +} + +func TestResolvedRegistryReleaseMatchesGetPackageURLs(t *testing.T) { + version := semver.MustParse("1.2.3") + release := pkginfo{ + Version: version, + Packages: []registryPackage{ + {PlatformTuple: "linux_amd64", URL: "packages/linux.tar.gz"}, + {PlatformTuple: "windows_amd64"}, + }, + } + driver := Driver{ + Path: "example-driver", + Registry: &Registry{BaseURL: mustParseURL("https://registry.example.test")}, + PkgInfo: []pkginfo{release}, + } + resolved, err := release.resolvedRelease(driver) + require.NoError(t, err) + + for _, rawPackage := range release.Packages { + target, err := resolution.TargetFromPlatformTuple(rawPackage.PlatformTuple) + require.NoError(t, err) + var artifact resolution.Artifact + found := false + for _, candidate := range resolved.Artifacts { + if candidate.Target == target { + artifact = candidate + found = true + break + } + } + require.True(t, found, "resolved release should contain the canonical target for %s", rawPackage.PlatformTuple) + pkg, err := driver.GetPackage(version, rawPackage.PlatformTuple, false) + require.NoError(t, err) + require.NotNil(t, pkg.Path) + assert.Equal(t, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: pkg.Path.String()}, artifact.Location) + } +} + +func TestRegistryTupleAliasesCanonicalizeWithoutChangingImplicitAssetURL(t *testing.T) { + version := semver.MustParse("1.2.3") + release := pkginfo{ + Version: version, + Packages: []registryPackage{{PlatformTuple: "linux_x86_64"}}, + } + driver := Driver{ + Path: "example-driver", + Registry: &Registry{BaseURL: mustParseURL("https://registry.example.test")}, + } + resolved, err := release.resolvedRelease(driver) + require.NoError(t, err) + require.Len(t, resolved.Artifacts, 1) + assert.Equal(t, resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, resolved.Artifacts[0].Target) + assert.Equal(t, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://registry.example.test/example-driver/1.2.3/example-driver_linux_x86_64-1.2.3.tar.gz"}, resolved.Artifacts[0].Location, + "implicit asset filenames retain the raw registry tuple") +} + +func TestRegistryArtifactMetadataRejectsInvalidHashes(t *testing.T) { + tests := []struct { + name string + metadata string + want string + }{ + {name: "unknown algorithm", metadata: "hash: sha512:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n", want: "unsupported artifact hash algorithm"}, + {name: "short digest", metadata: "hash: sha256:abcd\n", want: "64 lowercase hexadecimal"}, + {name: "uppercase digest", metadata: "hash: sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n", want: "64 lowercase hexadecimal"}, + {name: "missing algorithm", metadata: "hash: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n", want: "include an algorithm prefix"}, + {name: "non-string digest", metadata: "hash: 123\n", want: "hash must be a string"}, + {name: "empty digest", metadata: "hash: \"\"\n", want: "hash must not be empty"}, + {name: "null digest", metadata: "hash: null\n", want: "hash must be a string"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkg := decodeRegistryPackage(t, "platform: linux_amd64\n"+tt.metadata) + _, err := pkg.resolveArtifact() + require.ErrorContains(t, err, tt.want) + }) + } +} + +func TestRegistryUnknownMetadataKeyIsIgnored(t *testing.T) { + pkg := decodeRegistryPackage(t, "platform: linux_amd64\nhash: sha256:"+strings.Repeat("a", 64)+"\nfuture_metadata: anything\n") + artifact, err := pkg.resolveArtifact() + require.NoError(t, err) + assert.Equal(t, "sha256:"+strings.Repeat("a", 64), artifact.Hash) + assert.Nil(t, artifact.Size) +} + +func decodeRegistryPackage(t *testing.T, data string) registryPackage { + t.Helper() + var pkg registryPackage + require.NoError(t, yaml.NewDecoder(strings.NewReader(data)).Decode(&pkg)) + return pkg +} diff --git a/registry_config.go b/registry_config.go index a7ff096f..ca8cb8aa 100644 --- a/registry_config.go +++ b/registry_config.go @@ -20,8 +20,8 @@ import ( "net/url" "os" "path/filepath" - "strings" + "github.com/columnar-tech/dbc/internal/sourceidentity" "github.com/pelletier/go-toml/v2" ) @@ -73,17 +73,8 @@ func validateRegistryEntry(e RegistryEntry) error { if e.URL == "" { return errors.New("registry entry has empty url") } - u, err := url.Parse(e.URL) - if err != nil { - return fmt.Errorf("invalid registry URL %q: %w", e.URL, err) - } - if u.Host == "" { - return fmt.Errorf("invalid registry URL %q: missing host", e.URL) - } - if u.Scheme != "http" && u.Scheme != "https" { - return fmt.Errorf("invalid registry URL %q: scheme must be http or https", e.URL) - } - return nil + _, err := sourceidentity.Parse(sourceidentity.Registry, e.URL) + return err } // mergeRegistries combines project, global, and default registries into a @@ -102,37 +93,24 @@ func mergeRegistries( replaceDefaults = *projectReplaceDefaults } - seen := make(map[string]bool) + seen := make(map[sourceidentity.Key]bool) var result []Registry - // urlKey returns a canonical form that collapses only truly no-op - // differences: scheme/host casing, trailing-slash on the path, and - // fragments. Query, userinfo, and path segments are preserved because - // they change the effective registry endpoint (tenant selectors, - // credential-bearing URLs, path-mounted registries) and must be - // treated as distinct registries here. - urlKey := func(u *url.URL) string { - cp := *u - cp.Scheme = strings.ToLower(cp.Scheme) - cp.Host = strings.ToLower(cp.Host) - cp.Path = strings.TrimRight(cp.Path, "/") - cp.Fragment = "" - cp.RawFragment = "" - return cp.String() - } - addEntries := func(entries []RegistryEntry) { for _, e := range entries { - u, err := url.Parse(e.URL) - if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + key, err := sourceidentity.Parse(sourceidentity.Registry, e.URL) + if err != nil { continue } - key := urlKey(u) if seen[key] { continue } seen[key] = true - result = append(result, Registry{Name: e.Name, BaseURL: u}) + baseURL, err := url.Parse(e.URL) + if err != nil { + continue + } + result = append(result, Registry{Name: e.Name, BaseURL: baseURL}) } } @@ -144,7 +122,10 @@ func mergeRegistries( if r.BaseURL == nil { continue } - key := urlKey(r.BaseURL) + key, err := sourceidentity.Parse(sourceidentity.Registry, r.BaseURL.String()) + if err != nil { + continue + } if seen[key] { continue } diff --git a/registry_config_test.go b/registry_config_test.go index cbc871e8..5c8bf03f 100644 --- a/registry_config_test.go +++ b/registry_config_test.go @@ -229,8 +229,8 @@ func TestMergeRegistries(t *testing.T) { wantFirstName: "project", }, { - name: "project URL collides with default URL — project wins, default URL dedup'd", - project: []RegistryEntry{{URL: "https://default-a.example.com", Name: "project-name"}}, + name: "project URL collides with default URL — project wins, default URL dedup'd", + project: []RegistryEntry{{URL: "https://default-a.example.com", Name: "project-name"}}, defaults: defaults, wantURLsInOrder: []string{ "https://default-a.example.com", @@ -259,9 +259,9 @@ func TestMergeRegistries(t *testing.T) { wantFirstName: "p", }, { - name: "project URL matches second default — project wins, only that default URL dedup'd", - project: []RegistryEntry{{URL: "https://default-b.example.com", Name: "project-shadows-b"}}, - global: []RegistryEntry{{URL: "https://glob.example.com"}}, + name: "project URL matches second default — project wins, only that default URL dedup'd", + project: []RegistryEntry{{URL: "https://default-b.example.com", Name: "project-shadows-b"}}, + global: []RegistryEntry{{URL: "https://glob.example.com"}}, defaults: defaults, wantURLsInOrder: []string{ "https://default-b.example.com", @@ -306,6 +306,30 @@ func TestMergeRegistries(t *testing.T) { "https://r.example.com/tenant-b", }, }, + { + name: "escaped separator and literal separator are distinct", + project: []RegistryEntry{ + {URL: "https://r.example.com/a%2Fb"}, + {URL: "https://r.example.com/a/b"}, + }, + projectReplace: boolPtr(true), + wantURLsInOrder: []string{ + "https://r.example.com/a%2Fb", + "https://r.example.com/a/b", + }, + }, + { + name: "empty force query and no query are distinct", + project: []RegistryEntry{ + {URL: "https://r.example.com?"}, + {URL: "https://r.example.com"}, + }, + projectReplace: boolPtr(true), + wantURLsInOrder: []string{ + "https://r.example.com?", + "https://r.example.com", + }, + }, { name: "casing + trailing slash dedupe", project: []RegistryEntry{ @@ -426,8 +450,8 @@ func TestNewClientWithRegistryOptions(t *testing.T) { // Only assert non-empty — defaults content can change. }, { - name: "global replace_defaults=true + no project entries → empty merge rejected", - opts: []Option{WithGlobalConfig(emptyReplaceAllGlobal)}, + name: "global replace_defaults=true + no project entries → empty merge rejected", + opts: []Option{WithGlobalConfig(emptyReplaceAllGlobal)}, wantErr: "empty registry list", }, { @@ -440,28 +464,28 @@ func TestNewClientWithRegistryOptions(t *testing.T) { wantCount: 1, }, { - name: "WithProjectRegistries rejects empty URL", - opts: []Option{WithProjectRegistries([]RegistryEntry{{URL: ""}}, nil)}, + name: "WithProjectRegistries rejects empty URL", + opts: []Option{WithProjectRegistries([]RegistryEntry{{URL: ""}}, nil)}, wantErr: "empty url", }, { - name: "WithProjectRegistries rejects non-http scheme", - opts: []Option{WithProjectRegistries([]RegistryEntry{{URL: "ftp://example.com"}}, nil)}, + name: "WithProjectRegistries rejects non-http scheme", + opts: []Option{WithProjectRegistries([]RegistryEntry{{URL: "ftp://example.com"}}, nil)}, wantErr: "scheme must be http or https", }, { - name: "WithProjectRegistries rejects truly-empty merge", - opts: []Option{WithProjectRegistries(nil, boolPtr(true))}, + name: "WithProjectRegistries rejects truly-empty merge", + opts: []Option{WithProjectRegistries(nil, boolPtr(true))}, wantErr: "empty registry list", }, { - name: "WithGlobalConfig rejects empty URL", - opts: []Option{WithGlobalConfig(&GlobalConfig{Registries: []RegistryEntry{{URL: ""}}})}, + name: "WithGlobalConfig rejects empty URL", + opts: []Option{WithGlobalConfig(&GlobalConfig{Registries: []RegistryEntry{{URL: ""}}})}, wantErr: "empty url", }, { - name: "WithGlobalConfig rejects non-http scheme", - opts: []Option{WithGlobalConfig(&GlobalConfig{Registries: []RegistryEntry{{URL: "ftp://example.com"}}})}, + name: "WithGlobalConfig rejects non-http scheme", + opts: []Option{WithGlobalConfig(&GlobalConfig{Registries: []RegistryEntry{{URL: "ftp://example.com"}}})}, wantErr: "scheme must be http or https", }, } diff --git a/source.go b/source.go new file mode 100644 index 00000000..b54dc830 --- /dev/null +++ b/source.go @@ -0,0 +1,82 @@ +// 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 dbc + +import ( + "errors" + "fmt" + + "github.com/columnar-tech/dbc/internal/sourceidentity" +) + +// DriverSourceType identifies the source declared for a driver in dbc.toml. +type DriverSourceType string + +const ( + DriverSourceRegistry DriverSourceType = "registry" + DriverSourcePackslip DriverSourceType = "packslip" + DriverSourcePath DriverSourceType = "path" +) + +// DriverSource is the source identity declared for one project driver. +// Source identity is kept separate from runtime DriverInfo.Source, which marks +// who owns the installed registration. +type DriverSource struct { + Type DriverSourceType `toml:"type" json:"type"` + URL string `toml:"url,omitempty" json:"url,omitempty"` + Project string `toml:"project,omitempty" json:"project,omitempty"` + Path string `toml:"path,omitempty" json:"path,omitempty"` +} + +// Validate checks that the source's type and source-specific field agree. +// Source references are validated by the shared identity package; adapters +// remain responsible for resolving registry URLs and paths. +func (s DriverSource) Validate() error { + switch s.Type { + case DriverSourceRegistry: + if s.URL == "" { + return errors.New("registry source has no URL") + } + if _, err := sourceidentity.Parse(sourceidentity.Registry, s.URL); err != nil { + return err + } + if s.Project != "" || s.Path != "" { + return errors.New("registry source contains fields for another source type") + } + case DriverSourcePackslip: + if s.Project == "" { + return errors.New("packslip source has no project") + } + if _, err := sourceidentity.Parse(sourceidentity.Packslip, s.Project); err != nil { + return err + } + if s.URL != "" || s.Path != "" { + return errors.New("packslip source contains fields for another source type") + } + case DriverSourcePath: + if _, err := sourceidentity.Parse(sourceidentity.Path, s.Path); err != nil { + return err + } + if s.URL != "" || s.Project != "" { + return errors.New("path source contains fields for another source type") + } + default: + if s.Type == "" { + return errors.New("driver source has no type") + } + return fmt.Errorf("unsupported driver source type %q", s.Type) + } + return nil +} diff --git a/source_test.go b/source_test.go new file mode 100644 index 00000000..e166464b --- /dev/null +++ b/source_test.go @@ -0,0 +1,61 @@ +// 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 dbc + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDriverSourceValidation(t *testing.T) { + tests := []struct { + name string + source DriverSource + wantErr string + }{ + {name: "registry", source: DriverSource{Type: DriverSourceRegistry, URL: "https://registry.example.test"}}, + {name: "packslip", source: DriverSource{Type: DriverSourcePackslip, Project: "github.com/owner/project"}}, + {name: "packslip monorepo tool", source: DriverSource{Type: DriverSourcePackslip, Project: "github.com/owner/project/tools/driver"}}, + {name: "path", source: DriverSource{Type: DriverSourcePath, Path: "../packages/driver.tar.gz"}}, + {name: "empty type", wantErr: "driver source has no type"}, + {name: "unknown type", source: DriverSource{Type: "other", URL: "https://example.test"}, wantErr: `unsupported driver source type "other"`}, + {name: "registry missing url", source: DriverSource{Type: DriverSourceRegistry}, wantErr: "registry source has no URL"}, + {name: "registry invalid URL", source: DriverSource{Type: DriverSourceRegistry, URL: "ftp://registry.example.test"}, wantErr: "scheme must be http or https"}, + {name: "registry URL requires a host", source: DriverSource{Type: DriverSourceRegistry, URL: "https:///registry"}, wantErr: "missing host"}, + {name: "registry with project", source: DriverSource{Type: DriverSourceRegistry, URL: "https://registry.example.test", Project: "owner/project"}, wantErr: "registry source contains fields for another source type"}, + {name: "packslip missing project", source: DriverSource{Type: DriverSourcePackslip}, wantErr: "packslip source has no project"}, + {name: "packslip with url", source: DriverSource{Type: DriverSourcePackslip, Project: "github.com/owner/project", URL: "https://registry.example.test"}, wantErr: "packslip source contains fields for another source type"}, + {name: "packslip shorthand project", source: DriverSource{Type: DriverSourcePackslip, Project: "owner/project"}, wantErr: "github.com/owner/repo"}, + {name: "packslip URL project", source: DriverSource{Type: DriverSourcePackslip, Project: "https://github.com/owner/project"}, wantErr: "without a URL scheme"}, + {name: "packslip trailing slash", source: DriverSource{Type: DriverSourcePackslip, Project: "github.com/owner/project/"}, wantErr: "without a URL scheme"}, + {name: "packslip empty segment", source: DriverSource{Type: DriverSourcePackslip, Project: "github.com/owner//project"}, wantErr: "invalid path segment"}, + {name: "packslip dot segment", source: DriverSource{Type: DriverSourcePackslip, Project: "github.com/owner/../project"}, wantErr: "invalid path segment"}, + {name: "packslip invalid character", source: DriverSource{Type: DriverSourcePackslip, Project: "github.com/owner/pro ject"}, wantErr: "invalid path segment"}, + {name: "path missing path", source: DriverSource{Type: DriverSourcePath}, wantErr: "path source has no path"}, + {name: "path with url", source: DriverSource{Type: DriverSourcePath, Path: "package.tar.gz", URL: "https://registry.example.test"}, wantErr: "path source contains fields for another source type"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.source.Validate() + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.wantErr) + }) + } +} diff --git a/testdata/registry-index-artifact-metadata.yaml b/testdata/registry-index-artifact-metadata.yaml new file mode 100644 index 00000000..b8e09eff --- /dev/null +++ b/testdata/registry-index-artifact-metadata.yaml @@ -0,0 +1,13 @@ +name: Example Registry +drivers: + - name: Example Driver + description: Synthetic registry fixture for archive metadata. + license: Apache-2.0 + path: example-driver + pkginfo: + - version: v1.2.3 + packages: + - platform: linux_amd64 + url: example-driver/1.2.3/example-driver-linux_amd64.tar.gz + hash: sha256:0000000000000000000000000000000000000000000000000000000000000000 + future_metadata: ignored