From 2488bf16c16ab714f4438875a72236f2ba34c696 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 01:34:33 +0000 Subject: [PATCH 001/115] feat: add cross-platform artifact resolution model --- client_methods.go | 3 + drivers.go | 50 +++--- internal/resolution/types.go | 149 ++++++++++++++++++ internal/resolution/types_test.go | 104 ++++++++++++ registry_artifact.go | 139 ++++++++++++++++ registry_artifact_test.go | 118 ++++++++++++++ .../registry-index-artifact-metadata.yaml | 13 ++ 7 files changed, 552 insertions(+), 24 deletions(-) create mode 100644 internal/resolution/types.go create mode 100644 internal/resolution/types_test.go create mode 100644 registry_artifact.go create mode 100644 registry_artifact_test.go create mode 100644 testdata/registry-index-artifact-metadata.yaml diff --git a/client_methods.go b/client_methods.go index 4169aa81..bf1920aa 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 { diff --git a/drivers.go b/drivers.go index 2bc7585b..717e35f9 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,15 @@ 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"` + Size yaml.Node `yaml:"size,omitempty"` } func (p pkginfo) GetPackage(d Driver, platformTuple string) (PkgInfo, error) { @@ -326,30 +335,26 @@ 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 } + artifact.URL = uri.String() return PkgInfo{ Driver: d, Version: p.Version, PlatformTuple: platformTuple, Path: uri, + ArtifactHash: artifact.Hash, + ArtifactSize: artifact.Size, }, nil } } @@ -395,10 +400,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 }) }) diff --git a/internal/resolution/types.go b/internal/resolution/types.go new file mode 100644 index 00000000..df5827fa --- /dev/null +++ b/internal/resolution/types.go @@ -0,0 +1,149 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package 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" + "strings" +) + +// Requirement describes the driver release and target requested by a caller. +type Requirement struct { + DriverID string + VersionConstraint string + Platform string +} + +// 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 +} + +// ResolvedRelease is a source-independent release snapshot. Artifacts may be a +// partial platform set; callers must not infer that omitted platforms exist. +type ResolvedRelease struct { + DriverID string + Version string + Source SourceSpec + Evidence Evidence + Artifacts []Artifact +} + +// Evidence records the immutable metadata used to resolve a source. It is +// separate from SourceSpec because evidence describes one resolution event, +// while source identity remains stable across resolutions. +type Evidence struct { + BundleURL string + BundleHash string + ReleaseListURL string + ReleaseListHash string +} + +// Artifact describes one downloadable archive. Hash and Size are optional so +// older registry entries and locally resolved partial sets remain representable. +type Artifact struct { + // Platform retains the registry tuple during migration to explicit selectors. + Platform string + OS string + Arch string + LibC string + Variant string + Format string + URL string + 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 +} + +// ValidateResolvedRelease checks the contract required before a release can +// become a lockfile snapshot. Registry candidates may omit hashes or sizes; +// only the snapshot boundary requires them to be finalized. +func ValidateResolvedRelease(release ResolvedRelease) error { + if len(release.Artifacts) == 0 { + return fmt.Errorf("resolved release must contain at least one artifact") + } + + // Source models do not yet assign stable artifact IDs, so the resolved URL + // is the identity used to reject duplicate records within one release. + seen := make(map[string]struct{}, len(release.Artifacts)) + for i, artifact := range release.Artifacts { + if artifact.URL == "" { + return fmt.Errorf("artifact %d has no resolved URL", i) + } + if _, ok := seen[artifact.URL]; ok { + return fmt.Errorf("resolved release contains duplicate artifact identity") + } + seen[artifact.URL] = struct{}{} + + if artifact.Hash == "" { + return fmt.Errorf("artifact %d has no finalized hash", i) + } + if artifact.Size == nil { + return fmt.Errorf("artifact %d has no finalized size", i) + } + if err := ValidateArtifactMetadata(artifact.Hash, artifact.Size); err != nil { + return fmt.Errorf("artifact %d has invalid metadata: %w", i, err) + } + } + return nil +} diff --git a/internal/resolution/types_test.go b/internal/resolution/types_test.go new file mode 100644 index 00000000..3f6c6ae3 --- /dev/null +++ b/internal/resolution/types_test.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 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{ + BundleURL: "https://example.test/release.sigstore.json", + BundleHash: "sha256:" + strings.Repeat("a", 64), + }, + Artifacts: []Artifact{{ + Platform: "linux_amd64", + OS: "linux", + Arch: "amd64", + LibC: "gnu", + Format: "tar.gz", + URL: "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.BundleURL) + assert.Equal(t, "2.17", complete.Artifacts[0].HostRequirements.GLibCMin) +} + +func TestValidateResolvedReleaseRequiresFinalizedArtifacts(t *testing.T) { + size := int64(12) + base := ResolvedRelease{Artifacts: []Artifact{{ + URL: "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: "no finalized hash"}, + {name: "missing size", mutate: func(r *ResolvedRelease) { r.Artifacts[0].Size = nil }, wantErr: "no finalized size"}, + {name: "missing URL", mutate: func(r *ResolvedRelease) { r.Artifacts[0].URL = "" }, wantErr: "no resolved URL"}, + {name: "duplicate artifact URL", mutate: func(r *ResolvedRelease) { + duplicate := r.Artifacts[0] + duplicate.Platform = "macos_arm64" + r.Artifacts = append(r.Artifacts, duplicate) + }, wantErr: "duplicate artifact identity"}, + } + + 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 TestValidateResolvedReleaseKeepsRegistryCandidateWithoutHashValid(t *testing.T) { + size := int64(12) + candidate := ResolvedRelease{Artifacts: []Artifact{{ + URL: "https://registry.example.test/driver.tar.gz", + Size: &size, + }}} + + assert.ErrorContains(t, ValidateResolvedRelease(candidate), "no finalized hash") + assert.NoError(t, ValidateArtifactMetadata("", nil)) +} diff --git a/registry_artifact.go b/registry_artifact.go new file mode 100644 index 00000000..df29f4f6 --- /dev/null +++ b/registry_artifact.go @@ -0,0 +1,139 @@ +// 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" + + "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 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) + } + + 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() { + uri = d.Registry.BaseURL.JoinPath(pkg.URL) + } + return uri, nil + } + + 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 d.Registry == nil || d.Registry.BaseURL == nil { + return resolution.ResolvedRelease{}, fmt.Errorf("cannot resolve release for %s: driver has no registry URL", d.Title) + } + if p.Version == nil { + return resolution.ResolvedRelease{}, fmt.Errorf("cannot resolve release for %s: release has no version", d.Title) + } + + release := resolution.ResolvedRelease{ + DriverID: d.Path, + Version: p.Version.String(), + Source: resolution.SourceSpec{ + Type: "registry", + Reference: d.Registry.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.URL = uri.String() + 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") + } + + var size *int64 + if p.Size.Kind != 0 { + if p.Size.Kind != yaml.ScalarNode || p.Size.Tag != "!!int" { + return resolution.Artifact{}, fmt.Errorf("artifact size must be an integer") + } + var value int64 + if err := p.Size.Decode(&value); err != nil { + return resolution.Artifact{}, fmt.Errorf("invalid artifact size: %w", err) + } + size = &value + } + + if err := resolution.ValidateArtifactMetadata(hash, size); err != nil { + return resolution.Artifact{}, err + } + return resolution.Artifact{ + Platform: p.PlatformTuple, + URL: p.URL, + Hash: hash, + Size: size, + }, 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..5eb9a488 --- /dev/null +++ b/registry_artifact_test.go @@ -0,0 +1,118 @@ +// 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/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) + require.NotNil(t, pkg.ArtifactSize) + assert.EqualValues(t, 12345, *pkg.ArtifactSize) + + // 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\nsize: 0\n") + artifact, err = pkg.resolveArtifact() + require.NoError(t, err) + require.NotNil(t, artifact.Size) + assert.Zero(t, *artifact.Size) +} + +func TestResolvedRegistryReleaseFillsURLsForEveryPlatform(t *testing.T) { + var release pkginfo + require.NoError(t, yaml.NewDecoder(strings.NewReader(` +version: v1.2.3 +packages: + - platform: linux_amd64 + - platform: windows_amd64 +`)).Decode(&release)) + 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, 2) + assert.Equal(t, "https://registry.example.test/example-driver/1.2.3/example-driver_linux_amd64-1.2.3.tar.gz", resolved.Artifacts[0].URL) + assert.Equal(t, "https://registry.example.test/example-driver/1.2.3/example-driver_windows_amd64-1.2.3.tar.gz", resolved.Artifacts[1].URL) +} + +func TestRegistryArtifactMetadataRejectsInvalidValues(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"}, + {name: "non-integer size", metadata: "size: 1.5\n", want: "size must be an integer"}, + {name: "string size", metadata: "size: \"123\"\n", want: "size must be an integer"}, + {name: "null size", metadata: "size: null\n", want: "size must be an integer"}, + {name: "negative size", metadata: "size: -1\n", want: "must not be negative"}, + } + + 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 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/testdata/registry-index-artifact-metadata.yaml b/testdata/registry-index-artifact-metadata.yaml new file mode 100644 index 00000000..e43d47e7 --- /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 + size: 12345 From 6da00bcb5f7070999f86f485873c04a1bbda3c67 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 01:41:45 +0000 Subject: [PATCH 002/115] fix: preserve absolute registry package URLs --- registry_artifact.go | 23 +++++++++++++---------- registry_artifact_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/registry_artifact.go b/registry_artifact.go index df29f4f6..93935e3e 100644 --- a/registry_artifact.go +++ b/registry_artifact.go @@ -24,22 +24,25 @@ import ( ) func resolveRegistryPackageURL(d Driver, version *semver.Version, pkg registryPackage) (*url.URL, error) { - 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) - } - 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() { - uri = d.Registry.BaseURL.JoinPath(pkg.URL) + 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 uri, nil + 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 diff --git a/registry_artifact_test.go b/registry_artifact_test.go index 5eb9a488..ac7beb97 100644 --- a/registry_artifact_test.go +++ b/registry_artifact_test.go @@ -19,6 +19,7 @@ import ( "strings" "testing" + "github.com/Masterminds/semver/v3" "github.com/go-faster/yaml" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -82,6 +83,32 @@ packages: assert.Equal(t, "https://registry.example.test/example-driver/1.2.3/example-driver_windows_amd64-1.2.3.tar.gz", resolved.Artifacts[1].URL) } +func TestResolveRegistryPackageURLWithoutBaseURL(t *testing.T) { + driver := Driver{Title: "Example Driver", Registry: &Registry{}} + + t.Run("absolute URL", func(t *testing.T) { + uri, err := resolveRegistryPackageURL(driver, nil, registryPackage{ + URL: "https://packages.example.test/driver.tar.gz", + }) + require.NoError(t, err) + assert.Equal(t, "https://packages.example.test/driver.tar.gz", uri.String()) + }) + + t.Run("relative URL", func(t *testing.T) { + _, err := resolveRegistryPackageURL(driver, semver.MustParse("1.2.3"), registryPackage{ + URL: "driver.tar.gz", + }) + require.ErrorContains(t, err, "no registry URL") + }) + + t.Run("implicit URL", func(t *testing.T) { + _, err := resolveRegistryPackageURL(driver, semver.MustParse("1.2.3"), registryPackage{ + PlatformTuple: "linux_amd64", + }) + require.ErrorContains(t, err, "no registry URL") + }) +} + func TestRegistryArtifactMetadataRejectsInvalidValues(t *testing.T) { tests := []struct { name string From 321d3e48202eb9ce844679d7df25121f27c716de Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 01:44:55 +0000 Subject: [PATCH 003/115] fix: resolve absolute registry releases without a base URL --- registry_artifact.go | 9 +++++---- registry_artifact_test.go | 42 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/registry_artifact.go b/registry_artifact.go index 93935e3e..992bb550 100644 --- a/registry_artifact.go +++ b/registry_artifact.go @@ -54,19 +54,20 @@ func resolveRegistryPackageURL(d Driver, version *semver.Version, pkg registryPa // 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 d.Registry == nil || d.Registry.BaseURL == nil { - return resolution.ResolvedRelease{}, fmt.Errorf("cannot resolve release for %s: driver has no registry URL", d.Title) - } if p.Version == nil { return resolution.ResolvedRelease{}, fmt.Errorf("cannot resolve release for %s: release has no version", d.Title) } + var sourceReference string + if d.Registry != nil && d.Registry.BaseURL != nil { + sourceReference = d.Registry.BaseURL.String() + } release := resolution.ResolvedRelease{ DriverID: d.Path, Version: p.Version.String(), Source: resolution.SourceSpec{ Type: "registry", - Reference: d.Registry.BaseURL.String(), + Reference: sourceReference, }, Artifacts: make([]resolution.Artifact, 0, len(p.Packages)), } diff --git a/registry_artifact_test.go b/registry_artifact_test.go index ac7beb97..fe604475 100644 --- a/registry_artifact_test.go +++ b/registry_artifact_test.go @@ -83,6 +83,48 @@ packages: assert.Equal(t, "https://registry.example.test/example-driver/1.2.3/example-driver_windows_amd64-1.2.3.tar.gz", resolved.Artifacts[1].URL) } +func TestResolvedRegistryReleaseAllowsAbsoluteURLsWithoutRegistryBase(t *testing.T) { + for _, tt := range []struct { + name string + registry *Registry + }{ + {name: "no registry"}, + {name: "registry without base URL", registry: &Registry{}}, + } { + t.Run(tt.name, func(t *testing.T) { + release := pkginfo{ + Version: semver.MustParse("1.2.3"), + Packages: []registryPackage{ + {PlatformTuple: "linux_amd64", URL: "https://packages.example.test/linux.tar.gz"}, + {PlatformTuple: "windows_amd64", URL: "https://packages.example.test/windows.tar.gz"}, + }, + } + driver := Driver{Path: "example-driver", Title: "Example Driver", Registry: tt.registry} + + resolved, err := release.resolvedRelease(driver) + require.NoError(t, err) + require.Len(t, resolved.Artifacts, 2) + assert.Equal(t, "https://packages.example.test/linux.tar.gz", resolved.Artifacts[0].URL) + assert.Equal(t, "https://packages.example.test/windows.tar.gz", resolved.Artifacts[1].URL) + assert.Empty(t, resolved.Source.Reference) + }) + } +} + +func TestResolvedRegistryReleaseMixedURLsRequireRegistryBase(t *testing.T) { + release := pkginfo{ + Version: semver.MustParse("1.2.3"), + Packages: []registryPackage{ + {PlatformTuple: "linux_amd64", URL: "https://packages.example.test/linux.tar.gz"}, + {PlatformTuple: "windows_amd64", URL: "windows.tar.gz"}, + }, + } + driver := Driver{Path: "example-driver", Title: "Example Driver", Registry: &Registry{}} + + _, err := release.resolvedRelease(driver) + require.ErrorContains(t, err, "no registry URL") +} + func TestResolveRegistryPackageURLWithoutBaseURL(t *testing.T) { driver := Driver{Title: "Example Driver", Registry: &Registry{}} From 872fddb6e13c91fd25d39c6c91fd899340bd8709 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 01:53:13 +0000 Subject: [PATCH 004/115] fix: require registry identity for resolved releases --- registry_artifact.go | 19 +++- registry_artifact_test.go | 187 ++++++++++++++++++++++++-------------- 2 files changed, 134 insertions(+), 72 deletions(-) diff --git a/registry_artifact.go b/registry_artifact.go index 992bb550..212c90d8 100644 --- a/registry_artifact.go +++ b/registry_artifact.go @@ -17,6 +17,7 @@ package dbc import ( "fmt" "net/url" + "strings" "github.com/Masterminds/semver/v3" "github.com/columnar-tech/dbc/internal/resolution" @@ -57,17 +58,25 @@ 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) } - - var sourceReference string - if d.Registry != nil && d.Registry.BaseURL != nil { - sourceReference = d.Registry.BaseURL.String() + 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: sourceReference, + Reference: baseURL.String(), }, Artifacts: make([]resolution.Artifact, 0, len(p.Packages)), } diff --git a/registry_artifact_test.go b/registry_artifact_test.go index fe604475..64daad84 100644 --- a/registry_artifact_test.go +++ b/registry_artifact_test.go @@ -63,92 +63,145 @@ func TestRegistryArtifactMetadataOptional(t *testing.T) { assert.Zero(t, *artifact.Size) } -func TestResolvedRegistryReleaseFillsURLsForEveryPlatform(t *testing.T) { - var release pkginfo - require.NoError(t, yaml.NewDecoder(strings.NewReader(` -version: v1.2.3 -packages: - - platform: linux_amd64 - - platform: windows_amd64 -`)).Decode(&release)) - driver := Driver{ - Path: "example-driver", - Registry: &Registry{BaseURL: mustParseURL("https://registry.example.test")}, +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")}, } - resolved, err := release.resolvedRelease(driver) - require.NoError(t, err) - require.Len(t, resolved.Artifacts, 2) - assert.Equal(t, "https://registry.example.test/example-driver/1.2.3/example-driver_linux_amd64-1.2.3.tar.gz", resolved.Artifacts[0].URL) - assert.Equal(t, "https://registry.example.test/example-driver/1.2.3/example-driver_windows_amd64-1.2.3.tar.gz", resolved.Artifacts[1].URL) + 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 TestResolvedRegistryReleaseAllowsAbsoluteURLsWithoutRegistryBase(t *testing.T) { - for _, tt := range []struct { - name string - registry *Registry +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: "no registry"}, - {name: "registry without base URL", registry: &Registry{}}, - } { + {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 := pkginfo{ - Version: semver.MustParse("1.2.3"), - Packages: []registryPackage{ - {PlatformTuple: "linux_amd64", URL: "https://packages.example.test/linux.tar.gz"}, - {PlatformTuple: "windows_amd64", URL: "https://packages.example.test/windows.tar.gz"}, - }, + release := absoluteRelease + if tt.missingVersion { + release.Version = nil } - driver := Driver{Path: "example-driver", Title: "Example Driver", Registry: tt.registry} - - resolved, err := release.resolvedRelease(driver) - require.NoError(t, err) - require.Len(t, resolved.Artifacts, 2) - assert.Equal(t, "https://packages.example.test/linux.tar.gz", resolved.Artifacts[0].URL) - assert.Equal(t, "https://packages.example.test/windows.tar.gz", resolved.Artifacts[1].URL) - assert.Empty(t, resolved.Source.Reference) + _, err := release.resolvedRelease(tt.driver) + require.ErrorContains(t, err, tt.wantErr) }) } } -func TestResolvedRegistryReleaseMixedURLsRequireRegistryBase(t *testing.T) { +func TestResolvedRegistryReleaseKeepsSourceAndArtifactHostsSeparate(t *testing.T) { release := pkginfo{ Version: semver.MustParse("1.2.3"), - Packages: []registryPackage{ - {PlatformTuple: "linux_amd64", URL: "https://packages.example.test/linux.tar.gz"}, - {PlatformTuple: "windows_amd64", URL: "windows.tar.gz"}, - }, + Packages: []registryPackage{{ + PlatformTuple: "linux_amd64", + URL: "https://packages.example.test/driver.tar.gz", + }}, } - driver := Driver{Path: "example-driver", Title: "Example Driver", Registry: &Registry{}} + driver := Driver{Path: "example-driver", Registry: &Registry{BaseURL: mustParseURL("https://registry.example.test")}} - _, err := release.resolvedRelease(driver) - require.ErrorContains(t, err, "no registry URL") + 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, "https://packages.example.test/driver.tar.gz", resolved.Artifacts[0].URL) } -func TestResolveRegistryPackageURLWithoutBaseURL(t *testing.T) { - driver := Driver{Title: "Example Driver", Registry: &Registry{}} - - t.Run("absolute URL", func(t *testing.T) { - uri, err := resolveRegistryPackageURL(driver, nil, registryPackage{ - URL: "https://packages.example.test/driver.tar.gz", - }) - require.NoError(t, err) - assert.Equal(t, "https://packages.example.test/driver.tar.gz", uri.String()) - }) +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) +} - t.Run("relative URL", func(t *testing.T) { - _, err := resolveRegistryPackageURL(driver, semver.MustParse("1.2.3"), registryPackage{ - URL: "driver.tar.gz", - }) - require.ErrorContains(t, err, "no registry URL") - }) +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) - t.Run("implicit URL", func(t *testing.T) { - _, err := resolveRegistryPackageURL(driver, semver.MustParse("1.2.3"), registryPackage{ - PlatformTuple: "linux_amd64", - }) - require.ErrorContains(t, err, "no registry URL") - }) + for _, artifact := range resolved.Artifacts { + pkg, err := driver.GetPackage(version, artifact.Platform, false) + require.NoError(t, err) + require.NotNil(t, pkg.Path) + assert.Equal(t, pkg.Path.String(), artifact.URL) + } } func TestRegistryArtifactMetadataRejectsInvalidValues(t *testing.T) { From c9bf51785a622ddba5971fa1927d434f99f14dda Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 01:59:10 +0000 Subject: [PATCH 005/115] feat: add native Sigstore verification boundary --- go.mod | 68 +++- go.sum | 349 +++++++++++++++++- .../testdata/bundle-provenance.json | 1 + .../testdata/trusted-root-public-good.json | 114 ++++++ internal/packslipverify/verifier.go | 83 +++++ internal/packslipverify/verifier_js.go | 35 ++ internal/packslipverify/verifier_native.go | 203 ++++++++++ .../packslipverify/verifier_native_test.go | 115 ++++++ 8 files changed, 963 insertions(+), 5 deletions(-) create mode 100644 internal/packslipverify/testdata/bundle-provenance.json create mode 100644 internal/packslipverify/testdata/trusted-root-public-good.json create mode 100644 internal/packslipverify/verifier.go create mode 100644 internal/packslipverify/verifier_js.go create mode 100644 internal/packslipverify/verifier_native.go create mode 100644 internal/packslipverify/verifier_native_test.go diff --git a/go.mod b/go.mod index 766324a6..8f610723 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.47.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 95daa52b..c46b7ec8 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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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/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..3cc095ee --- /dev/null +++ b/internal/packslipverify/verifier.go @@ -0,0 +1,83 @@ +// 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. A +// TrustedRootJSON value is useful for offline verification and tests; when it +// is present, TUF is not contacted. +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..817370d9 --- /dev/null +++ b/internal/packslipverify/verifier_native.go @@ -0,0 +1,203 @@ +// 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" + "regexp" + "strings" + "sync" + + "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/verify" + "github.com/theupdateframework/go-tuf/v2/metadata/fetcher" +) + +type nativeVerifier struct { + config Config + + rootMu sync.Mutex + root *root.TrustedRoot +} + +// New returns a native Sigstore bundle verifier. +func New(config Config) (PackslipVerifier, error) { + config.TrustedRootJSON = append([]byte(nil), config.TrustedRootJSON...) + return &nativeVerifier{config: config}, 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 +} + +func (v *nativeVerifier) trustedRoot(ctx context.Context) (*root.TrustedRoot, error) { + v.rootMu.Lock() + defer v.rootMu.Unlock() + if v.root != nil { + return v.root, nil + } + if err := ctx.Err(); err != nil { + return nil, err + } + + var trustedRoot *root.TrustedRoot + var err error + if len(v.config.TrustedRootJSON) > 0 { + trustedRoot, err = root.NewTrustedRootFromJSON(v.config.TrustedRootJSON) + } else { + opts := sigtuf.DefaultOptions() + if v.config.CachePath != "" { + opts.CachePath = v.config.CachePath + } + opts.DisableLocalCache = v.config.DisableLocalCache + opts.Context = ctx + if v.config.HTTPClient != nil { + metadataFetcher := fetcher.NewDefaultFetcher() + metadataFetcher.SetHTTPClient(v.config.HTTPClient) + opts.Fetcher = metadataFetcher + } + trustedRoot, err = root.FetchTrustedRootWithOptions(opts) + } + if err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + v.root = trustedRoot + return v.root, nil +} + +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..b614f358 --- /dev/null +++ b/internal/packslipverify/verifier_native_test.go @@ -0,0 +1,115 @@ +// 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" + "strings" + "testing" +) + +//go:embed testdata/*.json +var bundleFixture embed.FS + +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 +} + +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) + } +} From a6f4fe36872586cd217a956430383e9e2d0ec55f Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 02:11:05 +0000 Subject: [PATCH 006/115] fix: honor cancellation while loading trust roots --- internal/packslipverify/verifier_native.go | 87 +++++++++- .../packslipverify/verifier_native_test.go | 163 ++++++++++++++++++ 2 files changed, 243 insertions(+), 7 deletions(-) diff --git a/internal/packslipverify/verifier_native.go b/internal/packslipverify/verifier_native.go index 817370d9..87303acd 100644 --- a/internal/packslipverify/verifier_native.go +++ b/internal/packslipverify/verifier_native.go @@ -36,10 +36,21 @@ import ( type nativeVerifier struct { config Config - rootMu sync.Mutex - root *root.TrustedRoot + rootMu sync.Mutex + root *root.TrustedRoot + rootLoad *trustedRootLoad + rootLoader trustedRootLoader // Optional loader seam for native tests. + rootWaitHook func() // Optional synchronization hook for native tests. } +type trustedRootLoad struct { + done chan struct{} + root *root.TrustedRoot + err error +} + +type trustedRootLoader func(context.Context) (*root.TrustedRoot, error) + // New returns a native Sigstore bundle verifier. func New(config Config) (PackslipVerifier, error) { config.TrustedRootJSON = append([]byte(nil), config.TrustedRootJSON...) @@ -124,16 +135,79 @@ func (v *nativeVerifier) Verify(ctx context.Context, bundleJSON []byte, identity return verified, nil } +// trustedRoot shares each in-flight load with its current waiters. A successful +// result is cached; a failed attempt is shared with its waiters and then +// discarded so a later caller can retry. func (v *nativeVerifier) trustedRoot(ctx context.Context) (*root.TrustedRoot, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + v.rootMu.Lock() - defer v.rootMu.Unlock() + if err := ctx.Err(); err != nil { + v.rootMu.Unlock() + return nil, err + } if v.root != nil { - return v.root, nil + trustedRoot := v.root + v.rootMu.Unlock() + return trustedRoot, nil + } + if currentLoad := v.rootLoad; currentLoad != nil { + v.rootMu.Unlock() + if v.rootWaitHook != nil { + v.rootWaitHook() + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-currentLoad.done: + if err := ctx.Err(); err != nil { + return nil, err + } + return currentLoad.root, currentLoad.err + } } + + currentLoad := &trustedRootLoad{done: make(chan struct{})} + v.rootLoad = currentLoad + loader := v.rootLoader + v.rootMu.Unlock() + + if loader == nil { + loader = v.loadTrustedRoot + } + trustedRoot, err := loader(ctx) + if err == nil { + switch { + case trustedRoot == nil: + err = errors.New("trusted root loader returned no root") + case ctx.Err() != nil: + trustedRoot = nil + err = ctx.Err() + } + } + + v.rootMu.Lock() + currentLoad.root = trustedRoot + currentLoad.err = err + if err == nil { + v.root = trustedRoot + } + if v.rootLoad == currentLoad { + // Failed attempts are shared with their current waiters, then forgotten + // so a later Verify call can retry transient TUF/network failures. + v.rootLoad = nil + } + close(currentLoad.done) + v.rootMu.Unlock() + return trustedRoot, err +} + +func (v *nativeVerifier) loadTrustedRoot(ctx context.Context) (*root.TrustedRoot, error) { if err := ctx.Err(); err != nil { return nil, err } - var trustedRoot *root.TrustedRoot var err error if len(v.config.TrustedRootJSON) > 0 { @@ -158,8 +232,7 @@ func (v *nativeVerifier) trustedRoot(ctx context.Context) (*root.TrustedRoot, er if err := ctx.Err(); err != nil { return nil, err } - v.root = trustedRoot - return v.root, nil + return trustedRoot, nil } func validateIdentity(identity IdentityPolicy) error { diff --git a/internal/packslipverify/verifier_native_test.go b/internal/packslipverify/verifier_native_test.go index b614f358..41c44f1d 100644 --- a/internal/packslipverify/verifier_native_test.go +++ b/internal/packslipverify/verifier_native_test.go @@ -22,13 +22,32 @@ package packslipverify import ( "context" "embed" + "errors" "strings" + "sync" + "sync/atomic" "testing" + "time" + + "github.com/sigstore/sigstore-go/pkg/root" ) //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 newFixtureVerifier(t *testing.T) PackslipVerifier { t.Helper() trustedRoot, err := bundleFixture.ReadFile("testdata/trusted-root-public-good.json") @@ -42,6 +61,150 @@ func newFixtureVerifier(t *testing.T) PackslipVerifier { return verifier } +func TestTrustedRootWaitHonorsCallerCancellationAndReusesSuccess(t *testing.T) { + trustedRoot := readFixtureTrustedRoot(t) + loaderStarted := make(chan struct{}) + allowLoadToFinish := make(chan struct{}) + waiterStarted := make(chan struct{}) + var releaseOnce sync.Once + defer releaseOnce.Do(func() { close(allowLoadToFinish) }) + var calls atomic.Int32 + + verifier := &nativeVerifier{ + rootLoader: func(ctx context.Context) (*root.TrustedRoot, error) { + calls.Add(1) + close(loaderStarted) + select { + case <-allowLoadToFinish: + return trustedRoot, nil + case <-ctx.Done(): + return nil, ctx.Err() + } + }, + rootWaitHook: func() { close(waiterStarted) }, + } + type rootResult struct { + root *root.TrustedRoot + err error + } + firstResult := make(chan rootResult, 1) + go func() { + got, err := verifier.trustedRoot(context.Background()) + firstResult <- rootResult{root: got, err: err} + }() + select { + case <-loaderStarted: + case <-time.After(time.Second): + t.Fatal("root loader did not start") + } + + waiterCtx, cancelWaiter := context.WithCancel(context.Background()) + defer cancelWaiter() + waiterResult := make(chan rootResult, 1) + go func() { + got, err := verifier.trustedRoot(waiterCtx) + waiterResult <- rootResult{root: got, err: err} + }() + select { + case <-waiterStarted: + case <-time.After(time.Second): + t.Fatal("second caller did not join the in-flight root load") + } + + cancelWaiter() + select { + case result := <-waiterResult: + if !errors.Is(result.err, context.Canceled) { + t.Fatalf("second caller error = %v, want context cancellation", result.err) + } + case <-time.After(time.Second): + t.Fatal("second caller did not return promptly after cancellation") + } + + releaseOnce.Do(func() { close(allowLoadToFinish) }) + first := <-firstResult + if first.err != nil || first.root != trustedRoot { + t.Fatalf("first caller got root %p, error %v; want shared root %p", first.root, first.err, trustedRoot) + } + reused, err := verifier.trustedRoot(context.Background()) + if err != nil || reused != trustedRoot { + t.Fatalf("cached root = %p, error %v; want shared root %p", reused, err, trustedRoot) + } + if got := calls.Load(); got != 1 { + t.Fatalf("root loader calls = %d, want 1", got) + } +} + +func TestTrustedRootFailureIsSharedThenRetried(t *testing.T) { + trustedRoot := readFixtureTrustedRoot(t) + temporaryFailure := errors.New("temporary trust root fetch failure") + loaderStarted := make(chan struct{}) + allowFailure := make(chan struct{}) + waiterStarted := make(chan struct{}) + var releaseOnce sync.Once + defer releaseOnce.Do(func() { close(allowFailure) }) + var calls atomic.Int32 + + verifier := &nativeVerifier{ + rootLoader: func(context.Context) (*root.TrustedRoot, error) { + if calls.Add(1) == 1 { + close(loaderStarted) + <-allowFailure + return nil, temporaryFailure + } + return trustedRoot, nil + }, + rootWaitHook: func() { close(waiterStarted) }, + } + type rootResult struct { + root *root.TrustedRoot + err error + } + firstResult := make(chan rootResult, 1) + go func() { + got, err := verifier.trustedRoot(context.Background()) + firstResult <- rootResult{root: got, err: err} + }() + select { + case <-loaderStarted: + case <-time.After(time.Second): + t.Fatal("root loader did not start") + } + waiterResult := make(chan rootResult, 1) + go func() { + got, err := verifier.trustedRoot(context.Background()) + waiterResult <- rootResult{root: got, err: err} + }() + select { + case <-waiterStarted: + case <-time.After(time.Second): + t.Fatal("second caller did not join the in-flight root load") + } + + releaseOnce.Do(func() { close(allowFailure) }) + for name, resultChannel := range map[string]<-chan rootResult{ + "initializer": firstResult, + "waiter": waiterResult, + } { + select { + case result := <-resultChannel: + if result.root != nil || !errors.Is(result.err, temporaryFailure) { + t.Errorf("%s got root %p, error %v; want the shared initialization error", name, result.root, result.err) + } + case <-time.After(time.Second): + t.Errorf("%s did not receive the initialization failure", name) + } + } + + retried, err := verifier.trustedRoot(context.Background()) + if err != nil || retried != trustedRoot { + t.Fatalf("retry got root %p, error %v; want root %p", retried, err, trustedRoot) + } + if got := calls.Load(); got != 2 { + t.Fatalf("root loader calls = %d, want 2 after one retry", got) + } +} + const ( fixtureIssuer = "https://token.actions.githubusercontent.com" fixtureSubject = `^https://github\.com/sigstore/sigstore-js/\.github/workflows/release\.yml@refs/heads/main$` From 2ba4715c21d2bb6e1fd83e191c3eb1a6d19ab8e1 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 02:31:37 +0000 Subject: [PATCH 007/115] feat: add context-aware file locking --- internal/fslock/fslock.go | 46 +++++++++++ internal/fslock/fslock_test.go | 125 ++++++++++++++++++++++++++++++ internal/fslock/fslock_unix.go | 44 ++++++----- internal/fslock/fslock_windows.go | 22 +++--- 4 files changed, 210 insertions(+), 27 deletions(-) diff --git a/internal/fslock/fslock.go b/internal/fslock/fslock.go index 4684a6bb..a12ecfdd 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,46 @@ 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: %w", + path, timeout, 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) +} + +// 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..e739ed12 100644 --- a/internal/fslock/fslock_test.go +++ b/internal/fslock/fslock_test.go @@ -15,6 +15,7 @@ package fslock_test import ( + "context" "errors" "os" "path/filepath" @@ -123,6 +124,130 @@ func TestAcquireTimeout(t *testing.T) { } } +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 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) + } +} + +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) { // Simulate a lock failure that's due to permissions instead of actual lock // contention by creating a read-only file and later trying to lock on it diff --git a/internal/fslock/fslock_unix.go b/internal/fslock/fslock_unix.go index 5c089841..255b25ed 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,16 @@ 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 for { + if !(firstAttempt && allowInitialAttempt) { + if err := ctx.Err(); err != nil { + return Lock{}, err + } + } + 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 +83,9 @@ 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) + if err := waitForRetry(ctx); err != nil { + return Lock{}, err } - time.Sleep(50 * time.Millisecond) } } diff --git a/internal/fslock/fslock_windows.go b/internal/fslock/fslock_windows.go index d8d504dd..ee99741c 100644 --- a/internal/fslock/fslock_windows.go +++ b/internal/fslock/fslock_windows.go @@ -17,37 +17,41 @@ 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 for { + if !(firstAttempt && allowInitialAttempt) { + if err := ctx.Err(); err != nil { + f.Close() + return Lock{}, err + } + } + 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) { + 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{}, err } - time.Sleep(50 * time.Millisecond) } } From 3c731e2154f86ec87d2d9dd6d6edb0a99e0f27fd Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 02:37:16 +0000 Subject: [PATCH 008/115] fix: retain file lock timeout diagnostics --- internal/fslock/fslock.go | 14 ++++++++++++-- internal/fslock/fslock_test.go | 10 ++++++++++ internal/fslock/fslock_unix.go | 6 ++++-- internal/fslock/fslock_windows.go | 6 ++++-- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/internal/fslock/fslock.go b/internal/fslock/fslock.go index a12ecfdd..7158c152 100644 --- a/internal/fslock/fslock.go +++ b/internal/fslock/fslock.go @@ -45,8 +45,8 @@ func Acquire(path string, timeout time.Duration) (Lock, error) { // 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: %w", - path, timeout, ErrLockContended) + return Lock{}, fmt.Errorf("fslock: could not acquire lock on %s within %s (%v): %w", + path, timeout, err, ErrLockContended) } return lock, err } @@ -61,6 +61,16 @@ func AcquireContext(ctx context.Context, path string) (Lock, error) { 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 { diff --git a/internal/fslock/fslock_test.go b/internal/fslock/fslock_test.go index e739ed12..648fe6f4 100644 --- a/internal/fslock/fslock_test.go +++ b/internal/fslock/fslock_test.go @@ -19,6 +19,7 @@ import ( "errors" "os" "path/filepath" + "strings" "sync" "testing" "time" @@ -122,6 +123,9 @@ 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) { @@ -147,6 +151,9 @@ func TestAcquireContextCanceledWhileContended(t *testing.T) { 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) } @@ -208,6 +215,9 @@ func TestAcquireContextDeadlineWhileContended(t *testing.T) { 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) { diff --git a/internal/fslock/fslock_unix.go b/internal/fslock/fslock_unix.go index 255b25ed..441af03d 100644 --- a/internal/fslock/fslock_unix.go +++ b/internal/fslock/fslock_unix.go @@ -61,10 +61,11 @@ var errStaleInode = errors.New("fslock: stale inode") 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{}, err + return Lock{}, retryContextError(path, err, lastLockErr) } } firstAttempt = false @@ -83,8 +84,9 @@ func lockFile(ctx context.Context, f *os.File, path string, allowInitialAttempt } return Lock{f: f, path: path}, nil } + lastLockErr = err if err := waitForRetry(ctx); err != nil { - return Lock{}, err + return Lock{}, retryContextError(path, err, lastLockErr) } } } diff --git a/internal/fslock/fslock_windows.go b/internal/fslock/fslock_windows.go index ee99741c..925c3189 100644 --- a/internal/fslock/fslock_windows.go +++ b/internal/fslock/fslock_windows.go @@ -33,11 +33,12 @@ func acquireContext(ctx context.Context, path string, allowInitialAttempt bool) ol := new(windows.Overlapped) firstAttempt := true + var lastLockErr error for { if !(firstAttempt && allowInitialAttempt) { if err := ctx.Err(); err != nil { f.Close() - return Lock{}, err + return Lock{}, retryContextError(path, err, lastLockErr) } } firstAttempt = false @@ -48,9 +49,10 @@ func acquireContext(ctx context.Context, path string, allowInitialAttempt bool) if err == nil { return Lock{f: f, path: path}, nil } + lastLockErr = err if err := waitForRetry(ctx); err != nil { f.Close() - return Lock{}, err + return Lock{}, retryContextError(path, err, lastLockErr) } } } From 3124acde3e04253ffa1020da603c338d3c18d4d0 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 02:40:18 +0000 Subject: [PATCH 009/115] fix: isolate Sigstore trust root loading --- internal/packslipverify/verifier.go | 7 +- internal/packslipverify/verifier_native.go | 220 ++++++----- .../packslipverify/verifier_native_test.go | 342 +++++++++++++----- 3 files changed, 380 insertions(+), 189 deletions(-) diff --git a/internal/packslipverify/verifier.go b/internal/packslipverify/verifier.go index 3cc095ee..8e8dad0a 100644 --- a/internal/packslipverify/verifier.go +++ b/internal/packslipverify/verifier.go @@ -26,9 +26,10 @@ import ( 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. A -// TrustedRootJSON value is useful for offline verification and tests; when it -// is present, TUF is not contacted. +// 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 diff --git a/internal/packslipverify/verifier_native.go b/internal/packslipverify/verifier_native.go index 87303acd..1b5ea55d 100644 --- a/internal/packslipverify/verifier_native.go +++ b/internal/packslipverify/verifier_native.go @@ -22,39 +22,56 @@ import ( "encoding/hex" "errors" "fmt" + "net/http" + "os" + "path/filepath" "regexp" "strings" - "sync" + "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 - - rootMu sync.Mutex - root *root.TrustedRoot - rootLoad *trustedRootLoad - rootLoader trustedRootLoader // Optional loader seam for native tests. - rootWaitHook func() // Optional synchronization hook for native tests. -} - -type trustedRootLoad struct { - done chan struct{} - root *root.TrustedRoot - err error + config Config + configuredTrustedRoot *root.TrustedRoot } -type trustedRootLoader func(context.Context) (*root.TrustedRoot, error) - // New returns a native Sigstore bundle verifier. func New(config Config) (PackslipVerifier, error) { - config.TrustedRootJSON = append([]byte(nil), config.TrustedRootJSON...) - return &nativeVerifier{config: config}, nil + 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) { @@ -135,106 +152,125 @@ func (v *nativeVerifier) Verify(ctx context.Context, bundleJSON []byte, identity return verified, nil } -// trustedRoot shares each in-flight load with its current waiters. A successful -// result is cached; a failed attempt is shared with its waiters and then -// discarded so a later caller can retry. +// 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 + } - v.rootMu.Lock() + 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 { - v.rootMu.Unlock() return nil, err } - if v.root != nil { - trustedRoot := v.root - v.rootMu.Unlock() - return trustedRoot, nil + if load == nil { + return nil, errors.New("Sigstore trusted root loader is nil") } - if currentLoad := v.rootLoad; currentLoad != nil { - v.rootMu.Unlock() - if v.rootWaitHook != nil { - v.rootWaitHook() - } - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-currentLoad.done: - if err := ctx.Err(); err != nil { - return nil, err - } - return currentLoad.root, currentLoad.err - } + 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) } - currentLoad := &trustedRootLoad{done: make(chan struct{})} - v.rootLoad = currentLoad - loader := v.rootLoader - v.rootMu.Unlock() - - if loader == nil { - loader = v.loadTrustedRoot - } - trustedRoot, err := loader(ctx) - if err == nil { - switch { - case trustedRoot == nil: - err = errors.New("trusted root loader returned no root") - case ctx.Err() != nil: - trustedRoot = nil - err = ctx.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 } - - v.rootMu.Lock() - currentLoad.root = trustedRoot - currentLoad.err = err - if err == nil { - v.root = trustedRoot - } - if v.rootLoad == currentLoad { - // Failed attempts are shared with their current waiters, then forgotten - // so a later Verify call can retry transient TUF/network failures. - v.rootLoad = nil - } - close(currentLoad.done) - v.rootMu.Unlock() - return trustedRoot, err + if releaseErr != nil { + return nil, fmt.Errorf("release Sigstore TUF cache lock: %w", releaseErr) + } + return trustedRoot, nil } -func (v *nativeVerifier) loadTrustedRoot(ctx context.Context) (*root.TrustedRoot, error) { +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 } - var trustedRoot *root.TrustedRoot - var err error - if len(v.config.TrustedRootJSON) > 0 { - trustedRoot, err = root.NewTrustedRootFromJSON(v.config.TrustedRootJSON) - } else { - opts := sigtuf.DefaultOptions() - if v.config.CachePath != "" { - opts.CachePath = v.config.CachePath - } - opts.DisableLocalCache = v.config.DisableLocalCache - opts.Context = ctx - if v.config.HTTPClient != nil { - metadataFetcher := fetcher.NewDefaultFetcher() - metadataFetcher.SetHTTPClient(v.config.HTTPClient) - opts.Fetcher = metadataFetcher - } - trustedRoot, err = root.FetchTrustedRootWithOptions(opts) + trustedRoot, err := load(opts) + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr } if err != nil { return nil, err } - if err := ctx.Err(); 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") diff --git a/internal/packslipverify/verifier_native_test.go b/internal/packslipverify/verifier_native_test.go index 41c44f1d..3b166bd4 100644 --- a/internal/packslipverify/verifier_native_test.go +++ b/internal/packslipverify/verifier_native_test.go @@ -23,13 +23,19 @@ 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 @@ -48,6 +54,15 @@ func readFixtureTrustedRoot(t *testing.T) *root.TrustedRoot { 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") @@ -61,147 +76,286 @@ func newFixtureVerifier(t *testing.T) PackslipVerifier { return verifier } -func TestTrustedRootWaitHonorsCallerCancellationAndReusesSuccess(t *testing.T) { - trustedRoot := readFixtureTrustedRoot(t) - loaderStarted := make(chan struct{}) - allowLoadToFinish := make(chan struct{}) - waiterStarted := make(chan struct{}) - var releaseOnce sync.Once - defer releaseOnce.Do(func() { close(allowLoadToFinish) }) - var calls atomic.Int32 +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 +} - verifier := &nativeVerifier{ - rootLoader: func(ctx context.Context) (*root.TrustedRoot, error) { - calls.Add(1) - close(loaderStarted) - select { - case <-allowLoadToFinish: - return trustedRoot, nil - case <-ctx.Done(): - return nil, ctx.Err() - } - }, - rootWaitHook: func() { close(waiterStarted) }, +func TestNewParsesConfiguredTrustedRootAndParallelVerifyNeedsNoNetwork(t *testing.T) { + if _, err := New(Config{TrustedRootJSON: []byte("{")}); err == nil { + t.Fatal("New accepted invalid trusted-root JSON") } - type rootResult struct { - root *root.TrustedRoot - err error + + fixture, err := bundleFixture.ReadFile("testdata/bundle-provenance.json") + if err != nil { + t.Fatal(err) } - firstResult := make(chan rootResult, 1) - go func() { - got, err := verifier.trustedRoot(context.Background()) - firstResult <- rootResult{root: got, err: err} - }() - select { - case <-loaderStarted: - case <-time.After(time.Second): - t.Fatal("root loader did not start") + 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() - waiterCtx, cancelWaiter := context.WithCancel(context.Background()) - defer cancelWaiter() - waiterResult := make(chan rootResult, 1) + 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() { - got, err := verifier.trustedRoot(waiterCtx) - waiterResult <- rootResult{root: got, err: err} + _, err := (requestContextHTTPClient{ctx: ctx, client: server.Client()}).Do(req) + result <- err }() select { - case <-waiterStarted: + case <-requestStarted: case <-time.After(time.Second): - t.Fatal("second caller did not join the in-flight root load") + t.Fatal("HTTP request did not reach the test server") } - - cancelWaiter() + cancel() select { - case result := <-waiterResult: - if !errors.Is(result.err, context.Canceled) { - t.Fatalf("second caller error = %v, want context cancellation", result.err) + 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("second caller did not return promptly after cancellation") + t.Fatal("blocked HTTP request did not stop after caller cancellation") } +} - releaseOnce.Do(func() { close(allowLoadToFinish) }) - first := <-firstResult - if first.err != nil || first.root != trustedRoot { - t.Fatalf("first caller got root %p, error %v; want shared root %p", first.root, first.err, trustedRoot) +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) } - reused, err := verifier.trustedRoot(context.Background()) - if err != nil || reused != trustedRoot { - t.Fatalf("cached root = %p, error %v; want shared root %p", reused, err, trustedRoot) + 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 != 1 { - t.Fatalf("root loader calls = %d, want 1", got) + if got := calls.Load(); got != 0 { + t.Fatalf("TUF loader was called %d times while cache lock was held", got) } } -func TestTrustedRootFailureIsSharedThenRetried(t *testing.T) { +func TestSeparateVerifiersSerializeTUFCacheAccess(t *testing.T) { + cachePath := t.TempDir() trustedRoot := readFixtureTrustedRoot(t) - temporaryFailure := errors.New("temporary trust root fetch failure") - loaderStarted := make(chan struct{}) - allowFailure := make(chan struct{}) - waiterStarted := make(chan struct{}) + firstEntered := make(chan struct{}) + secondEntered := make(chan struct{}) + allowFirstToFinish := make(chan struct{}) var releaseOnce sync.Once - defer releaseOnce.Do(func() { close(allowFailure) }) + defer releaseOnce.Do(func() { close(allowFirstToFinish) }) var calls atomic.Int32 - - verifier := &nativeVerifier{ - rootLoader: func(context.Context) (*root.TrustedRoot, error) { - if calls.Add(1) == 1 { - close(loaderStarted) - <-allowFailure - return nil, temporaryFailure - } - return trustedRoot, nil - }, - rootWaitHook: func() { close(waiterStarted) }, + 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 } - type rootResult struct { + firstVerifier := newCacheVerifier(t, cachePath, false) + secondVerifier := newCacheVerifier(t, cachePath, false) + type loadResult struct { root *root.TrustedRoot err error } - firstResult := make(chan rootResult, 1) + firstResult := make(chan loadResult, 1) go func() { - got, err := verifier.trustedRoot(context.Background()) - firstResult <- rootResult{root: got, err: err} + root, err := firstVerifier.trustedRootWithLoader(context.Background(), loader) + firstResult <- loadResult{root: root, err: err} }() select { - case <-loaderStarted: + case <-firstEntered: case <-time.After(time.Second): - t.Fatal("root loader did not start") + t.Fatal("first TUF loader did not enter") } - waiterResult := make(chan rootResult, 1) + secondStarted := make(chan struct{}) + secondResult := make(chan loadResult, 1) go func() { - got, err := verifier.trustedRoot(context.Background()) - waiterResult <- rootResult{root: got, err: err} + close(secondStarted) + root, err := secondVerifier.trustedRootWithLoader(context.Background(), loader) + secondResult <- loadResult{root: root, err: err} }() + <-secondStarted select { - case <-waiterStarted: - case <-time.After(time.Second): - t.Fatal("second caller did not join the in-flight root load") + 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(allowFailure) }) - for name, resultChannel := range map[string]<-chan rootResult{ - "initializer": firstResult, - "waiter": waiterResult, + releaseOnce.Do(func() { close(allowFirstToFinish) }) + for name, resultChannel := range map[string]<-chan loadResult{ + "first": firstResult, + "second": secondResult, } { select { case result := <-resultChannel: - if result.root != nil || !errors.Is(result.err, temporaryFailure) { - t.Errorf("%s got root %p, error %v; want the shared initialization error", name, result.root, result.err) + 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 did not receive the initialization failure", name) + 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) + } +} - retried, err := verifier.trustedRoot(context.Background()) - if err != nil || retried != trustedRoot { - t.Fatalf("retry got root %p, error %v; want root %p", retried, err, trustedRoot) +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("root loader calls = %d, want 2 after one retry", got) + 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) } } From d293bcfdf62328724cb6373ebb2cb2261ef04c42 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 03:14:40 +0000 Subject: [PATCH 010/115] feat: add package manifest v2 installer --- config/config.go | 135 +++--- config/driver.go | 7 +- config/install_lock_js.go | 26 ++ config/install_lock_native.go | 31 ++ config/package_archive.go | 724 +++++++++++++++++++++++++++++++++ config/package_archive_test.go | 386 ++++++++++++++++++ 6 files changed, 1219 insertions(+), 90 deletions(-) create mode 100644 config/install_lock_js.go create mode 100644 config/install_lock_native.go create mode 100644 config/package_archive.go create mode 100644 config/package_archive_test.go diff --git a/config/config.go b/config/config.go index a7b725fb..6dbae4cc 100644 --- a/config/config.go +++ b/config/config.go @@ -15,8 +15,6 @@ package config import ( - "archive/tar" - "compress/gzip" "errors" "fmt" "io" @@ -224,94 +222,67 @@ 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) } - rdr, err := gzip.NewReader(f) + if !info.IsDir() { + return Manifest{}, fmt.Errorf("output path %s is not a directory", outDir) + } + 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.PackageVersion != nil { + return Manifest{}, fmt.Errorf("%w: package manifest cannot be loaded as an ADBC runtime manifest", ErrInvalidManifest) + } if di.ManifestVersion > currentManifestVersion { return Manifest{}, fmt.Errorf("manifest version %d is unsupported, only %d and lower are supported by this version of dbc", @@ -326,6 +297,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 +316,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 } diff --git a/config/driver.go b/config/driver.go index c53b7767..a6365a1a 100644 --- a/config/driver.go +++ b/config/driver.go @@ -115,7 +115,10 @@ func (d driverMap) String() string { return sb.String() } -type tomlDriverInfo struct { +// runtimeManifestWire is the ADBC Driver Manager manifest format. Package +// MANIFEST files use packageManifestV2Wire/legacyPackageManifestWire instead. +type runtimeManifestWire struct { + PackageVersion *int64 `toml:"package_version,omitempty"` ManifestVersion int32 `toml:"manifest_version"` Name string `toml:"name"` Publisher string `toml:"publisher"` @@ -210,7 +213,7 @@ func createDriverManifest(location string, driver DriverInfo) error { // 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, diff --git a/config/install_lock_js.go b/config/install_lock_js.go new file mode 100644 index 00000000..1e63fd95 --- /dev/null +++ b/config/install_lock_js.go @@ -0,0 +1,26 @@ +// 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 "sync" + +var packageInstallLock sync.Mutex + +func acquirePackageInstallLock(_ string) (func(), error) { + packageInstallLock.Lock() + return packageInstallLock.Unlock, nil +} diff --git a/config/install_lock_native.go b/config/install_lock_native.go new file mode 100644 index 00000000..4312cc6d --- /dev/null +++ b/config/install_lock_native.go @@ -0,0 +1,31 @@ +// 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 ( + "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 +} diff --git a/config/package_archive.go b/config/package_archive.go new file mode 100644 index 00000000..daf48e0f --- /dev/null +++ b/config/package_archive.go @@ -0,0 +1,724 @@ +// 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" + + "github.com/Masterminds/semver/v3" + "github.com/pelletier/go-toml/v2" +) + +const installReceiptName = "dbc-install-receipt.json" + +// ExpectedPackageMetadata describes the resolution that selected an archive. +// Hashes use the canonical form "sha256:". ArchiveSize is the +// size of the compressed archive, not the extracted library. +type ExpectedPackageMetadata struct { + ID string + Version string + Platform string + SourceType string + SourceIdentity string + ArchiveHash string + ArchiveSize int64 +} + +// 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"` + InstalledLibraryHash string `json:"installed_library_hash,omitempty"` +} + +type packageManifest struct { + manifest Manifest + id string + platform string + v2 bool +} + +type packageManifestV2Wire struct { + PackageVersion int64 `toml:"package_version"` + ManifestVersion *int64 `toml:"manifest_version"` + ID string `toml:"id"` + Name string `toml:"name"` + Version *semver.Version `toml:"version"` + Platform string `toml:"platform"` + Driver struct { + Entrypoint string `toml:"entrypoint"` + } `toml:"Driver"` + Files struct { + Driver string `toml:"driver"` + } `toml:"Files"` +} + +// 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"` +} + +func decodePackageManifest(data []byte) (packageManifest, error) { + var root map[string]any + if err := toml.Unmarshal(data, &root); err != nil { + return packageManifest{}, fmt.Errorf("%w: error decoding package manifest: %v", ErrInvalidManifest, err) + } + + if rawVersion, hasDiscriminator := root["package_version"]; hasDiscriminator { + version, ok := rawVersion.(int64) + if !ok { + return packageManifest{}, fmt.Errorf("%w: package_version must be an integer", ErrInvalidManifest) + } + if version != 2 { + return packageManifest{}, fmt.Errorf("%w: package version %d is unsupported", ErrInvalidManifest, version) + } + + var wire packageManifestV2Wire + if err := toml.Unmarshal(data, &wire); err != nil { + return packageManifest{}, fmt.Errorf("%w: error decoding package v2 manifest: %v", ErrInvalidManifest, err) + } + if wire.PackageVersion != 2 { + return packageManifest{}, fmt.Errorf("%w: package_version must be 2", ErrInvalidManifest) + } + if wire.ManifestVersion != nil { + return packageManifest{}, fmt.Errorf("%w: package v2 must not set runtime manifest_version", ErrInvalidManifest) + } + if err := validateFlatName(wire.ID); err != nil { + return packageManifest{}, fmt.Errorf("%w: invalid package id: %v", ErrInvalidManifest, err) + } + if strings.TrimSpace(wire.Name) == "" { + return packageManifest{}, fmt.Errorf("%w: name is required", ErrInvalidManifest) + } + if wire.Version == nil { + return packageManifest{}, fmt.Errorf("%w: version is required", ErrInvalidManifest) + } + if err := validatePlatformIdentifier(wire.Platform); err != nil { + return packageManifest{}, fmt.Errorf("%w: invalid platform: %v", ErrInvalidManifest, err) + } + if strings.TrimSpace(wire.Driver.Entrypoint) == "" { + return packageManifest{}, fmt.Errorf("%w: Driver.entrypoint is required", ErrInvalidManifest) + } + if err := validateFlatName(wire.Files.Driver); err != nil { + return packageManifest{}, fmt.Errorf("%w: invalid Files.driver: %v", ErrInvalidManifest, err) + } + + return packageManifest{ + manifest: Manifest{ + DriverInfo: DriverInfo{ + ID: wire.ID, + Name: wire.Name, + Version: wire.Version, + Driver: struct { + Entrypoint string + Shared driverMap + }{Entrypoint: wire.Driver.Entrypoint}, + }, + Files: struct { + Driver string `toml:"driver,omitempty"` + Signature string `toml:"signature,omitempty"` + }{Driver: wire.Files.Driver}, + }, + id: wire.ID, platform: wire.Platform, v2: true, + }, nil + } + + var wire legacyPackageManifestWire + if err := toml.Unmarshal(data, &wire); err != nil { + return packageManifest{}, fmt.Errorf("%w: error decoding legacy package manifest: %v", ErrInvalidManifest, err) + } + if wire.ManifestVersion > currentManifestVersion { + return packageManifest{}, 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 packageManifest{}, fmt.Errorf("%w: name is required", ErrInvalidManifest) + } + if wire.Version == nil { + return packageManifest{}, fmt.Errorf("%w: version is required", ErrInvalidManifest) + } + if wire.Files.Driver != "" { + if err := validateFlatName(wire.Files.Driver); err != nil { + return packageManifest{}, fmt.Errorf("%w: invalid Files.driver: %v", ErrInvalidManifest, err) + } + } + shared, err := decodeDriverShared(wire.Driver.Shared, false) + if err != nil { + return packageManifest{}, err + } + return packageManifest{ + manifest: 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 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 +} + +func hasRuntimeSharedPath(shared driverMap) bool { + return shared.defaultPath != "" || len(shared.platformMap) != 0 +} + +// InstallPackageArchive verifies and installs an already-downloaded package. +// The expected metadata must describe the resolution that selected the +// archive. The archive remains open for the caller. +func InstallPackageArchive(cfg Config, downloaded *os.File, expected ExpectedPackageMetadata) (Manifest, error) { + if downloaded == nil { + return Manifest{}, errors.New("package archive is nil") + } + if err := validateExpectedPackage(expected); err != nil { + return Manifest{}, err + } + return installPackageArchive(cfg, expected.ID, expected.ID, downloaded, expected) +} + +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 be positive") + } + if _, err := parseSHA256(expected.ArchiveHash); err != nil { + return fmt.Errorf("invalid expected archive hash: %w", err) + } + return 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) + + archivePath := filepath.Join(workDir, "archive.tgz") + archiveHash, archiveSize, err := snapshotArchive(downloaded, archivePath) + if err != nil { + return result, fmt.Errorf("could not snapshot package archive: %w", err) + } + if expected.ArchiveHash != "" && expected.ArchiveHash != archiveHash { + return result, fmt.Errorf("package archive hash mismatch: got %s, expected %s", archiveHash, expected.ArchiveHash) + } + if expected.ArchiveSize > 0 && expected.ArchiveSize != archiveSize { + return result, 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, fmt.Errorf("could not create private package staging directory: %w", err) + } + manifest, meta, files, err := extractPackageArchive(archivePath, payloadDir) + if err != nil { + return result, fmt.Errorf("failed to extract package archive: %w", err) + } + if expected.ID != "" && meta.v2 && expected.ID != meta.id { + return result, fmt.Errorf("package id mismatch: archive declares %q, expected %q", meta.id, expected.ID) + } + if expected.Version != "" && manifest.Version.String() != expected.Version { + return result, fmt.Errorf("package version mismatch: archive declares %q, expected %q", manifest.Version, expected.Version) + } + platform := expected.Platform + if meta.v2 { + if platform != "" && meta.platform != platform { + return result, fmt.Errorf("package platform mismatch: archive declares %q, expected %q", meta.platform, platform) + } + platform = meta.platform + } + if platform == "" { + platform = PlatformTuple() + } + if err := validatePackageFileReferences(manifest, files, meta.v2); err != nil { + return result, err + } + if _, exists := files[strings.ToLower(installReceiptName)]; exists { + return result, fmt.Errorf("package archive uses reserved file name %q", installReceiptName) + } + + manifest.DriverInfo.ID = runtimeID + manifest.DriverInfo.Source = "dbc" + installedHash := "" + if manifest.Files.Driver != "" { + manifest.DriverInfo.Driver.Shared.Set(platform, filepath.Join(finalDir, manifest.Files.Driver)) + installedHash, err = hashFile(filepath.Join(payloadDir, manifest.Files.Driver)) + if err != nil { + return result, fmt.Errorf("could not hash installed driver file: %w", err) + } + } else if !hasRuntimeSharedPath(manifest.DriverInfo.Driver.Shared) { + // Legacy manifest-only packages used the package directory as a + // removable sidecar while retaining Driver.shared as their runtime value. + manifest.DriverInfo.Driver.Shared.Set(platform, finalDir) + } + receipt := InstallReceipt{ + SourceType: expected.SourceType, SourceIdentity: expected.SourceIdentity, + DriverID: runtimeID, DriverVersion: manifest.Version.String(), Platform: platform, + ArchiveHash: archiveHash, ArchiveSize: archiveSize, InstalledLibraryHash: installedHash, + } + if err := writeInstallReceipt(payloadDir, receipt); err != nil { + return result, fmt.Errorf("could not write installation receipt: %w", err) + } + if err := os.Chmod(payloadDir, 0o755); err != nil { + return result, fmt.Errorf("could not prepare package directory for publication: %w", err) + } + if err := publishDirectory(payloadDir, finalDir); err != nil { + return result, fmt.Errorf("could not publish package directory: %w", err) + } + return manifest, nil +} + +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, packageManifest, map[string]string, error) { + var empty Manifest + archive, err := os.Open(archivePath) + if err != nil { + return empty, packageManifest{}, nil, err + } + defer archive.Close() + gz, err := gzip.NewReader(archive) + if err != nil { + return empty, packageManifest{}, nil, fmt.Errorf("could not create gzip reader: %w", err) + } + defer gz.Close() + reader := tar.NewReader(gz) + seen := make(map[string]string) + manifestCount := 0 + var parsed packageManifest + files := make(map[string]string) + for { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return empty, packageManifest{}, nil, fmt.Errorf("error reading tar archive: %w", err) + } + if header.Typeflag == tar.TypeDir { + return empty, packageManifest{}, 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, packageManifest{}, 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, packageManifest{}, nil, fmt.Errorf("archive entry %q uses an unsupported sparse-file extension", header.Name) + } + } + if err := validateFlatName(header.Name); err != nil { + return empty, packageManifest{}, nil, fmt.Errorf("invalid archive entry %q: %w", header.Name, err) + } + if header.Size < 0 { + return empty, packageManifest{}, 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, packageManifest{}, nil, fmt.Errorf("archive entries %q and %q collide by name", previous, header.Name) + } + seen[folded] = header.Name + if strings.EqualFold(header.Name, "MANIFEST") && header.Name != "MANIFEST" { + return empty, packageManifest{}, nil, fmt.Errorf("package manifest must be named exactly MANIFEST") + } + if header.Name == "MANIFEST" { + manifestCount++ + if header.Size > 1<<20 { + return empty, packageManifest{}, nil, errors.New("package manifest exceeds 1 MiB") + } + data, err := io.ReadAll(io.LimitReader(reader, (1<<20)+1)) + if err != nil { + return empty, packageManifest{}, nil, fmt.Errorf("could not read package manifest: %w", err) + } + if int64(len(data)) != header.Size { + return empty, packageManifest{}, nil, errors.New("package manifest size does not match its tar header") + } + parsed, err = decodePackageManifest(data) + if err != nil { + return empty, packageManifest{}, nil, err + } + 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, packageManifest{}, 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, packageManifest{}, nil, fmt.Errorf("could not write staged package file %q: %w", header.Name, writeErr) + } + if closeErr != nil { + return empty, packageManifest{}, nil, fmt.Errorf("could not close staged package file %q: %w", header.Name, closeErr) + } + files[folded] = header.Name + } + if manifestCount != 1 { + return empty, packageManifest{}, nil, fmt.Errorf("package archive must contain exactly one MANIFEST; found %d", manifestCount) + } + if _, err := io.Copy(io.Discard, gz); err != nil { + return empty, packageManifest{}, nil, fmt.Errorf("could not verify gzip stream: %w", err) + } + if err := validatePackageFileReferences(parsed.manifest, files, parsed.v2); err != nil { + return empty, packageManifest{}, nil, err + } + return parsed.manifest, parsed, files, nil +} + +func validatePackageFileReferences(manifest Manifest, files map[string]string, requireDriver bool) error { + driver := manifest.Files.Driver + if driver == "" { + if requireDriver { + return fmt.Errorf("%w: Files.driver is required", ErrInvalidManifest) + } + } else { + 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..718e9063 --- /dev/null +++ b/config/package_archive_test.go @@ -0,0 +1,386 @@ +// 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" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "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 packageV2Manifest(id, version, platform, driverFile string) []byte { + return []byte(fmt.Sprintf(`package_version = 2 +id = %q +name = "Example Driver" +version = %q +platform = %q + +[Driver] +entrypoint = "AdbcDriverExampleInit" + +[Files] +driver = %q +`, id, version, platform, 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) + _, 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 validV2Archive(t *testing.T, contents []byte) []byte { + t.Helper() + manifest := packageV2Manifest("example", "1.2.3", config.PlatformTuple(), "libexample.so") + return makePackageArchive(t, + archiveEntry{name: "MANIFEST", data: manifest}, + archiveEntry{name: "libexample.so", data: contents}, + archiveEntry{name: "LICENSE", data: []byte("license")}, + ) +} + +func TestPackageArchiveManifestVersions(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("package v2 is decoded separately from runtime v1", func(t *testing.T) { + data := makePackageArchive(t, + archiveEntry{name: "MANIFEST", data: packageV2Manifest("example", "1.2.3", "linux_amd64", "libexample.so")}, + archiveEntry{name: "libexample.so", data: []byte("library")}, + ) + f := openPackageArchive(t, data) + manifest, err := config.InflateTarball(f, t.TempDir()) + require.NoError(t, err) + assert.Equal(t, "example", manifest.ID) + assert.Equal(t, "AdbcDriverExampleInit", manifest.Driver.Entrypoint) + assert.Equal(t, "libexample.so", manifest.Files.Driver) + assert.Empty(t, manifest.Driver.Shared.Get("linux_amd64")) + }) + + t.Run("unknown discriminator does not fall back to legacy", func(t *testing.T) { + manifest := []byte("package_version = 3\nname = 'Legacy-looking name'\nversion = '1.0.0'\n\n[Files]\ndriver = 'driver.so'\n") + data := makePackageArchive(t, + archiveEntry{name: "MANIFEST", data: manifest}, + archiveEntry{name: "driver.so", data: []byte("library")}, + ) + f := openPackageArchive(t, data) + _, err := config.InflateTarball(f, t.TempDir()) + require.Error(t, err) + assert.ErrorIs(t, err, config.ErrInvalidManifest) + }) + + t.Run("malformed discriminator does not fall back to legacy", func(t *testing.T) { + manifest := []byte("package_version = '2'\nname = 'Legacy-looking name'\nversion = '1.0.0'\n\n[Files]\ndriver = 'driver.so'\n") + data := makePackageArchive(t, + archiveEntry{name: "MANIFEST", data: manifest}, + archiveEntry{name: "driver.so", data: []byte("library")}, + ) + f := openPackageArchive(t, data) + _, err := config.InflateTarball(f, t.TempDir()) + require.Error(t, err) + assert.ErrorIs(t, err, config.ErrInvalidManifest) + }) + + 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'") + }) +} + +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 TestInstallPackageArchiveChecksMetadataAndDigests(t *testing.T) { + archive := validV2Archive(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: "platform mismatch", mutate: func(e *config.ExpectedPackageMetadata) { e.Platform = "macos_arm64" }}, + {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.InstallPackageArchive(cfg, f, expected) + _ = f.Close() + require.Error(t, err) + assert.NoDirExists(t, filepath.Join(root, "example")) + }) + } +} + +func TestInstallPackageArchiveReceiptReplacementAndRuntimeManifest(t *testing.T) { + root := t.TempDir() + cfg := config.Config{Level: config.ConfigEnv, Location: root} + firstArchive := validV2Archive(t, []byte("first library")) + first := expectedPackage("example", "1.2.3", config.PlatformTuple(), "github.com/first/source", firstArchive) + f := openPackageArchive(t, firstArchive) + manifest, err := config.InstallPackageArchive(cfg, f, first) + require.NoError(t, err) + _ = f.Close() + assert.Equal(t, "dbc", manifest.Source) + assert.FileExists(t, filepath.Join(root, "example", "libexample.so")) + + receiptPath := filepath.Join(root, "example", "dbc-install-receipt.json") + firstReceiptBytes, err := os.ReadFile(receiptPath) + 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.NotEqual(t, firstReceipt.ArchiveHash, firstReceipt.InstalledLibraryHash) + installedDigest := sha256.Sum256([]byte("first library")) + assert.Equal(t, "sha256:"+hex.EncodeToString(installedDigest[:]), firstReceipt.InstalledLibraryHash) + + secondArchive := validV2Archive(t, []byte("second library")) + second := expectedPackage("example", "1.2.3", config.PlatformTuple(), "github.com/second/source", secondArchive) + f = openPackageArchive(t, secondArchive) + manifest, err = config.InstallPackageArchive(cfg, f, second) + require.NoError(t, err) + _ = f.Close() + assert.Equal(t, "dbc", manifest.Source, "runtime source retains dbc uninstall semantics") + contents, err := os.ReadFile(filepath.Join(root, "example", "libexample.so")) + require.NoError(t, err) + assert.Equal(t, "second library", string(contents)) + + secondReceiptBytes, err := os.ReadFile(receiptPath) + 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, "package_version") + assert.NotContains(t, runtimeText, "[Files]") + + loaded, err := config.GetDriver(cfg, "example") + require.NoError(t, err) + assert.Equal(t, "dbc", loaded.Source) + assert.Equal(t, filepath.Join(root, "example", "libexample.so"), loaded.Driver.Shared.Get(config.PlatformTuple())) +} + +func TestFailedReplacementPreservesExistingPackage(t *testing.T) { + root := t.TempDir() + cfg := config.Config{Level: config.ConfigEnv, Location: root} + archive := validV2Archive(t, []byte("installed library")) + expected := expectedPackage("example", "1.2.3", config.PlatformTuple(), "github.com/example/source", archive) + f := openPackageArchive(t, archive) + _, err := config.InstallPackageArchive(cfg, f, expected) + require.NoError(t, err) + _ = f.Close() + originalLibrary, err := os.ReadFile(filepath.Join(root, "example", "libexample.so")) + require.NoError(t, err) + originalReceipt, err := os.ReadFile(filepath.Join(root, "example", "dbc-install-receipt.json")) + require.NoError(t, err) + + badExpected := expected + badExpected.ArchiveHash = "sha256:" + strings.Repeat("0", 64) + f = openPackageArchive(t, validV2Archive(t, []byte("replacement library"))) + _, err = config.InstallPackageArchive(cfg, f, badExpected) + _ = f.Close() + require.Error(t, err) + currentLibrary, err := os.ReadFile(filepath.Join(root, "example", "libexample.so")) + require.NoError(t, err) + currentReceipt, err := os.ReadFile(filepath.Join(root, "example", "dbc-install-receipt.json")) + require.NoError(t, err) + assert.Equal(t, originalLibrary, currentLibrary) + assert.Equal(t, originalReceipt, currentReceipt) +} + +func TestConcurrentInstallPackageArchiveSerializesSameTarget(t *testing.T) { + root := t.TempDir() + cfg := config.Config{Level: config.ConfigEnv, Location: root} + firstArchive := validV2Archive(t, []byte("first library")) + secondArchive := validV2Archive(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.InstallPackageArchive(cfg, firstFile, firstExpected) + _ = firstFile.Close() + results <- err + }() + go func() { + _, err := config.InstallPackageArchive(cfg, secondFile, secondExpected) + _ = secondFile.Close() + results <- err + }() + for range 2 { + require.NoError(t, <-results) + } + + var receipt config.InstallReceipt + receiptBytes, err := os.ReadFile(filepath.Join(root, "example", "dbc-install-receipt.json")) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(receiptBytes, &receipt)) + library, err := os.ReadFile(filepath.Join(root, "example", "libexample.so")) + 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) + } +} From bc3efedfa46a730ca0ab93a5a66067c8908acb27 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 03:34:53 +0000 Subject: [PATCH 011/115] fix: verify resolved package archives --- client_methods.go | 47 +++++++++- client_methods_test.go | 121 +++++++++++++++++++++++++ cmd/dbc/install.go | 60 ++++++++++++- cmd/dbc/install_test.go | 155 ++++++++++++++++++++++++++++++++- config/driver.go | 4 + config/package_archive.go | 89 +++++++++++++++++-- config/package_archive_test.go | 15 ++++ 7 files changed, 478 insertions(+), 13 deletions(-) diff --git a/client_methods.go b/client_methods.go index bf1920aa..2efe1817 100644 --- a/client_methods.go +++ b/client_methods.go @@ -274,14 +274,31 @@ 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, hasMetadata, 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) + var manifest config.Manifest + if hasMetadata { + manifest, err = config.InstallPackageArchive(cfg, f, expected) + } else { + packageManifest, inspectErr := config.InspectPackageManifest(f) + if inspectErr != nil { + return nil, fmt.Errorf("failed to inspect package for driver %s: %w", driverName, inspectErr) + } + if packageManifest.PackageVersion == 2 { + return nil, fmt.Errorf("failed to install driver %s: registry package v2 requires archive hash and size metadata", driverName) + } + manifest, err = config.InstallDriver(cfg, driverName, f) + } if err != nil { return nil, fmt.Errorf("failed to install driver %s: %w", driverName, err) } @@ -293,6 +310,34 @@ func (c *Client) Install(ctx context.Context, cfg config.Config, driverName stri return &manifest, nil } +func expectedRegistryPackageMetadata(pkg PkgInfo) (config.ExpectedPackageMetadata, bool, error) { + hasHash := pkg.ArtifactHash != "" + hasSize := pkg.ArtifactSize != nil + if hasHash != hasSize { + return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata must include both archive hash and size") + } + if !hasHash { + return config.ExpectedPackageMetadata{}, false, nil + } + if pkg.Version == nil { + return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata is missing its version") + } + if strings.TrimSpace(pkg.PlatformTuple) == "" { + return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata is missing its platform") + } + if pkg.Driver.Registry == nil || pkg.Driver.Registry.BaseURL == nil { + return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata is missing its source identity") + } + if strings.TrimSpace(pkg.Driver.Path) == "" { + return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata is missing its driver ID") + } + return config.ExpectedPackageMetadata{ + ID: pkg.Driver.Path, Version: pkg.Version.String(), Platform: pkg.PlatformTuple, + SourceType: "registry", SourceIdentity: pkg.Driver.Registry.BaseURL.String(), + ArchiveHash: pkg.ArtifactHash, ArchiveSize: *pkg.ArtifactSize, + }, true, 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..248378e1 100644 --- a/client_methods_test.go +++ b/client_methods_test.go @@ -15,6 +15,12 @@ package dbc_test import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "encoding/json" "fmt" "io" "net/http" @@ -31,6 +37,67 @@ import ( "github.com/stretchr/testify/require" ) +func makeClientPackageV2Archive(t *testing.T, platform string) []byte { + t.Helper() + manifest := fmt.Sprintf(`package_version = 2 +id = "v2-driver" +name = "V2 Driver" +version = "1.2.3" +platform = %q + +[Driver] +entrypoint = "AdbcDriverV2Init" + +[Files] +driver = "driver.so" +`, platform) + var buffer bytes.Buffer + gz := gzip.NewWriter(&buffer) + tw := tar.NewWriter(gz) + for _, entry := range []struct { + name string + data []byte + }{{"MANIFEST", []byte(manifest)}, {"driver.so", []byte("v2 library")}} { + require.NoError(t, tw.WriteHeader(&tar.Header{Name: entry.name, Mode: 0o644, Size: int64(len(entry.data)), Typeflag: tar.TypeReg})) + _, err := tw.Write(entry.data) + require.NoError(t, err) + } + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) + return buffer.Bytes() +} + +func newV2InstallServer(t *testing.T, archive []byte, metadata string) *httptest.Server { + t.Helper() + digest := sha256.Sum256(archive) + index := fmt.Sprintf(`drivers: + - name: V2 Driver + path: v2-driver + pkginfo: + - version: v1.2.3 + packages: + - platform: %s + url: package.tar.gz +`, config.PlatformTuple()) + if metadata == "complete" { + index += fmt.Sprintf(" hash: %q\n size: %d\n", "sha256:"+hex.EncodeToString(digest[:]), len(archive)) + } 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 newTestClientForServer(t *testing.T, serverURL string) *dbc.Client { t.Helper() c, err := dbc.NewClient( @@ -133,6 +200,60 @@ func TestClientInstall(t *testing.T) { }) } +func TestClientInstallPackageV2UsesRegistryMetadata(t *testing.T) { + archive := makeClientPackageV2Archive(t, config.PlatformTuple()) + srv := newV2InstallServer(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, "v2-driver") + require.NoError(t, err) + require.NotNil(t, manifest) + assert.Equal(t, 2, manifest.PackageVersion) + + var receipt config.InstallReceipt + receiptBytes, err := os.ReadFile(filepath.Join(root, "v2-driver", "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, "v2-driver.toml")) + require.NoError(t, err) + assert.Contains(t, string(runtimeManifest), "manifest_version = 1") + assert.NotContains(t, string(runtimeManifest), "package_version") +} + +func TestClientInstallPackageV2WithoutRegistryMetadataIsRejected(t *testing.T) { + archive := makeClientPackageV2Archive(t, config.PlatformTuple()) + srv := newV2InstallServer(t, archive, "none") + c := newTestClientForServer(t, srv.URL) + root := t.TempDir() + cfg := config.Config{Level: config.ConfigEnv, Location: root} + + _, err := c.Install(t.Context(), cfg, "v2-driver") + require.Error(t, err) + assert.Contains(t, err.Error(), "registry package v2 requires archive hash and size metadata") + assert.NoDirExists(t, filepath.Join(root, "v2-driver")) + assert.NoFileExists(t, filepath.Join(root, "v2-driver.toml")) +} + +func TestClientInstallRejectsPartialRegistryArchiveMetadata(t *testing.T) { + archive := makeClientPackageV2Archive(t, config.PlatformTuple()) + srv := newV2InstallServer(t, archive, "hash-only") + c := newTestClientForServer(t, srv.URL) + root := t.TempDir() + cfg := config.Config{Level: config.ConfigEnv, Location: root} + + _, err := c.Install(t.Context(), cfg, "v2-driver") + require.Error(t, err) + assert.Contains(t, err.Error(), "registry package metadata must include both archive hash and size") + assert.NoDirExists(t, filepath.Join(root, "v2-driver")) +} + func TestClientUninstall(t *testing.T) { srv := newInstallTestServer(t) c := newTestClientForServer(t, srv.URL) diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index be06fd35..18bb44b8 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -112,7 +112,7 @@ func (c InstallCmd) GetModel() tea.Model { } func verifySignature(m config.Manifest, noVerify bool) error { - if m.Files.Driver == "" || noVerify { + if m.PackageVersion == 2 || m.Files.Driver == "" || noVerify { return nil } @@ -479,13 +479,41 @@ func (m progressiveInstallModel) startInstalling(downloaded *os.File) (tea.Model } return m, func() tea.Msg { + if downloaded == nil { + return errors.New("downloaded package archive is nil") + } + defer downloaded.Close() + if m.conflictingInfo.ID != "" { if err := config.UninstallDriver(m.cfg, m.conflictingInfo); err != nil { return err } } - manifest, err := config.InstallDriver(m.cfg, m.Driver, downloaded) + var ( + manifest config.Manifest + err error + ) + if !m.isLocal { + expected, hasMetadata, metadataErr := expectedRegistryPackageMetadata(m.DriverPackage) + if metadataErr != nil { + return metadataErr + } + if hasMetadata { + manifest, err = config.InstallPackageArchive(m.cfg, downloaded, expected) + } else { + packageManifest, inspectErr := config.InspectPackageManifest(downloaded) + if inspectErr != nil { + return inspectErr + } + if packageManifest.PackageVersion == 2 { + return errors.New("registry package v2 requires archive hash and size metadata") + } + manifest, err = config.InstallDriver(m.cfg, m.Driver, downloaded) + } + } else { + manifest, err = config.InstallDriver(m.cfg, m.Driver, downloaded) + } if err != nil { return err } @@ -493,6 +521,34 @@ func (m progressiveInstallModel) startInstalling(downloaded *os.File) (tea.Model } } +func expectedRegistryPackageMetadata(pkg dbc.PkgInfo) (config.ExpectedPackageMetadata, bool, error) { + hasHash := pkg.ArtifactHash != "" + hasSize := pkg.ArtifactSize != nil + if hasHash != hasSize { + return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata must include both archive hash and size") + } + if !hasHash { + return config.ExpectedPackageMetadata{}, false, nil + } + if pkg.Version == nil { + return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata is missing its version") + } + if strings.TrimSpace(pkg.PlatformTuple) == "" { + return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata is missing its platform") + } + if pkg.Driver.Registry == nil || pkg.Driver.Registry.BaseURL == nil { + return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata is missing its source identity") + } + if strings.TrimSpace(pkg.Driver.Path) == "" { + return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata is missing its driver ID") + } + return config.ExpectedPackageMetadata{ + ID: pkg.Driver.Path, Version: pkg.Version.String(), Platform: pkg.PlatformTuple, + SourceType: "registry", SourceIdentity: pkg.Driver.Registry.BaseURL.String(), + ArchiveHash: pkg.ArtifactHash, ArchiveSize: *pkg.ArtifactSize, + }, true, nil +} + func (m progressiveInstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case alreadyInstalledChecksumMsg: diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index decd17c5..419a1ad4 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -16,14 +16,20 @@ package main import ( "archive/tar" + "bytes" "compress/gzip" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" + "net/url" "os" "path/filepath" "runtime" "strings" + "testing" + "github.com/Masterminds/semver/v3" "github.com/columnar-tech/dbc" "github.com/columnar-tech/dbc/config" "github.com/columnar-tech/dbc/internal/jsonschema" @@ -94,8 +100,9 @@ func (suite *SubcommandTestSuite) TestReinstallUpdateVersion() { "\nRemoved conflicting driver: test-driver-1 (version: 1.0.0)\nInstalled test-driver-1 1.1.0 to "+suite.tempdir, 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()) + suite.Equal([]string{"test-driver-1.1/dbc-install-receipt.json", + "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()) } func (suite *SubcommandTestSuite) TestReinstallDowngradeVersion() { @@ -483,7 +490,149 @@ 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 packageV2ArchiveForInstall(t *testing.T, id, version, platform string) []byte { + t.Helper() + var buffer bytes.Buffer + gz := gzip.NewWriter(&buffer) + tw := tar.NewWriter(gz) + manifest := fmt.Sprintf(`package_version = 2 +id = %q +name = "Example Driver" +version = %q +platform = %q + +[Driver] +entrypoint = "AdbcDriverExampleInit" + +[Files] +driver = "driver.so" +`, id, version, platform) + for _, entry := range []struct { + name string + data []byte + }{{"MANIFEST", []byte(manifest)}, {"driver.so", []byte("driver library")}} { + 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) + } + if _, err := f.Write(data); err != nil { + t.Fatal(err) + } + if _, err := f.Seek(0, 0); err != nil { + t.Fatal(err) + } + return f +} + +func TestStartInstallingRegistryPackageV2RequiresAndUsesMetadata(t *testing.T) { + archive := packageV2ArchiveForInstall(t, "example", "1.2.3", config.PlatformTuple()) + digest := sha256.Sum256(archive) + size := int64(len(archive)) + baseURL, err := url.Parse("https://registry.example.test") + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + model := progressiveInstallModel{ + Driver: "example", + cfg: config.Config{Level: config.ConfigEnv, Location: root}, + DriverPackage: dbc.PkgInfo{ + Driver: dbc.Driver{Path: "example", Registry: &dbc.Registry{BaseURL: baseURL}}, + Version: semver.MustParse("1.2.3"), + PlatformTuple: config.PlatformTuple(), + ArtifactHash: "sha256:" + hex.EncodeToString(digest[:]), + ArtifactSize: &size, + }, + } + _, install := model.startInstalling(openInstallArchive(t, archive)) + message := install() + manifest, ok := message.(config.Manifest) + if !ok { + t.Fatalf("startInstalling returned %T, want config.Manifest", message) + } + if manifest.PackageVersion != 2 { + t.Fatalf("package marker = %d, want 2", manifest.PackageVersion) + } + if err := verifySignature(manifest, false); err != nil { + t.Fatalf("package v2 must not require legacy PGP signature: %v", err) + } + var receipt config.InstallReceipt + receiptData, err := os.ReadFile(filepath.Join(root, "example", "dbc-install-receipt.json")) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(receiptData, &receipt); err != nil { + t.Fatal(err) + } + if receipt.SourceType != "registry" || receipt.SourceIdentity != baseURL.String() { + t.Fatalf("receipt source = %q %q, want registry %q", receipt.SourceType, receipt.SourceIdentity, baseURL) + } + if receipt.ArchiveHash != "sha256:"+hex.EncodeToString(digest[:]) || receipt.ArchiveSize != size { + t.Fatalf("receipt archive metadata = %q/%d, want %q/%d", receipt.ArchiveHash, receipt.ArchiveSize, "sha256:"+hex.EncodeToString(digest[:]), size) + } +} + +func TestStartInstallingRegistryV2WithoutArchiveMetadataIsRejected(t *testing.T) { + archive := packageV2ArchiveForInstall(t, "example", "1.2.3", config.PlatformTuple()) + root := t.TempDir() + model := progressiveInstallModel{ + Driver: "example", + cfg: config.Config{Level: config.ConfigEnv, Location: root}, + DriverPackage: dbc.PkgInfo{ + Driver: dbc.Driver{Path: "example", Registry: &dbc.Registry{BaseURL: must(url.Parse("https://registry.example.test"))}}, + Version: semver.MustParse("1.2.3"), + PlatformTuple: config.PlatformTuple(), + }, + } + _, install := model.startInstalling(openInstallArchive(t, archive)) + message := install() + installErr, ok := message.(error) + if !ok || !strings.Contains(installErr.Error(), "requires archive hash and size metadata") { + t.Fatalf("startInstalling returned %v, want missing metadata error", message) + } + if _, err := os.Stat(filepath.Join(root, "example")); !os.IsNotExist(err) { + t.Fatalf("package was published despite missing registry metadata: stat error = %v", err) + } +} + +func TestStartInstallingRejectsPartialRegistryArchiveMetadata(t *testing.T) { + model := progressiveInstallModel{ + Driver: "example", + DriverPackage: dbc.PkgInfo{ + Driver: dbc.Driver{Path: "example", Registry: &dbc.Registry{BaseURL: must(url.Parse("https://registry.example.test"))}}, + Version: semver.MustParse("1.2.3"), + PlatformTuple: config.PlatformTuple(), + ArtifactHash: "sha256:" + strings.Repeat("0", 64), + }, + cfg: config.Config{Level: config.ConfigEnv, Location: t.TempDir()}, + } + _, install := model.startInstalling(openInstallArchive(t, packageV2ArchiveForInstall(t, "example", "1.2.3", config.PlatformTuple()))) + message := install() + installErr, ok := message.(error) + if !ok || !strings.Contains(installErr.Error(), "must include both archive hash and size") { + t.Fatalf("startInstalling returned %v, want partial metadata error", message) + } } func (suite *SubcommandTestSuite) TestInstallJSON() { diff --git a/config/driver.go b/config/driver.go index a6365a1a..a838f505 100644 --- a/config/driver.go +++ b/config/driver.go @@ -32,6 +32,10 @@ const currentManifestVersion = 1 type Manifest struct { DriverInfo + // PackageVersion is the parsed package wire version. It is in-memory + // metadata only; runtime ADBC manifests continue to use ManifestVersion. + PackageVersion int + Files struct { Driver string `toml:"driver,omitempty"` Signature string `toml:"signature,omitempty"` diff --git a/config/package_archive.go b/config/package_archive.go index daf48e0f..cd725004 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -158,6 +158,7 @@ func decodePackageManifest(data []byte) (packageManifest, error) { return packageManifest{ manifest: Manifest{ + PackageVersion: 2, DriverInfo: DriverInfo{ ID: wire.ID, Name: wire.Name, @@ -240,10 +241,6 @@ func decodeDriverShared(value any, required bool) (driverMap, error) { return result, nil } -func hasRuntimeSharedPath(shared driverMap) bool { - return shared.defaultPath != "" || len(shared.platformMap) != 0 -} - // InstallPackageArchive verifies and installs an already-downloaded package. // The expected metadata must describe the resolution that selected the // archive. The archive remains open for the caller. @@ -286,6 +283,84 @@ func validateExpectedPackage(expected ExpectedPackageMetadata) error { return nil } +// InspectPackageManifest decodes the archive's MANIFEST without installing +// files. It is intended to classify legacy and versioned package wire formats +// before choosing an install policy; the installer still validates the full +// archive before publication. The archive remains open for the caller. +func InspectPackageManifest(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) + for { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + return Manifest{}, errors.New("package archive has no MANIFEST") + } + 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 + if strings.EqualFold(header.Name, "MANIFEST") && header.Name != "MANIFEST" { + return Manifest{}, errors.New("package manifest must be named exactly MANIFEST") + } + if header.Name != "MANIFEST" { + 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 > 1<<20 { + return Manifest{}, errors.New("package manifest size is invalid or exceeds 1 MiB") + } + data, err := io.ReadAll(io.LimitReader(reader, (1<<20)+1)) + if err != nil { + return Manifest{}, fmt.Errorf("could not read package manifest: %w", err) + } + if int64(len(data)) != header.Size { + return Manifest{}, errors.New("package manifest size does not match its tar header") + } + manifest, err := decodePackageManifest(data) + if err != nil { + return Manifest{}, err + } + return manifest.manifest, nil + } +} + func installPackageArchive(cfg Config, targetName, runtimeID string, downloaded *os.File, expected ExpectedPackageMetadata) (Manifest, error) { var result Manifest if downloaded == nil { @@ -371,9 +446,9 @@ func installPackageArchive(cfg Config, targetName, runtimeID string, downloaded if err != nil { return result, fmt.Errorf("could not hash installed driver file: %w", err) } - } else if !hasRuntimeSharedPath(manifest.DriverInfo.Driver.Shared) { - // Legacy manifest-only packages used the package directory as a - // removable sidecar while retaining Driver.shared as their runtime value. + } else { + // Legacy installs recorded the package directory for the current + // platform, while a string-form Driver.shared remains the runtime value. manifest.DriverInfo.Driver.Shared.Set(platform, finalDir) } receipt := InstallReceipt{ diff --git a/config/package_archive_test.go b/config/package_archive_test.go index 718e9063..44773801 100644 --- a/config/package_archive_test.go +++ b/config/package_archive_test.go @@ -137,9 +137,24 @@ func TestPackageArchiveManifestVersions(t *testing.T) { manifest, err := config.InflateTarball(f, t.TempDir()) require.NoError(t, err) assert.Equal(t, "example", manifest.ID) + assert.Equal(t, 2, manifest.PackageVersion) assert.Equal(t, "AdbcDriverExampleInit", manifest.Driver.Entrypoint) assert.Equal(t, "libexample.so", manifest.Files.Driver) assert.Empty(t, manifest.Driver.Shared.Get("linux_amd64")) + + inspected, err := config.InspectPackageManifest(openPackageArchive(t, data)) + require.NoError(t, err) + assert.Equal(t, 2, inspected.PackageVersion) + }) + + t.Run("legacy package remains distinguishable from v2", 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.InspectPackageManifest(f) + require.NoError(t, err) + assert.Zero(t, manifest.PackageVersion) }) t.Run("unknown discriminator does not fall back to legacy", func(t *testing.T) { From 489f45a2f99ef5b1ad09e256cb739731c4102ad7 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 03:40:56 +0000 Subject: [PATCH 012/115] fix: preserve legacy manifest-only paths --- config/package_archive.go | 20 +++++++++++++++++--- config/package_archive_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/config/package_archive.go b/config/package_archive.go index cd725004..82211f15 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -447,9 +447,11 @@ func installPackageArchive(cfg Config, targetName, runtimeID string, downloaded return result, fmt.Errorf("could not hash installed driver file: %w", err) } } else { - // Legacy installs recorded the package directory for the current - // platform, while a string-form Driver.shared remains the runtime value. - manifest.DriverInfo.Driver.Shared.Set(platform, finalDir) + // Preserve explicit legacy runtime load paths. Older packages that omit + // Driver.shared rely on the package directory as their fallback. + if !hasRuntimeSharedPath(manifest.DriverInfo.Driver.Shared) { + manifest.DriverInfo.Driver.Shared.Set(platform, finalDir) + } } receipt := InstallReceipt{ SourceType: expected.SourceType, SourceIdentity: expected.SourceIdentity, @@ -468,6 +470,18 @@ func installPackageArchive(cfg Config, targetName, runtimeID string, downloaded return manifest, 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) diff --git a/config/package_archive_test.go b/config/package_archive_test.go index 44773801..0c7a9c56 100644 --- a/config/package_archive_test.go +++ b/config/package_archive_test.go @@ -202,6 +202,36 @@ func TestPackageArchiveManifestVersions(t *testing.T) { 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 TestInstallDriverRejectsNilArchive(t *testing.T) { From 32bb82033ba5317a9e40d6119a8acf33719dfbc9 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 03:46:46 +0000 Subject: [PATCH 013/115] test: update manifest-only uninstall expectations --- cmd/dbc/uninstall_test.go | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/cmd/dbc/uninstall_test.go b/cmd/dbc/uninstall_test.go index 3d6c1f00..397bdca2 100644 --- a/cmd/dbc/uninstall_test.go +++ b/cmd/dbc/uninstall_test.go @@ -200,10 +200,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 + // survive installation and remain separate from the package sidecar directory. type partialManifest struct { Driver struct { Shared map[string]string `toml:"shared"` @@ -211,17 +212,15 @@ 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) + 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) @@ -233,9 +232,11 @@ func (suite *SubcommandTestSuite) TestUninstallInvalidManifest() { // 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")) + // Legacy uninstall currently leaves the package sidecar and library because + // Driver.shared is a relative runtime name rather than an installed path. + packageDir := filepath.Join(suite.Dir(), "test-driver-invalid-manifest") + suite.DirExists(packageDir) + suite.FileExists(filepath.Join(packageDir, "libadbc_driver_invalid_manifest.so")) } func (suite *SubcommandTestSuite) TestUninstallRemovesSymlink() { From 99f70aa3af3ca9f635f2d5b1660d606c3cc7f045 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 03:49:12 +0000 Subject: [PATCH 014/115] feat: add lockfile v2 snapshots --- cmd/dbc/lockfile.go | 362 ++++++++++++++++++- cmd/dbc/lockfile_v2.go | 460 +++++++++++++++++++++++++ cmd/dbc/lockfile_v2_test.go | 385 +++++++++++++++++++++ cmd/dbc/sync.go | 199 +++++++++-- internal/atomicfile/atomicfile.go | 65 ++++ internal/atomicfile/atomicfile_test.go | 46 +++ internal/atomicfile/replace_unix.go | 23 ++ internal/atomicfile/replace_windows.go | 23 ++ 8 files changed, 1527 insertions(+), 36 deletions(-) create mode 100644 cmd/dbc/lockfile_v2.go create mode 100644 cmd/dbc/lockfile_v2_test.go create mode 100644 internal/atomicfile/atomicfile.go create mode 100644 internal/atomicfile/atomicfile_test.go create mode 100644 internal/atomicfile/replace_unix.go create mode 100644 internal/atomicfile/replace_windows.go diff --git a/cmd/dbc/lockfile.go b/cmd/dbc/lockfile.go index 46e951b8..2562ffe0 100644 --- a/cmd/dbc/lockfile.go +++ b/cmd/dbc/lockfile.go @@ -18,59 +18,393 @@ import ( "crypto/sha256" "fmt" "io" + "net/url" "os" + "strings" "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc/internal/resolution" "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 { + BundleURL string `toml:"bundle_url,omitempty"` + BundleHash string `toml:"bundle_hash,omitempty"` + ReleaseListURL string `toml:"release_list_url,omitempty"` + ReleaseListHash string `toml:"release_list_hash,omitempty"` +} + +type lockArtifact struct { + Platform string `toml:"platform,omitempty"` + OS string `toml:"os,omitempty"` + Arch string `toml:"arch,omitempty"` + LibC string `toml:"libc,omitempty"` + Variant string `toml:"variant,omitempty"` + Format string `toml:"format,omitempty"` + URL string `toml:"url,omitempty"` + Path string `toml:"path,omitempty"` + 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 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 || 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 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 := validateHTTPURL("registry source URL", 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 source.Path == "" { + return fmt.Errorf("path source has no path") + } + 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 validateHTTPURL(field, raw string) error { + parsed, err := url.Parse(raw) + if err != nil || !parsed.IsAbs() || + (!strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https")) || parsed.Hostname() == "" { + return fmt.Errorf("%s must be an absolute HTTP(S) URL with a host", field) + } + return nil +} + +func validateLockArtifacts(artifacts []lockArtifact) error { + seenSelectors := make(map[string]struct{}, len(artifacts)) + seenURLs := make(map[string]struct{}, len(artifacts)) + for i, artifact := range artifacts { + if artifact.Platform == "" && (artifact.OS == "" || artifact.Arch == "") { + return fmt.Errorf("artifact %d must identify a platform or both OS and architecture", i) + } + if (artifact.OS == "") != (artifact.Arch == "") { + return fmt.Errorf("artifact %d must provide OS and architecture together", i) + } + if (artifact.URL == "") == (artifact.Path == "") { + return fmt.Errorf("artifact %d must have exactly one of URL or path", i) + } + if artifact.Hash == "" || artifact.Size == nil { + return fmt.Errorf("artifact %d must have a finalized hash and size", i) + } + if err := validateLockHash(artifact.Hash); err != nil { + return fmt.Errorf("artifact %d: %w", i, err) + } + if *artifact.Size < 0 { + return fmt.Errorf("artifact %d has negative size", i) + } + if artifact.URL != "" { + if err := validateHTTPURL("artifact URL", artifact.URL); err != nil { + return fmt.Errorf("artifact %d: %w", i, err) + } + } + selector := artifactSelectorIdentity(artifact) + if _, exists := seenSelectors[selector]; exists { + return fmt.Errorf("duplicate or ambiguous artifact selector %q", selector) + } + seenSelectors[selector] = struct{}{} + location := artifact.URL + if artifact.Path != "" { + location = "path:" + artifact.Path + } + if _, exists := seenURLs[location]; exists { + return fmt.Errorf("duplicate artifact location %q", location) + } + seenURLs[location] = struct{}{} + } + return nil +} + +func validateLockHash(hash string) error { + // Share the resolution layer's canonical digest contract. + return resolution.ValidateArtifactMetadata(hash, nil) +} + +func artifactSelectorIdentity(artifact lockArtifact) string { + osName, arch, libc, variant := artifact.OS, artifact.Arch, artifact.LibC, artifact.Variant + if platformOS, platformArch, platformLibC, platformVariant, ok := parsePlatformTuple(artifact.Platform); ok { + if osName == "" { + osName = platformOS + } + if arch == "" { + arch = platformArch + } + if libc == "" { + libc = platformLibC + } + if variant == "" { + variant = platformVariant + } + } + if osName == "linux" && libc == "" { + libc = "gnu" + } + return fmt.Sprintf("%s\x00%s\x00%s\x00%s", osName, arch, libc, variant) +} + +func parsePlatformTuple(platform string) (osName, arch, libc, variant string, ok bool) { + if platform == "" { + return "", "", "", "", false + } + parts := strings.Split(platform, "_") + if len(parts) < 2 { + return "", "", "", "", false + } + osName, arch = parts[0], parts[1] + for _, part := range parts[2:] { + switch part { + case "gnu", "musl": + libc = part + default: + if variant == "" { + variant = part + } else { + variant += "_" + part + } + } + } + return osName, arch, libc, variant, true +} + +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_v2.go b/cmd/dbc/lockfile_v2.go new file mode 100644 index 00000000..f60bb266 --- /dev/null +++ b/cmd/dbc/lockfile_v2.go @@ -0,0 +1,460 @@ +// 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" + "sort" + + "github.com/Masterminds/semver/v3" + "github.com/columnar-tech/dbc/internal/atomicfile" + "github.com/columnar-tech/dbc/internal/resolution" + "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") +) + +// 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 +} + +func (e *LockRefreshRequiredError) Error() string { + 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. +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: lockEvidence{ + BundleURL: release.Evidence.BundleURL, + BundleHash: release.Evidence.BundleHash, + ReleaseListURL: release.Evidence.ReleaseListURL, + ReleaseListHash: release.Evidence.ReleaseListHash, + }, + } + 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 { + lockedArtifact := lockArtifactFromResolved(artifact) + if release.Source.Type == "path" { + lockedArtifact.Path = lockedArtifact.URL + lockedArtifact.URL = "" + } + entry.Artifacts = append(entry.Artifacts, lockedArtifact) + } + if err := validateLockInfo(entry); err != nil { + return lockInfo{}, err + } + return entry, nil +} + +func lockArtifactFromResolved(artifact resolution.Artifact) lockArtifact { + result := lockArtifact{ + Platform: artifact.Platform, + OS: artifact.OS, + Arch: artifact.Arch, + LibC: artifact.LibC, + Variant: artifact.Variant, + Format: artifact.Format, + URL: artifact.URL, + Hash: artifact.Hash, + Size: cloneInt64(artifact.Size), + HostRequirements: lockHostRequirements{ + OSMin: artifact.HostRequirements.OSMin, + GLibCMin: artifact.HostRequirements.GLibCMin, + Libs: append([]string(nil), artifact.HostRequirements.Libs...), + }, + } + for _, bin := range artifact.HostRequirements.Bins { + result.HostRequirements.Bins = append(result.HostRequirements.Bins, lockNamedRequirement{Name: bin.Name, Min: bin.Min}) + } + 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: resolution.Evidence{ + BundleURL: d.Evidence.BundleURL, + BundleHash: d.Evidence.BundleHash, + ReleaseListURL: d.Evidence.ReleaseListURL, + ReleaseListHash: d.Evidence.ReleaseListHash, + }, + } + if d.Version != nil { + release.Version = d.Version.String() + } + for _, artifact := range d.Artifacts { + artifactURL := artifact.URL + if artifact.Path != "" { + artifactURL = artifact.Path + } + resolved := resolution.Artifact{ + Platform: artifact.Platform, + OS: artifact.OS, + Arch: artifact.Arch, + LibC: artifact.LibC, + Variant: artifact.Variant, + Format: artifact.Format, + URL: artifactURL, + Hash: artifact.Hash, + Size: cloneInt64(artifact.Size), + HostRequirements: resolution.HostRequirements{ + OSMin: artifact.HostRequirements.OSMin, + GLibCMin: artifact.HostRequirements.GLibCMin, + Libs: append([]string(nil), artifact.HostRequirements.Libs...), + }, + } + for _, bin := range artifact.HostRequirements.Bins { + resolved.HostRequirements.Bins = append(resolved.HostRequirements.Bins, resolution.NamedRequirement{Name: bin.Name, Min: bin.Min}) + } + 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 (entry.Evidence.BundleURL == "") != (entry.Evidence.BundleHash == "") { + return fmt.Errorf("driver %q evidence must include both bundle URL and hash", entry.Name) + } + if entry.Evidence.BundleURL != "" { + if err := validateHTTPURL("bundle URL", entry.Evidence.BundleURL); err != nil { + return fmt.Errorf("driver %q evidence: %w", entry.Name, err) + } + } + if (entry.Evidence.ReleaseListURL == "") != (entry.Evidence.ReleaseListHash == "") { + return fmt.Errorf("driver %q evidence must include both release-list URL and hash", entry.Name) + } + if entry.Evidence.ReleaseListURL != "" { + if err := validateHTTPURL("release-list URL", entry.Evidence.ReleaseListURL); err != nil { + return fmt.Errorf("driver %q evidence: %w", entry.Name, err) + } + } + for _, hash := range []string{entry.Evidence.BundleHash, entry.Evidence.ReleaseListHash} { + if hash != "" { + if err := validateLockHash(hash); 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) + } + return validateLockArtifacts(entry.Artifacts) +} + +func cloneInt64(value *int64) *int64 { + if value == nil { + return nil + } + copy := *value + return © +} + +// 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 && old.Legacy.Platform == currentPlatform { + if verified == nil || 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 || 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 +// artifact whose digest or size contradicts the current lock. +func refreshLockEntry(existing, refreshed lockInfo) (lockInfo, error) { + if existing.Name != refreshed.Name || existing.Version == nil || refreshed.Version == nil || !existing.Version.Equal(refreshed.Version) { + return lockInfo{}, errors.New("metadata refresh must keep the locked driver version") + } + if 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.Hash != candidate.Hash || *prior.Size != *candidate.Size { + return lockInfo{}, fmt.Errorf("metadata refresh contradicts locked artifact %q", candidateIdentity) + } + break + } + if !found { + merged.Artifacts = append(merged.Artifacts, candidate) + } + } + merged.Evidence = refreshed.Evidence + if existing.Legacy != nil { + proof := *existing.Legacy + merged.Legacy = &proof + } + if err := validateLockInfo(merged); err != nil { + return lockInfo{}, err + } + return merged, nil +} + +// 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 existing.Version.Equal(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) { + var matches []lockArtifact + for _, artifact := range entry.Artifacts { + if artifactMatchesPlatform(artifact, platform) { + 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 artifactMatchesPlatform(artifact lockArtifact, target string) bool { + targetOS, targetArch, targetLibC, targetVariant, ok := parsePlatformTuple(target) + if !ok { + return false + } + artifactOS, artifactArch, artifactLibC, artifactVariant, parsed := parsePlatformTuple(artifact.Platform) + if !parsed { + artifactOS, artifactArch, artifactLibC, artifactVariant = artifact.OS, artifact.Arch, "", "" + } + if artifact.OS != "" { + artifactOS = artifact.OS + } + if artifact.Arch != "" { + artifactArch = artifact.Arch + } + if artifact.LibC != "" { + artifactLibC = artifact.LibC + } + if artifact.Variant != "" { + artifactVariant = artifact.Variant + } + if artifactOS != targetOS || artifactArch != targetArch { + return false + } + // Existing dbc Linux tuples mean GNU by default; a musl build must not + // silently satisfy an ordinary linux_amd64 target. + if targetOS == "linux" && targetLibC == "" { + targetLibC = "gnu" + } + if targetOS == "linux" && artifactLibC == "" { + artifactLibC = "gnu" + } + if artifactLibC != targetLibC { + return false + } + if artifactVariant != "" && artifactVariant != targetVariant { + return false + } + return true +} + +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].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 +} diff --git a/cmd/dbc/lockfile_v2_test.go b/cmd/dbc/lockfile_v2_test.go new file mode 100644 index 00000000..485a02d4 --- /dev/null +++ b/cmd/dbc/lockfile_v2_test.go @@ -0,0 +1,385 @@ +// 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 ( + "crypto/sha256" + "encoding/hex" + "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{ + Platform: platform, + URL: "https://example.test/" + platform + ".tar.gz", + Hash: "sha256:" + strings.Repeat(digest, 64), + Size: &size, + Format: "tar.gz", + } +} + +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{ + BundleURL: "https://example.test/release.packslip", + BundleHash: "sha256:" + strings.Repeat("e", 64), + }, + Artifacts: []lockArtifact{ + { + Platform: "linux_amd64", OS: "linux", Arch: "amd64", LibC: "gnu", Variant: "v1", Format: "tar.gz", + URL: "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"}}, + }, + }, + { + Platform: "macos_arm64", OS: "macos", Arch: "arm64", Format: "tar.gz", + URL: "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() + 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{{ + Platform: "windows_amd64", Path: "./packages/local.tar.gz", Hash: "sha256:" + strings.Repeat("c", 64), Size: int64Pointer(0), Format: "tar.gz", + }}, + }}} + firstPath := filepath.Join(dir, "first.lock") + require.NoError(t, writeLockFileAtomic(firstPath, lock)) + first, err := os.ReadFile(firstPath) + require.NoError(t, err) + + lock.Drivers[0].Artifacts[0], lock.Drivers[0].Artifacts[1] = lock.Drivers[0].Artifacts[1], lock.Drivers[0].Artifacts[0] + secondPath := filepath.Join(dir, "second.lock") + require.NoError(t, writeLockFileAtomic(secondPath, lock)) + second, err := os.ReadFile(secondPath) + require.NoError(t, err) + 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.LibC) + assert.Equal(t, "v1", linux.Variant) + 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, "./packages/local.tar.gz", loaded.lockinfo["local"].Artifacts[0].Path) + assert.Zero(t, *loaded.lockinfo["local"].Artifacts[0].Size, "an explicit zero size is distinct from an omitted size") + localRelease := loaded.lockinfo["local"].resolvedRelease() + localRoundTrip, err := lockInfoFromResolvedRelease("local", localRelease) + require.NoError(t, err) + assert.Equal(t, "./packages/local.tar.gz", localRoundTrip.Artifacts[0].Path) +} + +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, "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") + + entry := testLockEntry() + entry.Evidence.BundleHash = "" + err = validateLockInfo(entry) + require.Error(t, err) + assert.Contains(t, err.Error(), "both bundle URL and 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 = \"macos_arm64\"\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.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))) + 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, "macos_arm64", 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 + contradictory := existing.Artifacts[0] + contradictory.Hash = "sha256:" + strings.Repeat("f", 64) + contradictory.Size = int64Pointer(999) + refreshed.Artifacts = []lockArtifact{contradictory, testLockArtifact("windows_amd64", "c", 22)} + _, err := refreshLockEntry(existing, refreshed) + require.Error(t, err) + assert.Contains(t, err.Error(), "contradicts") + + refreshed.Artifacts = []lockArtifact{existing.Artifacts[0], testLockArtifact("windows_amd64", "c", 22)} + merged, err := refreshLockEntry(existing, refreshed) + require.NoError(t, err) + assert.Len(t, merged.Artifacts, 3) +} + +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 TestLockSnapshotRequiresFinalizedArtifactMetadata(t *testing.T) { + release := testResolvedRelease() + release.Artifacts[0].Hash = "" + _, err := lockInfoFromResolvedRelease("example", release) + require.Error(t, err) + assert.Contains(t, err.Error(), "no finalized hash") + + release = testResolvedRelease() + release.Artifacts = append(release.Artifacts, release.Artifacts[0]) + _, err = lockInfoFromResolvedRelease("example", release) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate artifact identity") +} + +func TestSyncAdapterReusesCompleteV2SnapshotWithoutRegistryHashes(t *testing.T) { + platform := config.PlatformTuple() + osName, arch, libc, variant, ok := parsePlatformTuple(platform) + require.True(t, ok) + 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{{ + Platform: platform, OS: osName, Arch: arch, LibC: libc, Variant: variant, + URL: "https://registry.example.test/archive.tar.gz", Hash: "sha256:" + strings.Repeat("a", 64), Size: int64Pointer(10), + }}, + } + packageURL, err := url.Parse("https://registry.example.test/archive.tar.gz") + require.NoError(t, err) + item := installItem{ + Driver: dbc.Driver{Path: "example", Registry: &dbc.Registry{BaseURL: registryURL}}, + Package: dbc.PkgInfo{Version: entry.Version, PlatformTuple: platform, Path: packageURL}, + LockEntry: &entry, + } + + 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") +} + +func TestSyncAdapterRejectsFreshEntryWithoutArchiveMetadata(t *testing.T) { + registryURL, err := url.Parse("https://registry.example.test") + require.NoError(t, err) + packageURL, err := url.Parse("https://registry.example.test/archive.tar.gz") + require.NoError(t, err) + item := installItem{ + Driver: dbc.Driver{Path: "example", Registry: &dbc.Registry{BaseURL: registryURL}}, + Package: dbc.PkgInfo{Version: semver.MustParse("1.2.3"), PlatformTuple: "linux_amd64", Path: packageURL}, + } + _, err = lockEntryForItem(item) + require.Error(t, err) + assert.Contains(t, err.Error(), "no finalized hash") +} + +func TestSyncAdapterVerifiesLegacyLibraryProofBeforeReusingV2Entry(t *testing.T) { + platform := config.PlatformTuple() + osName, arch, libc, variant, ok := parsePlatformTuple(platform) + require.True(t, ok) + registryURL, err := url.Parse("https://registry.example.test") + require.NoError(t, err) + packageURL, err := url.Parse("https://registry.example.test/archive.tar.gz") + 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{{ + Platform: platform, OS: osName, Arch: arch, LibC: libc, Variant: variant, + URL: "https://registry.example.test/archive.tar.gz", Hash: "sha256:" + strings.Repeat("a", 64), Size: int64Pointer(10), + }}, + } + item := installItem{ + Driver: dbc.Driver{Path: "example", Registry: &dbc.Registry{BaseURL: registryURL}}, + Package: dbc.PkgInfo{Version: entry.Version, PlatformTuple: platform, Path: packageURL}, + InstalledLibraryHash: strings.Repeat("0", 64), LockEntry: &entry, + } + _, err = lockEntryForItem(item) + require.Error(t, err) + assert.Contains(t, err.Error(), "legacy installed-library checksum mismatch") +} + +func TestDownloadedArchiveSnapshotUsesArchiveBytes(t *testing.T) { + archivePath := filepath.Join(t.TempDir(), "package.tar.gz") + archiveBytes := []byte("compressed package fixture") + require.NoError(t, os.WriteFile(archivePath, archiveBytes, 0o600)) + archive, err := os.Open(archivePath) + require.NoError(t, err) + defer archive.Close() + + item := installItem{Package: dbc.PkgInfo{}} + require.NoError(t, snapshotDownloadedArchive(&item, archive)) + digest := sha256.Sum256(archiveBytes) + assert.Equal(t, "sha256:"+hex.EncodeToString(digest[:]), item.ArchiveHash) + assert.EqualValues(t, len(archiveBytes), item.ArchiveSize) + assert.Empty(t, item.InstalledLibraryHash, "archive bytes must not be recorded as an installed-library hash") +} + +func TestLockReplayRejectsMuslForGenericLinuxTargetAndAmbiguousArtifacts(t *testing.T) { + entry := testLockEntry() + entry.Artifacts = []lockArtifact{{ + Platform: "linux_amd64", LibC: "musl", URL: "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) +} + +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{ + BundleURL: "https://example.test/bundle", BundleHash: "sha256:" + strings.Repeat("e", 64), + }, + Artifacts: []resolution.Artifact{{ + Platform: "linux_amd64", OS: "linux", Arch: "amd64", LibC: "gnu", Variant: "v1", Format: "tar.gz", + URL: "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/sync.go b/cmd/dbc/sync.go index ab8337bc..946658ea 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -32,7 +32,7 @@ import ( "github.com/columnar-tech/dbc" "github.com/columnar-tech/dbc/config" "github.com/columnar-tech/dbc/internal/jsonschema" - "github.com/pelletier/go-toml/v2" + "github.com/columnar-tech/dbc/internal/resolution" ) type SyncCmd struct { @@ -185,9 +185,13 @@ func loadDriverList(path string) (DriversList, error) { } type installItem struct { - Driver dbc.Driver - Package dbc.PkgInfo - Checksum string + Driver dbc.Driver + Package dbc.PkgInfo + Checksum string + ArchiveHash string + ArchiveSize int64 + InstalledLibraryHash string + LockEntry *lockInfo } func (s syncModel) createInstallList(list DriversList) ([]installItem, error) { @@ -237,7 +241,14 @@ func (s syncModel) createInstallList(list DriversList) ([]installItem, error) { items = append(items, installItem{ Driver: drv, Package: pkg, - Checksum: info.Checksum, + Checksum: info.legacyChecksumFor(config.PlatformTuple()), + LockEntry: func() *lockInfo { + if info.Version == nil { + return nil + } + copy := info + return © + }(), }) } return items, nil @@ -247,6 +258,7 @@ type installedDrvMsg struct { removed *config.DriverInfo info config.DriverInfo postInstall []string + item installItem } type alreadyInstalledDrvMsg struct { @@ -269,6 +281,10 @@ func (s syncModel) installDriver(cfg config.Config, item installItem) tea.Cmd { } if item.Checksum != "" { + // A v1 checksum proves only the installed library for its + // recorded platform. A v2 archive digest is intentionally not + // compared with this installed file; a separate install receipt + // will be needed to establish that relationship. if chksum != item.Checksum { return fmt.Errorf("checksum mismatch for driver %s: %s != %s", item.Driver.Path, chksum, item.Checksum) @@ -276,6 +292,13 @@ func (s syncModel) installDriver(cfg config.Config, item installItem) tea.Cmd { } else { item.Checksum = chksum } + item.InstalledLibraryHash = chksum + + if !canReuseLockedEntry(item) { + if err := ensureArchiveSnapshot(s, &item); err != nil { + return fmt.Errorf("failed to snapshot driver archive: %w", err) + } + } return alreadyInstalledDrvMsg{info: drv, item: item} } else { @@ -294,6 +317,11 @@ func (s syncModel) installDriver(cfg config.Config, item installItem) tea.Cmd { prog.Send(fmt.Errorf("failed to download driver: %w", err)) return } + defer output.Close() + if err := snapshotDownloadedArchive(&item, output); err != nil { + prog.Send(fmt.Errorf("failed to snapshot downloaded driver archive: %w", err)) + return + } var loc string if loc, err = config.EnsureLocation(cfg); err != nil { @@ -336,6 +364,7 @@ func (s syncModel) installDriver(cfg config.Config, item installItem) tea.Cmd { removed: removedDriver, info: manifest.DriverInfo, postInstall: manifest.PostInstall.Messages, + item: item, }) }() return nil @@ -343,14 +372,140 @@ func (s syncModel) installDriver(cfg config.Config, item installItem) tea.Cmd { } func (s syncModel) writeLockFile() error { - f, err := os.Create(s.LockFilePath) + s.locked.Version = lockFileVersion + return writeLockFileAtomic(s.LockFilePath, s.locked) +} + +func canReuseLockedEntry(item installItem) bool { + if item.LockEntry == nil || item.LockEntry.Version == nil || item.Package.Version == nil || + !item.LockEntry.Version.Equal(item.Package.Version) { + return false + } + source, err := packageLockSource(item) + if err != nil || item.LockEntry.Source != source { + return false + } + if item.LockEntry.Version == nil { + return false + } + _, err = selectLockedArtifact(*item.LockEntry, config.PlatformTuple(), false) + return err == nil +} + +func packageLockSource(item installItem) (lockSource, error) { + if item.Driver.Registry == nil || item.Driver.Registry.BaseURL == nil { + return lockSource{}, fmt.Errorf("driver %q has no registry identity", item.Driver.Path) + } + return lockSource{Type: "registry", URL: item.Driver.Registry.BaseURL.String()}, nil +} + +func ensureArchiveSnapshot(s syncModel, item *installItem) error { + if item.Package.ArtifactHash != "" && item.Package.ArtifactSize != nil { + if err := resolution.ValidateArtifactMetadata(item.Package.ArtifactHash, item.Package.ArtifactSize); err != nil { + return err + } + item.ArchiveHash = item.Package.ArtifactHash + item.ArchiveSize = *item.Package.ArtifactSize + return nil + } + output, err := s.downloadPkg(item.Package) if err != nil { - return fmt.Errorf("failed to create lock file %s: %w", s.LockFilePath, err) + return fmt.Errorf("failed to download archive for lock metadata: %w", err) } - defer f.Close() + defer output.Close() + return snapshotDownloadedArchive(item, output) +} - s.locked.Version = lockFileVersion - return toml.NewEncoder(f).Encode(s.locked) +func snapshotDownloadedArchive(item *installItem, archive *os.File) error { + if archive == nil { + return fmt.Errorf("download returned no archive") + } + info, err := archive.Stat() + if err != nil { + return fmt.Errorf("failed to stat downloaded archive: %w", err) + } + actualSize := info.Size() + actualHash, err := checksumFile(archive, archive.Name()) + if err != nil { + return err + } + actualHash = "sha256:" + actualHash + if err := resolution.ValidateArtifactMetadata(actualHash, &actualSize); err != nil { + return err + } + if item.Package.ArtifactHash != "" && item.Package.ArtifactHash != actualHash { + return fmt.Errorf("downloaded archive hash %s does not match registry hash %s", actualHash, item.Package.ArtifactHash) + } + if item.Package.ArtifactSize != nil && *item.Package.ArtifactSize != actualSize { + return fmt.Errorf("downloaded archive size %d does not match registry size %d", actualSize, *item.Package.ArtifactSize) + } + item.ArchiveHash = actualHash + item.ArchiveSize = actualSize + if _, err := archive.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("failed to rewind downloaded archive: %w", err) + } + return nil +} + +func lockEntryForItem(item installItem) (lockInfo, error) { + if item.LockEntry != nil && item.LockEntry.Legacy != nil && item.LockEntry.Legacy.Platform == config.PlatformTuple() { + if err := verifyLegacyLibraryProof(*item.LockEntry, config.PlatformTuple(), item.InstalledLibraryHash); err != nil { + return lockInfo{}, err + } + } + if canReuseLockedEntry(item) { + return *item.LockEntry, nil + } + if item.Package.Version == nil || item.Package.Path == nil { + return lockInfo{}, fmt.Errorf("driver %q has incomplete resolved package metadata", item.Driver.Path) + } + source, err := packageLockSource(item) + if err != nil { + return lockInfo{}, err + } + hash := item.ArchiveHash + if hash == "" { + hash = item.Package.ArtifactHash + } + size := item.ArchiveSize + if item.ArchiveHash == "" && item.Package.ArtifactSize != nil { + size = *item.Package.ArtifactSize + } + release := resolution.ResolvedRelease{ + DriverID: item.Driver.Path, + Version: item.Package.Version.String(), + Source: resolution.SourceSpec{Type: source.Type, Reference: source.URL}, + Artifacts: []resolution.Artifact{{ + Platform: item.Package.PlatformTuple, + URL: item.Package.Path.String(), + Hash: hash, + Size: &size, + }}, + } + candidate, err := lockInfoFromResolvedRelease(item.Driver.Path, release) + if err != nil { + return lockInfo{}, err + } + if item.LockEntry == nil || item.LockEntry.Version == nil || !item.LockEntry.Version.Equal(item.Package.Version) { + return candidate, nil + } + if item.LockEntry.Version == nil { + return candidate, nil + } + if len(item.LockEntry.Artifacts) == 0 { + if item.LockEntry.Version == nil { + return candidate, nil + } + if item.LockEntry.Legacy != nil { + var verified *VerifiedLegacyLibrary + if item.LockEntry.Legacy.Platform == config.PlatformTuple() { + verified = &VerifiedLegacyLibrary{Platform: config.PlatformTuple(), LibraryHash: item.InstalledLibraryHash} + } + return migrateV1Entry(*item.LockEntry, release, config.PlatformTuple(), verified) + } + return candidate, nil + } + return refreshLockEntry(*item.LockEntry, candidate) } func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -425,12 +580,11 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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, - }) + entry, err := lockEntryForItem(msg.item) + if err != nil { + return s, errCmd("failed to update lock entry: %w", err) + } + s.locked.Drivers = append(s.locked.Drivers, entry) s.skippedDrivers = append(s.skippedDrivers, jsonschema.SyncedDriver{ Name: msg.info.ID, Version: msg.info.Version.String(), @@ -482,12 +636,13 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } 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, - }) + msg.item.InstalledLibraryHash = chksum + entry, err := lockEntryForItem(msg.item) + if err != nil { + s.status = 1 + return s, tea.Sequence(tea.Println("Error: ", err), tea.Quit) + } + s.locked.Drivers = append(s.locked.Drivers, entry) s.newlyInstalled = append(s.newlyInstalled, jsonschema.SyncedDriver{ Name: msg.info.ID, Version: msg.info.Version.String(), 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) +} From e460f74bedec9c1e2f4a8810bb8f177d029341f6 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 04:13:43 +0000 Subject: [PATCH 015/115] fix: replay sync from lockfile artifacts --- cmd/dbc/lockfile_v2_test.go | 152 ++++++++++++++++++++++++++++++++++-- cmd/dbc/sync.go | 121 ++++++++++++++++++++++++++-- 2 files changed, 261 insertions(+), 12 deletions(-) diff --git a/cmd/dbc/lockfile_v2_test.go b/cmd/dbc/lockfile_v2_test.go index 485a02d4..06497a05 100644 --- a/cmd/dbc/lockfile_v2_test.go +++ b/cmd/dbc/lockfile_v2_test.go @@ -17,6 +17,7 @@ package main import ( "crypto/sha256" "encoding/hex" + "errors" "net/url" "os" "path/filepath" @@ -261,21 +262,28 @@ func TestSyncAdapterReusesCompleteV2SnapshotWithoutRegistryHashes(t *testing.T) Source: lockSource{Type: "registry", URL: registryURL.String()}, Artifacts: []lockArtifact{{ Platform: platform, OS: osName, Arch: arch, LibC: libc, Variant: variant, - URL: "https://registry.example.test/archive.tar.gz", Hash: "sha256:" + strings.Repeat("a", 64), Size: int64Pointer(10), + URL: "https://assets.example.test/archive.tar.gz", Hash: "sha256:" + strings.Repeat("a", 64), Size: int64Pointer(10), Format: "tar.gz", }}, } - packageURL, err := url.Parse("https://registry.example.test/archive.tar.gz") + item, err := installItemFromLockedArtifact("example", entry, entry.Artifacts[0]) require.NoError(t, err) - item := installItem{ - Driver: dbc.Driver{Path: "example", Registry: &dbc.Registry{BaseURL: registryURL}}, - Package: dbc.PkgInfo{Version: entry.Version, PlatformTuple: platform, Path: packageURL}, - LockEntry: &entry, - } + assert.Equal(t, registryURL.String(), item.Driver.Registry.BaseURL.String()) + assert.Equal(t, "https://assets.example.test/archive.tar.gz", item.Package.Path.String(), "artifact host is independent of source identity") + assert.Equal(t, entry.Artifacts[0].Hash, item.Package.ArtifactHash) + assert.Equal(t, *entry.Artifacts[0].Size, *item.Package.ArtifactSize) + 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 TestSyncAdapterRejectsFreshEntryWithoutArchiveMetadata(t *testing.T) { @@ -336,6 +344,136 @@ func TestDownloadedArchiveSnapshotUsesArchiveBytes(t *testing.T) { assert.Empty(t, item.InstalledLibraryHash, "archive bytes must not be recorded as an installed-library hash") } +func TestLockedArchiveDownloadMustMatchExpectedHashAndSize(t *testing.T) { + archivePath := filepath.Join(t.TempDir(), "package.tar.gz") + archiveBytes := []byte("downloaded bytes differ from the lock") + require.NoError(t, os.WriteFile(archivePath, archiveBytes, 0o600)) + digest := sha256.Sum256(archiveBytes) + actualHash := "sha256:" + hex.EncodeToString(digest[:]) + actualSize := int64(len(archiveBytes)) + + tests := []struct { + name string + pkg dbc.PkgInfo + want string + }{ + { + name: "hash mismatch", + pkg: dbc.PkgInfo{ArtifactHash: "sha256:" + strings.Repeat("a", 64), ArtifactSize: &actualSize}, + want: "does not match expected hash", + }, + { + name: "size mismatch", + pkg: dbc.PkgInfo{ArtifactHash: actualHash, ArtifactSize: int64Pointer(actualSize + 1)}, + want: "does not match expected size", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + archive, err := os.Open(archivePath) + require.NoError(t, err) + defer archive.Close() + item := installItem{Package: tt.pkg} + err = snapshotDownloadedArchive(&item, archive) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.want) + assert.Empty(t, item.ArchiveHash, "failed verification must not produce a lock snapshot") + }) + } +} + +func TestV2RegistryReplaySkipsDiscoveryAndUsesLockedURLAndMetadata(t *testing.T) { + projectPath := filepath.Join(t.TempDir(), "dbc.toml") + lockPath := filepath.Join(filepath.Dir(projectPath), "dbc.lock") + entry := testRegistryLockEntryForPlatform(config.PlatformTuple()) + require.NoError(t, writeLockFileAtomic(lockPath, LockFile{Version: 2, Drivers: []lockInfo{entry}})) + require.NoError(t, os.WriteFile(projectPath, []byte("[drivers]\n[drivers.example]\n"), 0o600)) + + discoveryCalls := 0 + model := syncModel{ + baseModel: baseModel{getDriverRegistry: func() ([]dbc.Driver, error) { + discoveryCalls++ + return nil, errors.New("registry unavailable") + }}, + Path: projectPath, + // Deliberately include a changed/incomplete registry result. A valid v2 + // target artifact must remain authoritative and never consult it. + driverIndex: []dbc.Driver{{Path: "example", Title: "changed registry metadata"}}, + } + updated, cmd := model.Update(driversListMsg{path: projectPath, list: DriversList{ + Drivers: map[string]driverSpec{"example": {}}, + }}) + require.NotNil(t, cmd) + msg := cmd() + items, ok := msg.([]installItem) + require.True(t, ok, "expected lock replay install items, got %T", msg) + require.Len(t, items, 1) + assert.Zero(t, discoveryCalls, "registry discovery must not run for a complete v2 registry artifact") + assert.Equal(t, entry.Artifacts[0].URL, items[0].Package.Path.String()) + assert.Equal(t, entry.Artifacts[0].Hash, items[0].Package.ArtifactHash) + assert.Equal(t, *entry.Artifacts[0].Size, *items[0].Package.ArtifactSize) + assert.Equal(t, entry.Source.URL, items[0].Driver.Registry.BaseURL.String()) + + // The normal completion path builds the entry from the locked PkgInfo and + // rewrites v2 without changing its artifact snapshot. + updatedModel := updated.(syncModel) + updatedModel.LockFilePath = lockPath + rewritten, err := lockEntryForItem(items[0]) + require.NoError(t, err) + updatedModel.locked = LockFile{Version: 2, Drivers: []lockInfo{rewritten}} + require.NoError(t, updatedModel.writeLockFile()) + reloaded, err := loadLockFile(lockPath) + require.NoError(t, err) + assert.Equal(t, entry.Artifacts[0], reloaded.lockinfo["example"].Artifacts[0]) +} + +func testRegistryLockEntryForPlatform(platform string) lockInfo { + osName, arch, libc, variant, ok := parsePlatformTuple(platform) + if !ok { + panic("invalid test platform tuple: " + platform) + } + return lockInfo{ + Name: "example", + Version: semver.MustParse("1.2.3"), + Source: lockSource{Type: "registry", URL: "https://registry.example.test"}, + Artifacts: []lockArtifact{{ + Platform: platform, OS: osName, Arch: arch, LibC: libc, Variant: variant, + Format: "tar.gz", URL: "https://assets.example.test/" + platform + ".tar.gz", + Hash: "sha256:" + strings.Repeat("a", 64), Size: int64Pointer(10), + }}, + } +} + +func TestV2RegistryReplayFallsBackToDiscoveryOnlyWhenTargetArtifactIsMissing(t *testing.T) { + tmp := t.TempDir() + projectPath := filepath.Join(tmp, "dbc.toml") + lockPath := filepath.Join(tmp, "dbc.lock") + otherPlatform := "linux_amd64" + if config.PlatformTuple() == otherPlatform { + otherPlatform = "macos_arm64" + } + entry := testRegistryLockEntryForPlatform(otherPlatform) + require.NoError(t, writeLockFileAtomic(lockPath, LockFile{Version: 2, Drivers: []lockInfo{entry}})) + require.NoError(t, os.WriteFile(projectPath, []byte("[drivers]\n[drivers.example]\n"), 0o600)) + + discoveryCalls := 0 + model := syncModel{ + baseModel: baseModel{getDriverRegistry: func() ([]dbc.Driver, error) { + discoveryCalls++ + return []dbc.Driver{}, nil + }}, + Path: projectPath, + } + _, cmd := model.Update(driversListMsg{path: projectPath, list: DriversList{ + Drivers: map[string]driverSpec{"example": {}}, + }}) + require.NotNil(t, cmd) + msg := cmd() + _, ok := msg.(driversWithRegistryError) + require.True(t, ok, "missing target should enter the existing registry fallback, got %T", msg) + assert.Equal(t, 1, discoveryCalls) +} + func TestLockReplayRejectsMuslForGenericLinuxTargetAndAmbiguousArtifacts(t *testing.T) { entry := testLockEntry() entry.Artifacts = []lockArtifact{{ diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 946658ea..14d4bfed 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -19,6 +19,7 @@ import ( "fmt" "io" "io/fs" + "net/url" "os" "path" "path/filepath" @@ -208,6 +209,26 @@ func (s syncModel) createInstallList(list DriversList) ([]installItem, error) { if lf.lockinfo != nil { info = lf.lockinfo[name] } + if lf.Version == lockFileVersion && info.Version != nil { + if info.Source.Type != "registry" { + return nil, fmt.Errorf("locked source type %q for driver %q is not supported by sync yet; source integration will follow", info.Source.Type, name) + } + if lockVersionSatisfiesSpec(info, spec) { + artifact, err := selectLockedArtifact(info, config.PlatformTuple(), false) + if err == nil { + item, err := installItemFromLockedArtifact(name, info, artifact) + if err != nil { + return nil, err + } + items = append(items, item) + continue + } + var refreshErr *LockRefreshRequiredError + if !errors.As(err, &refreshErr) { + return nil, err + } + } + } // locate the driver info in the CDN driver registry index drv, err := findDriver(name, s.driverIndex) @@ -254,6 +275,81 @@ func (s syncModel) createInstallList(list DriversList) ([]installItem, error) { return items, nil } +func (s syncModel) registryDiscoveryNeeded(list DriversList) (bool, error) { + lf, err := loadLockFile(s.LockFilePath) + if errors.Is(err, fs.ErrNotExist) { + return true, nil + } + if err != nil { + return false, err + } + for name, spec := range list.Drivers { + entry, ok := lf.lockinfo[name] + if lf.Version != lockFileVersion || !ok || entry.Version == nil { + return true, nil + } + // Non-registry source adapters are outside this sync slice. Their error + // is reported by createInstallList without contacting the registry. + if entry.Source.Type != "registry" { + continue + } + if !lockVersionSatisfiesSpec(entry, spec) { + return true, nil + } + if _, err := selectLockedArtifact(entry, config.PlatformTuple(), false); err != nil { + var refreshErr *LockRefreshRequiredError + if errors.As(err, &refreshErr) { + return true, nil + } + return false, err + } + } + return false, nil +} + +func lockVersionSatisfiesSpec(entry lockInfo, spec driverSpec) bool { + if entry.Version == nil { + return false + } + if entry.Version.Prerelease() != "" && spec.Prerelease != "allow" { + return false + } + return spec.Version == nil || spec.Version.Check(entry.Version) +} + +func installItemFromLockedArtifact(name string, entry lockInfo, artifact lockArtifact) (installItem, error) { + if artifact.Path != "" || artifact.URL == "" { + return installItem{}, fmt.Errorf("locked registry artifact for %s is not a remote URL artifact", name) + } + if artifact.Format != "" && artifact.Format != "tar.gz" { + return installItem{}, fmt.Errorf("locked artifact format %q for %s is not supported by sync yet", artifact.Format, name) + } + packageURL, err := url.Parse(artifact.URL) + if err != nil || !packageURL.IsAbs() || packageURL.Hostname() == "" { + return installItem{}, fmt.Errorf("invalid locked artifact URL for %s: %q", name, artifact.URL) + } + registryURL, err := url.Parse(entry.Source.URL) + if err != nil || !registryURL.IsAbs() || registryURL.Hostname() == "" { + return installItem{}, fmt.Errorf("invalid locked registry identity for %s: %q", name, entry.Source.URL) + } + driver := dbc.Driver{Path: name, Title: name, Registry: &dbc.Registry{BaseURL: registryURL}} + pkg := dbc.PkgInfo{ + Driver: driver, + Version: entry.Version, + PlatformTuple: config.PlatformTuple(), + Path: packageURL, + ArtifactHash: artifact.Hash, + ArtifactSize: cloneInt64(artifact.Size), + } + copy := entry + return installItem{ + Driver: driver, + Package: pkg, + Checksum: entry.legacyChecksumFor(config.PlatformTuple()), + LockEntry: ©, + }, nil +} + type installedDrvMsg struct { removed *config.DriverInfo info config.DriverInfo @@ -385,11 +481,12 @@ func canReuseLockedEntry(item installItem) bool { if err != nil || item.LockEntry.Source != source { return false } - if item.LockEntry.Version == nil { + artifact, err := selectLockedArtifact(*item.LockEntry, config.PlatformTuple(), false) + if err != nil || artifact.URL == "" || item.Package.Path == nil || item.Package.ArtifactSize == nil { return false } - _, err = selectLockedArtifact(*item.LockEntry, config.PlatformTuple(), false) - return err == nil + return item.Package.Path.String() == artifact.URL && + item.Package.ArtifactHash == artifact.Hash && *item.Package.ArtifactSize == *artifact.Size } func packageLockSource(item installItem) (lockSource, error) { @@ -434,10 +531,10 @@ func snapshotDownloadedArchive(item *installItem, archive *os.File) error { return err } if item.Package.ArtifactHash != "" && item.Package.ArtifactHash != actualHash { - return fmt.Errorf("downloaded archive hash %s does not match registry hash %s", actualHash, item.Package.ArtifactHash) + return fmt.Errorf("downloaded archive hash %s does not match expected hash %s", actualHash, item.Package.ArtifactHash) } if item.Package.ArtifactSize != nil && *item.Package.ArtifactSize != actualSize { - return fmt.Errorf("downloaded archive size %d does not match registry size %d", actualSize, *item.Package.ArtifactSize) + return fmt.Errorf("downloaded archive size %d does not match expected size %d", actualSize, *item.Package.ArtifactSize) } item.ArchiveHash = actualHash item.ArchiveSize = actualSize @@ -477,6 +574,7 @@ func lockEntryForItem(item installItem) (lockInfo, error) { Source: resolution.SourceSpec{Type: source.Type, Reference: source.URL}, Artifacts: []resolution.Artifact{{ Platform: item.Package.PlatformTuple, + Format: "tar.gz", URL: item.Package.Path.String(), Hash: hash, Size: &size, @@ -527,6 +625,19 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err := applyProjectRegistries(s.list); err != nil { return s, errCmd("%v", err) } + needsRegistry, err := s.registryDiscoveryNeeded(s.list) + if err != nil { + return s, errCmd("failed to inspect lock file: %w", err) + } + if !needsRegistry { + return s, func() tea.Msg { + items, err := s.createInstallList(s.list) + if err != nil { + return err + } + return items + } + } return s, func() tea.Msg { drivers, err := s.getDriverRegistry() // Return both drivers and error - we'll decide how to handle based on whether From a1a3380574b6f32f7d152863459cc0244cf5e2ca Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 04:21:15 +0000 Subject: [PATCH 016/115] fix: replay explicitly constrained prereleases --- cmd/dbc/lockfile_v2_test.go | 30 ++++++++++++++++++++++++++++++ cmd/dbc/sync.go | 8 +++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/cmd/dbc/lockfile_v2_test.go b/cmd/dbc/lockfile_v2_test.go index 06497a05..db073c24 100644 --- a/cmd/dbc/lockfile_v2_test.go +++ b/cmd/dbc/lockfile_v2_test.go @@ -474,6 +474,36 @@ func TestV2RegistryReplayFallsBackToDiscoveryOnlyWhenTargetArtifactIsMissing(t * assert.Equal(t, 1, discoveryCalls) } +func TestV2RegistryReplayAllowsExplicitPrereleaseConstraintOffline(t *testing.T) { + projectPath := filepath.Join(t.TempDir(), "dbc.toml") + entry := testRegistryLockEntryForPlatform(config.PlatformTuple()) + entry.Version = semver.MustParse("1.2.3-beta.1") + lockPath := filepath.Join(filepath.Dir(projectPath), "dbc.lock") + require.NoError(t, writeLockFileAtomic(lockPath, LockFile{Version: 2, Drivers: []lockInfo{entry}})) + require.NoError(t, os.WriteFile(projectPath, []byte("[drivers]\n[drivers.example]\n"), 0o600)) + constraint, err := semver.NewConstraint("=1.2.3-beta.1") + require.NoError(t, err) + + discoveryCalls := 0 + model := syncModel{ + baseModel: baseModel{getDriverRegistry: func() ([]dbc.Driver, error) { + discoveryCalls++ + return nil, errors.New("registry unavailable") + }}, + Path: projectPath, + } + _, cmd := model.Update(driversListMsg{path: projectPath, list: DriversList{ + Drivers: map[string]driverSpec{"example": {Version: constraint}}, + }}) + require.NotNil(t, cmd) + msg := cmd() + items, ok := msg.([]installItem) + require.True(t, ok, "expected explicit prerelease constraint to replay lock, got %T", msg) + require.Len(t, items, 1) + assert.Equal(t, "1.2.3-beta.1", items[0].Package.Version.String()) + assert.Zero(t, discoveryCalls, "an explicit matching prerelease constraint must not trigger registry discovery") +} + func TestLockReplayRejectsMuslForGenericLinuxTargetAndAmbiguousArtifacts(t *testing.T) { entry := testLockEntry() entry.Artifacts = []lockArtifact{{ diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 14d4bfed..7a1b6b29 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -311,10 +311,12 @@ func lockVersionSatisfiesSpec(entry lockInfo, spec driverSpec) bool { if entry.Version == nil { return false } - if entry.Version.Prerelease() != "" && spec.Prerelease != "allow" { - return false + if spec.Version != nil { + // An explicit constraint can name a prerelease directly. Let semver's + // constraint evaluation decide whether that locked version is allowed. + return spec.Version.Check(entry.Version) } - return spec.Version == nil || spec.Version.Check(entry.Version) + return entry.Version.Prerelease() == "" || spec.Prerelease == "allow" } func installItemFromLockedArtifact(name string, entry lockInfo, artifact lockArtifact) (installItem, error) { From 0384af22c30dbd73a062e91f8e87006f74990252 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 04:30:01 +0000 Subject: [PATCH 017/115] feat: add Packslip release resolution --- internal/packslip/github.go | 196 ++++ internal/packslip/model.go | 1001 +++++++++++++++++++ internal/packslip/model_native_test.go | 42 + internal/packslip/model_test.go | 240 +++++ internal/packslip/resolver_js.go | 42 + internal/packslip/resolver_native.go | 420 ++++++++ internal/packslip/resolver_native_test.go | 347 +++++++ internal/packslip/truststore_native.go | 345 +++++++ internal/packslip/truststore_native_test.go | 225 +++++ internal/packslip/types.go | 106 ++ 10 files changed, 2964 insertions(+) create mode 100644 internal/packslip/github.go create mode 100644 internal/packslip/model.go create mode 100644 internal/packslip/model_native_test.go create mode 100644 internal/packslip/model_test.go create mode 100644 internal/packslip/resolver_js.go create mode 100644 internal/packslip/resolver_native.go create mode 100644 internal/packslip/resolver_native_test.go create mode 100644 internal/packslip/truststore_native.go create mode 100644 internal/packslip/truststore_native_test.go create mode 100644 internal/packslip/types.go diff --git a/internal/packslip/github.go b/internal/packslip/github.go new file mode 100644 index 00000000..77832817 --- /dev/null +++ b/internal/packslip/github.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 +// +// 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/http" + "net/url" + "strconv" + "strings" + "time" +) + +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) { + project, err := normalizeProject(source.Project) + if err != nil { + return nil, err + } + 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) { + project, err := normalizeProject(source.Project) + if err != nil { + return "", err + } + 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) { + 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") + } + response, err := d.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 response.Request.URL.Scheme != "https" && !(response.Request.URL.Scheme == "http" && isLoopbackHost(response.Request.URL.Hostname())) { + return nil, finalURL, errors.New("metadata request redirected to a non-HTTPS URL") + } + } + 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 isLoopbackHost(host string) bool { + return host == "localhost" || strings.HasPrefix(host, "127.") || host == "::1" +} diff --git a/internal/packslip/model.go b/internal/packslip/model.go new file mode 100644 index 00000000..b03ed14f --- /dev/null +++ b/internal/packslip/model.go @@ -0,0 +1,1001 @@ +// 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" + "math" + "net/url" + "regexp" + "strings" + "time" + + "github.com/columnar-tech/dbc/internal/resolution" +) + +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_.-]*$`) +) + +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"` +} + +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 +} + +type parsedList struct { + envelope statementEnvelope + predicate releaseListPredicate + byURL map[string]subject +} + +// 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) + } + canonicalProject, err := normalizeProject(predicate.Project) + if err != nil { + return nil, fmt.Errorf("packslip project %q is invalid: %w", predicate.Project, err) + } + if expectedProject != "" { + expectedProject, err = normalizeProject(expectedProject) + if err != nil { + return nil, err + } + 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)) + 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) + } + 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 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) + } + canonicalProject, err := normalizeProject(predicate.Project) + if err != nil { + return nil, fmt.Errorf("release-list project %q is invalid: %w", predicate.Project, err) + } + expectedProject, err = normalizeProject(expectedProject) + if err != nil { + return nil, err + } + 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 +} + +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 +} + +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") +} + +func normalizeProject(value string) (string, error) { + if value != strings.TrimSpace(value) || strings.Contains(value, "://") || strings.HasSuffix(value, "/") { + return "", fmt.Errorf("project must be a canonical host path without URL scheme: %q", value) + } + parts := strings.Split(value, "/") + if len(parts) < 3 || !strings.EqualFold(parts[0], "github.com") { + return "", fmt.Errorf("only GitHub projects of the form github.com/owner/repo are supported: %q", value) + } + for i, part := range parts { + if part == "" || part == "." || part == ".." { + return "", fmt.Errorf("project contains an invalid path segment: %q", value) + } + for _, ch := range part { + if !(ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' || strings.ContainsRune("._-", ch)) { + return "", fmt.Errorf("project contains an invalid path segment: %q", value) + } + } + if i < 3 { + parts[i] = strings.ToLower(part) + } + } + return strings.Join(parts, "/"), nil +} + +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) +} + +func selectArtifact(release *parsedRelease, target Target) (*releaseArtifact, error) { + formats := supportedArchiveFormats + bestSpecificity, bestFormat := -1, len(formats) + var chosen, tied *releaseArtifact + for i := range release.predicate.Artifacts { + artifact := &release.predicate.Artifacts[i] + if !supportedArchiveFormat(stringValue(artifact.Format)) { + continue + } + 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 checks ambiguity over the entire signed release, +// including platforms other than the one currently requested. +func validateSupportedArtifactSet(release *parsedRelease) error { + count := 0 + for i := range release.predicate.Artifacts { + left := &release.predicate.Artifacts[i] + if !supportedArchiveFormat(stringValue(left.Format)) { + continue + } + count++ + for j := i + 1; j < len(release.predicate.Artifacts); j++ { + right := &release.predicate.Artifacts[j] + if !supportedArchiveFormat(stringValue(right.Format)) || stringValue(left.Format) != stringValue(right.Format) { + continue + } + if artifactSpecificity(left) == artifactSpecificity(right) && selectorVariantsOverlap(left, right) && selectorsOverlap(left, right) { + return fmt.Errorf("%w: %q and %q", ErrAmbiguousArtifact, left.Name, right.Name) + } + } + } + if count == 0 { + return errors.New("packslip release has no supported tar.gz or tgz artifacts") + } + return nil +} + +func artifactSpecificity(artifact *releaseArtifact) int { + specificity := 0 + for _, selector := range []*string{artifact.OS, artifact.Arch, artifact.LibC} { + if selector != nil { + specificity++ + } + } + return specificity +} + +func selectorVariantsOverlap(left, right *releaseArtifact) bool { + if left.Variant == nil || right.Variant == nil { + return left.Variant == nil && right.Variant == nil + } + return *left.Variant == *right.Variant +} + +func selectorsOverlap(left, right *releaseArtifact) bool { + for _, pair := range [][2]*string{{left.OS, right.OS}, {left.Arch, right.Arch}, {left.LibC, right.LibC}} { + if pair[0] != nil && pair[1] != nil && *pair[0] != *pair[1] { + return false + } + } + return true +} + +func convertSupportedArtifacts(release *parsedRelease, assets []githubAsset) ([]resolution.Artifact, error) { + result := make([]resolution.Artifact, 0, len(release.predicate.Artifacts)) + for i := range release.predicate.Artifacts { + artifact := &release.predicate.Artifacts[i] + if !supportedArchiveFormat(stringValue(artifact.Format)) { + continue + } + 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 nil, fmt.Errorf("packslip artifact %q has no unambiguous matching GitHub release asset", artifact.Name) + } + artifactURL = matches[0] + } + if err := validateHTTPSURL(artifactURL); err != nil { + return nil, fmt.Errorf("packslip artifact %q URL: %w", artifact.Name, err) + } + converted, err := convertArtifact(release, artifact, artifactURL) + if err != nil { + return nil, err + } + result = append(result, converted) + } + return result, nil +} + +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 fitsTarget(value *string, target string) bool { + if value == nil { + return true + } + return target != "" && *value == target +} + +func convertArtifact(release *parsedRelease, artifact *releaseArtifact, artifactURL string) (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{ + OS: stringValue(artifact.OS), + Arch: stringValue(artifact.Arch), + LibC: stringValue(artifact.LibC), + Variant: stringValue(artifact.Variant), + Format: stringValue(artifact.Format), + URL: 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/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..3928fa28 --- /dev/null +++ b/internal/packslip/model_test.go @@ -0,0 +1,240 @@ +// 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/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")}} + } + 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, + 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 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 := parseRelease(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) + 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 := parseRelease(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 := parseRelease(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) +} + +func optionalToken(value string) *string { + if value == "" { + return nil + } + return &value +} + +func TestNormalizeProjectAndTagVersion(t *testing.T) { + project, err := normalizeProject("GitHub.COM/Acme/driver/Tools/Tool") + require.NoError(t, err) + 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 = normalizeProject("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 TestSupportedArtifactAmbiguityIsRejectedForOtherPlatforms(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 := parseRelease(validRelease("1.0.0", + artifact("windows.tar.gz", "windows", ""), + artifact("arm64.tar.gz", "", "aarch64"), + artifact("linux-x64.tar.gz", "linux", "x86_64"), + ), testProject) + require.NoError(t, err) + require.ErrorIs(t, validateSupportedArtifactSet(release), ErrAmbiguousArtifact) +} + +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/resolver_js.go b/internal/packslip/resolver_js.go new file mode 100644 index 00000000..9d369a0b --- /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 targets 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..18c87f0b --- /dev/null +++ b/internal/packslip/resolver_native.go @@ -0,0 +1,420 @@ +// 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" + "strings" + "time" + + "github.com/columnar-tech/dbc/internal/packslipverify" + "github.com/columnar-tech/dbc/internal/resolution" +) + +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 + } + project, err := normalizeProject(source.Project) + if err != nil { + return resolution.ResolvedRelease{}, err + } + if strings.TrimSpace(request.DriverID) == "" { + return resolution.ResolvedRelease{}, errors.New("packslip resolution requires a driver ID") + } + 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) + } + if !validToken(request.Target.OS) || !validToken(request.Target.Arch) { + return resolution.ResolvedRelease{}, errors.New("packslip target requires valid OS and architecture tokens") + } + if request.Target.LibC != "" && !validToken(request.Target.LibC) || request.Target.Variant != "" && !validToken(request.Target.Variant) { + return resolution.ResolvedRelease{}, errors.New("packslip target has an invalid libc or variant token") + } + 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 _, err := selectArtifact(verified.statement, request.Target); err != nil { + return resolution.ResolvedRelease{}, err + } + if err := validateSupportedArtifactSet(verified.statement); err != nil { + return resolution.ResolvedRelease{}, err + } + artifacts, err := convertSupportedArtifacts(verified.statement, assets) + if err != nil { + return resolution.ResolvedRelease{}, err + } + provenance, err := provenancePresence(verified.statement) + if err != nil { + return resolution.ResolvedRelease{}, err + } + checksum := digestBytes(bundleBytes) + result := resolution.ResolvedRelease{ + DriverID: request.DriverID, + Version: verified.statement.predicate.Version, + Source: resolution.SourceSpec{Type: "packslip", Reference: project}, + Evidence: resolution.Evidence{ + BundleURL: bundleURL, BundleHash: checksum, + ReleaseListURL: listURL, ReleaseListHash: listHash, + }, + 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 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) { + project, err := normalizeProject(project) + if err != nil { + return packslipverify.IdentityPolicy{}, err + } + 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 !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..d59de56b --- /dev/null +++ b/internal/packslip/resolver_native_test.go @@ -0,0 +1,347 @@ +// 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/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 + apiHits 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) { + 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) + _, _ = w.Write(mustJSON([]githubRelease{{TagName: fixture.tag, Assets: []githubAsset{ + {Name: "packslip.sigstore.json", BrowserDownloadURL: serverURL(r, "/assets/release.json")}, + {Name: "driver-linux.tar.gz", BrowserDownloadURL: fixture.artifactURL}, + }}})) + case r.URL.Path == "/assets/release.json": + _, _ = w.Write(fixture.release) + 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-linux.tar.gz", "linux", "x86_64", "gnu", "tar.gz", "https://downloads.example/driver-linux.tar.gz"), + artifact("driver-macos.tgz", "darwin", "aarch64", "", "tgz", "https://downloads.example/driver-macos.tgz"), + artifact("driver-windows.tar.gz", "windows", "x86_64", "", "tar.gz", "https://downloads.example/driver-windows.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: "iceberg", Version: "1.0.0", Target: Target{OS: "linux", Arch: "x86_64", LibC: "gnu"}}) + require.NoError(t, err) + require.Equal(t, "iceberg", 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, "sha256:"+digestHex(releaseBytes), resolved.Evidence.BundleHash) + require.Equal(t, "sha256:"+digestHex(listBytes), resolved.Evidence.ReleaseListHash) + require.Equal(t, server.URL+"/acme/driver/HEAD/.well-known/packslip.json", resolved.Evidence.ReleaseListURL) + require.Equal(t, server.URL+"/assets/release.json", resolved.Evidence.BundleURL) + require.Len(t, resolved.Artifacts, 3, "a resolution must preserve all supported platforms") + require.Equal(t, "https://downloads.example/driver-linux.tar.gz", resolved.Artifacts[0].URL) + require.Equal(t, "sha256:"+strings.Repeat("a", 64), resolved.Artifacts[0].Hash) + require.Equal(t, int64(10), *resolved.Artifacts[0].Size) + require.Equal(t, "darwin", resolved.Artifacts[1].OS) + require.Equal(t, "windows", resolved.Artifacts[2].OS) + require.Equal(t, int32(1), fixture.apiHits.Load()) +} + +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: "iceberg", Version: "1.0.0", Target: Target{OS: "linux", Arch: "x86_64", LibC: "gnu"}}) + require.NoError(t, err) + require.NotEmpty(t, resolved.Evidence.ReleaseListURL) + require.Equal(t, "sha256:"+digestHex(fixture.list), resolved.Evidence.ReleaseListHash) +} + +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", Target: Target{OS: "linux", Arch: "x86_64", LibC: "gnu"}}) + 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", Target: Target{OS: "linux", Arch: "x86_64", LibC: "gnu"}}) + 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", Target: Target{OS: "linux", Arch: "x86_64"}}) + 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", Target: Target{OS: "linux", Arch: "x86_64"}}) + 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", Target: Target{OS: "linux", Arch: "x86_64", LibC: "gnu"}}) + 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", Target: Target{OS: "linux", Arch: "x86_64", LibC: "gnu"}}) + 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", Target: Target{OS: "linux", Arch: "x86_64", LibC: "gnu"}}) + require.ErrorContains(t, err, "signature is invalid") +} + +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("os.tar.gz", "linux", "", "tar.gz", ptr(uint64(1))), + artifact("arch.tar.gz", "", "x86_64", "tar.gz", ptr(uint64(1))), + ) + 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", Target: Target{OS: "linux", Arch: "x86_64"}}) + 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", Target: Target{OS: "linux", Arch: "x86_64", LibC: "gnu"}}) + // 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", Target: Target{OS: "linux", Arch: "x86_64", LibC: "gnu"}}) + 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/truststore_native.go b/internal/packslip/truststore_native.go new file mode 100644 index 00000000..8cdb89ec --- /dev/null +++ b/internal/packslip/truststore_native.go @@ -0,0 +1,345 @@ +// 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" +) + +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) + } + for selector, wasPresent := range state.Provenance { + if wasPresent && !candidate.Provenance[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 + } + if state.Provenance == nil { + state.Provenance = make(map[string]bool) + } + for selector, present := range candidate.Provenance { + if present || !state.Provenance[selector] { + state.Provenance[selector] = present + } + } + return nil +} + +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 { + canonical, err := normalizeProject(project) + if err != nil || canonical != 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..6be9b479 --- /dev/null +++ b/internal/packslip/truststore_native_test.go @@ -0,0 +1,225 @@ +// 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 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 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..09569214 --- /dev/null +++ b/internal/packslip/types.go @@ -0,0 +1,106 @@ +// 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 host tuple used to select one archive from a release. +type Target struct { + OS string + Arch string + LibC string + Variant string +} + +// Request identifies one driver release and the archive needed by this host. +type Request struct { + DriverID string + Version string + Target Target +} + +// 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 for one target. +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 } From 1e69d71e333e1f3a027958b833b662cc91ff5530 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 04:44:33 +0000 Subject: [PATCH 018/115] fix: validate Packslip redirects --- internal/packslip/github.go | 99 ++++++++++++++++++- internal/packslip/github_native_test.go | 122 ++++++++++++++++++++++++ 2 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 internal/packslip/github_native_test.go diff --git a/internal/packslip/github.go b/internal/packslip/github.go index 77832817..e21d7376 100644 --- a/internal/packslip/github.go +++ b/internal/packslip/github.go @@ -22,6 +22,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/url" "strconv" @@ -154,6 +155,21 @@ func (d *GitHubPackslipDiscovery) ReleaseListURL(source PackslipSource) (string, } 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 @@ -163,7 +179,26 @@ func (d *GitHubPackslipDiscovery) get(ctx context.Context, resourceURL string, g request.Header.Set("Accept", "application/vnd.github+json") request.Header.Set("X-GitHub-Api-Version", "2022-11-28") } - response, err := d.client.Do(request) + 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 } @@ -171,8 +206,8 @@ func (d *GitHubPackslipDiscovery) get(ctx context.Context, resourceURL string, g finalURL := resourceURL if response.Request != nil && response.Request.URL != nil { finalURL = response.Request.URL.String() - if response.Request.URL.Scheme != "https" && !(response.Request.URL.Scheme == "http" && isLoopbackHost(response.Request.URL.Hostname())) { - return nil, finalURL, errors.New("metadata request redirected to a non-HTTPS URL") + if err := validateMetadataURL(response.Request.URL, loopbackHTTPEndpoint); err != nil { + return nil, finalURL, err } } if response.StatusCode == http.StatusNotFound { @@ -191,6 +226,62 @@ func (d *GitHubPackslipDiscovery) get(ctx context.Context, resourceURL string, g 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 { - return host == "localhost" || strings.HasPrefix(host, "127.") || host == "::1" + 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") +} From 94a4b2dab3cb7757d3cef5e2bee712ac6b16feb5 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 05:16:43 +0000 Subject: [PATCH 019/115] feat: add verified package install boundary --- config/dirs_windows.go | 156 ++++++-- config/driver.go | 40 +- config/install_transaction_test.go | 565 +++++++++++++++++++++++++++++ config/package_archive.go | 326 +++++++++++++++-- 4 files changed, 1007 insertions(+), 80 deletions(-) create mode 100644 config/install_transaction_test.go diff --git a/config/dirs_windows.go b/config/dirs_windows.go index 5805fee5..841e5177 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" ) @@ -270,61 +272,137 @@ 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() + }() + created := !openedExisting + valueNames := []string{"name", "manifest_version", "publisher", "license", "version", "source", "driver", "entrypoint"} + 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()) + } + } + 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 +} - k, _, err = registry.CreateKey(k, "Drivers", 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 } - defer k.Close() - } else { - k, err = registry.OpenKey(cfg.Level.key(), regKeyADBC, registry.ALL_ACCESS) if err != nil { - return err + return nil, err + } + snapshot[name] = registryValueSnapshot{present: true, typ: typ, data: append([]byte(nil), buffer[:n]...)} + } + return snapshot, nil +} + +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) } - defer k.Close() } + return rollbackErr +} - dkey, _, err := registry.CreateKey(k, driver.ID, registry.ALL_ACCESS) +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 } - defer dkey.Close() - - defer func() { - if r := recover(); r != nil { - switch r := r.(type) { - case string: - err = errors.New(r) - case error: - err = r - default: - err = fmt.Errorf("unknown error type: %v", r) - } - } - }() - - 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) + 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 registrationFailure(writeErr, rollbackErr error) error { + if rollbackErr != nil { + return &manifestRollbackError{writeErr: writeErr, rollbackErr: rollbackErr} + } + return writeErr +} + func UninstallDriver(cfg Config, info DriverInfo) error { if err := UninstallDriverShared(info); err != nil { return fmt.Errorf("failed to delete driver shared object: %w", err) diff --git a/config/driver.go b/config/driver.go index a838f505..b37300aa 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" ) @@ -201,22 +203,6 @@ 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 := runtimeManifestWire{ ManifestVersion: currentManifestVersion, Name: driver.Name, @@ -234,11 +220,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/install_transaction_test.go b/config/install_transaction_test.go new file mode 100644 index 00000000..ca55a56e --- /dev/null +++ b/config/install_transaction_test.go @@ -0,0 +1,565 @@ +// 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" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "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(`package_version = 2 +id = %q +name = "Example Driver" +version = %q +platform = %q + +[Driver] +entrypoint = "AdbcDriverExampleInit" + +[Files] +driver = %q +`, id, version, PlatformTuple(), 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)), + } +} + +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: "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 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 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) + } +} + +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) + } + 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) + } +} + +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) + } +} + +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 +} diff --git a/config/package_archive.go b/config/package_archive.go index 82211f15..84268db3 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -23,6 +23,7 @@ import ( "errors" "fmt" "io" + "io/fs" "os" "path/filepath" "strings" @@ -57,9 +58,16 @@ type InstallReceipt struct { 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"` } +// InstallOptions supplies verification that must finish before an installation +// is made visible to the runtime driver manager. +type InstallOptions struct { + Verify func(stagingDir string, manifest Manifest) error +} + type packageManifest struct { manifest Manifest id string @@ -254,6 +262,266 @@ func InstallPackageArchive(cfg Config, downloaded *os.File, expected ExpectedPac return installPackageArchive(cfg, expected.ID, expected.ID, downloaded, expected) } +// 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) +} + +func installPackage(cfg Config, runtimeID string, downloaded *os.File, expected ExpectedPackageMetadata, options InstallOptions, registerManifest func(Config, DriverInfo) error) (Manifest, error) { + if downloaded == nil { + return Manifest{}, errors.New("package archive is nil") + } + if err := validateFlatName(runtimeID); err != nil { + return Manifest{}, fmt.Errorf("invalid runtime driver id: %w", err) + } + if expected.ID == "" { + expected.ID = runtimeID + } + if expected.ID != runtimeID { + return Manifest{}, fmt.Errorf("expected package id %q does not match runtime driver id %q", expected.ID, runtimeID) + } + if expected.ArchiveHash != "" || expected.ArchiveSize != 0 { + if err := validateExpectedPackage(expected); err != nil { + return Manifest{}, err + } + } else { + if expected.Version != "" { + if _, err := semver.NewVersion(expected.Version); err != nil { + return Manifest{}, fmt.Errorf("invalid expected package version %q: %w", expected.Version, err) + } + } + if expected.Platform != "" { + if err := validatePlatformIdentifier(expected.Platform); err != nil { + return Manifest{}, fmt.Errorf("invalid expected package platform: %w", err) + } + } + } + if expected.SourceType == "" { + expected.SourceType = "local" + } + if expected.SourceIdentity == "" { + expected.SourceIdentity = "local" + } + + 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() + + if _, err := loadInstalledDriver(cfg, loc, runtimeID); err != nil { + return Manifest{}, fmt.Errorf("could not inspect existing driver registration: %w", err) + } + workDir, err := os.MkdirTemp(loc, ".dbc-install-") + if err != nil { + return Manifest{}, 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{}, fmt.Errorf("could not reserve package generation directory: %w", err) + } + if err := os.Remove(reservedDir); err != nil { + return Manifest{}, 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{}, err + } + if err := os.Rename(payloadDir, finalDir); err != nil { + return Manifest{}, 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{}, fmt.Errorf("could not register driver manifest; preserving verified package at %s: %w", finalDir, err) + } + if removeErr := os.RemoveAll(finalDir); removeErr != nil { + return Manifest{}, fmt.Errorf("could not register driver manifest: %w; could not remove unregistered package at %s: %v", err, finalDir, removeErr) + } + return Manifest{}, fmt.Errorf("could not register driver manifest: %w", err) + } + + cleanupManagedPackageDirectories(loc, runtimeID, finalDir, manifest.DriverInfo) + return manifest, 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) { + lockTarget := filepath.Join(location, runtimeID) + lockHash := sha256.Sum256([]byte(strings.ToLower(filepath.Clean(lockTarget)))) + return acquirePackageInstallLock(filepath.Join(location, ".dbc-package-install-"+hex.EncodeToString(lockHash[:])+".lock")) +} + +func managedPackageDirectory(location, runtimeID string, info DriverInfo) (string, bool) { + if info.Source != "dbc" { + return "", false + } + for sharedPath := range info.Driver.Shared.Paths() { + if sharedPath == "" { + continue + } + if !filepath.IsAbs(sharedPath) { + sharedPath = filepath.Join(location, sharedPath) + } + dir := filepath.Clean(filepath.Dir(sharedPath)) + receipt, ok := readManagedPackageReceipt(location, runtimeID, dir) + if !ok || receipt.InstalledLibrary == "" || info.ID != runtimeID || info.Version == nil || receipt.DriverVersion != info.Version.String() { + continue + } + registeredPath := info.Driver.Shared.defaultPath + if registeredPath == "" { + var found bool + registeredPath, found = info.Driver.Shared.platformMap[receipt.Platform] + 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 + } + return dir, true + } + return "", false +} + +func cleanupManagedPackageDirectories(location, runtimeID, currentDir string, current DriverInfo) { + entries, err := os.ReadDir(location) + if err != nil { + return + } + currentDir = filepath.Clean(currentDir) + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), ".dbc-package-"+runtimeID+"-") { + continue + } + dir := filepath.Join(location, entry.Name()) + if filepath.Clean(dir) == currentDir || runtimeReferencesDirectory(current, dir) { + continue + } + if _, ok := readManagedPackageReceipt(location, runtimeID, dir); !ok { + continue + } + _ = os.RemoveAll(dir) + } +} + +func readManagedPackageReceipt(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 + } + if !strings.HasPrefix(filepath.Base(absDirectory), ".dbc-package-"+runtimeID+"-") { + 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 + } + libraryPath := filepath.Join(absDirectory, receipt.InstalledLibrary) + libraryInfo, err := os.Lstat(libraryPath) + if err != nil || !libraryInfo.Mode().IsRegular() { + return InstallReceipt{}, false + } + actualHash, err := hashFile(libraryPath) + if err != nil || actualHash != receipt.InstalledLibraryHash { + 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 runtimeReferencesDirectory(info DriverInfo, directory string) bool { + for sharedPath := range info.Driver.Shared.Paths() { + if sharedPath == "" { + continue + } + sharedPath = filepath.Clean(sharedPath) + if sharedPath == filepath.Clean(directory) || filepath.Dir(sharedPath) == filepath.Clean(directory) { + return true + } + } + return false +} + func validateExpectedPackage(expected ExpectedPackageMetadata) error { if err := validateFlatName(expected.ID); err != nil { return fmt.Errorf("invalid expected package id: %w", err) @@ -393,37 +661,48 @@ func installPackageArchive(cfg Config, targetName, runtimeID string, downloaded 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, error) { + var result Manifest archivePath := filepath.Join(workDir, "archive.tgz") archiveHash, archiveSize, err := snapshotArchive(downloaded, archivePath) if err != nil { - return result, fmt.Errorf("could not snapshot package archive: %w", err) + return result, "", fmt.Errorf("could not snapshot package archive: %w", err) } if expected.ArchiveHash != "" && expected.ArchiveHash != archiveHash { - return result, fmt.Errorf("package archive hash mismatch: got %s, expected %s", archiveHash, expected.ArchiveHash) + return result, "", fmt.Errorf("package archive hash mismatch: got %s, expected %s", archiveHash, expected.ArchiveHash) } if expected.ArchiveSize > 0 && expected.ArchiveSize != archiveSize { - return result, fmt.Errorf("package archive size mismatch: got %d, expected %d", archiveSize, expected.ArchiveSize) + return result, "", 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, fmt.Errorf("could not create private package staging directory: %w", err) + return result, "", fmt.Errorf("could not create private package staging directory: %w", err) } manifest, meta, files, err := extractPackageArchive(archivePath, payloadDir) if err != nil { - return result, fmt.Errorf("failed to extract package archive: %w", err) + return result, "", fmt.Errorf("failed to extract package archive: %w", err) } if expected.ID != "" && meta.v2 && expected.ID != meta.id { - return result, fmt.Errorf("package id mismatch: archive declares %q, expected %q", meta.id, expected.ID) + return result, "", fmt.Errorf("package id mismatch: archive declares %q, expected %q", meta.id, expected.ID) } if expected.Version != "" && manifest.Version.String() != expected.Version { - return result, fmt.Errorf("package version mismatch: archive declares %q, expected %q", manifest.Version, expected.Version) + return result, "", fmt.Errorf("package version mismatch: archive declares %q, expected %q", manifest.Version, expected.Version) } platform := expected.Platform if meta.v2 { if platform != "" && meta.platform != platform { - return result, fmt.Errorf("package platform mismatch: archive declares %q, expected %q", meta.platform, platform) + return result, "", fmt.Errorf("package platform mismatch: archive declares %q, expected %q", meta.platform, platform) } platform = meta.platform } @@ -431,43 +710,44 @@ func installPackageArchive(cfg Config, targetName, runtimeID string, downloaded platform = PlatformTuple() } if err := validatePackageFileReferences(manifest, files, meta.v2); err != nil { - return result, err + return result, "", err } if _, exists := files[strings.ToLower(installReceiptName)]; exists { - return result, fmt.Errorf("package archive uses reserved file name %q", installReceiptName) + return result, "", fmt.Errorf("package archive uses reserved file name %q", installReceiptName) } manifest.DriverInfo.ID = runtimeID manifest.DriverInfo.Source = "dbc" installedHash := "" + installedLibrary := "" if manifest.Files.Driver != "" { manifest.DriverInfo.Driver.Shared.Set(platform, filepath.Join(finalDir, manifest.Files.Driver)) + installedLibrary = manifest.Files.Driver installedHash, err = hashFile(filepath.Join(payloadDir, manifest.Files.Driver)) if err != nil { - return result, fmt.Errorf("could not hash installed driver file: %w", err) - } - } else { - // Preserve explicit legacy runtime load paths. Older packages that omit - // Driver.shared rely on the package directory as their fallback. - if !hasRuntimeSharedPath(manifest.DriverInfo.Driver.Shared) { - manifest.DriverInfo.Driver.Shared.Set(platform, finalDir) + return result, "", fmt.Errorf("could not hash installed driver file: %w", err) } + } else if !hasRuntimeSharedPath(manifest.DriverInfo.Driver.Shared) { + manifest.DriverInfo.Driver.Shared.Set(platform, finalDir) } receipt := InstallReceipt{ SourceType: expected.SourceType, SourceIdentity: expected.SourceIdentity, DriverID: runtimeID, DriverVersion: manifest.Version.String(), Platform: platform, - ArchiveHash: archiveHash, ArchiveSize: archiveSize, InstalledLibraryHash: installedHash, + ArchiveHash: archiveHash, ArchiveSize: archiveSize, InstalledLibrary: installedLibrary, + InstalledLibraryHash: installedHash, } if err := writeInstallReceipt(payloadDir, receipt); err != nil { - return result, fmt.Errorf("could not write installation receipt: %w", err) + return result, "", fmt.Errorf("could not write installation receipt: %w", err) } if err := os.Chmod(payloadDir, 0o755); err != nil { - return result, fmt.Errorf("could not prepare package directory for publication: %w", err) + return result, "", fmt.Errorf("could not prepare package directory for publication: %w", err) } - if err := publishDirectory(payloadDir, finalDir); err != nil { - return result, fmt.Errorf("could not publish package directory: %w", err) + if verify != nil { + if err := verify(payloadDir, manifest); err != nil { + return result, "", fmt.Errorf("package verification failed: %w", err) + } } - return manifest, nil + return manifest, payloadDir, nil } func hasRuntimeSharedPath(shared driverMap) bool { From cf5ed3b268e0a12e618c98c3ca77bf0a77087559 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 05:28:38 +0000 Subject: [PATCH 020/115] fix: finalize package receipt after verification --- config/install_transaction_test.go | 70 ++++++++++++++++++++++++++++++ config/package_archive.go | 36 +++++++++------ 2 files changed, 93 insertions(+), 13 deletions(-) diff --git a/config/install_transaction_test.go b/config/install_transaction_test.go index ca55a56e..02c2c38f 100644 --- a/config/install_transaction_test.go +++ b/config/install_transaction_test.go @@ -202,6 +202,76 @@ func TestInstallPackageMetadataFailuresPreserveOldGeneration(t *testing.T) { } } +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 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} diff --git a/config/package_archive.go b/config/package_archive.go index 84268db3..3bc8adbb 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -718,18 +718,36 @@ func stagePackageArchive(location, runtimeID, finalDir string, downloaded *os.Fi manifest.DriverInfo.ID = runtimeID manifest.DriverInfo.Source = "dbc" - installedHash := "" installedLibrary := "" if manifest.Files.Driver != "" { manifest.DriverInfo.Driver.Shared.Set(platform, filepath.Join(finalDir, manifest.Files.Driver)) installedLibrary = manifest.Files.Driver - installedHash, err = hashFile(filepath.Join(payloadDir, manifest.Files.Driver)) - if err != nil { - return result, "", fmt.Errorf("could not hash installed driver file: %w", err) - } } else if !hasRuntimeSharedPath(manifest.DriverInfo.Driver.Shared) { manifest.DriverInfo.Driver.Shared.Set(platform, finalDir) } + if err := os.Chmod(payloadDir, 0o755); err != nil { + return result, "", fmt.Errorf("could not prepare package directory for publication: %w", err) + } + if verify != nil { + if err := verify(payloadDir, manifest); err != nil { + return result, "", 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, "", fmt.Errorf("could not inspect verified driver file: %w", err) + } + if !libraryInfo.Mode().IsRegular() { + return result, "", errors.New("verified driver file is not a regular file") + } + installedHash, err = hashFile(libraryPath) + if err != nil { + return result, "", fmt.Errorf("could not hash verified driver file: %w", err) + } + } receipt := InstallReceipt{ SourceType: expected.SourceType, SourceIdentity: expected.SourceIdentity, DriverID: runtimeID, DriverVersion: manifest.Version.String(), Platform: platform, @@ -739,14 +757,6 @@ func stagePackageArchive(location, runtimeID, finalDir string, downloaded *os.Fi if err := writeInstallReceipt(payloadDir, receipt); err != nil { return result, "", fmt.Errorf("could not write installation receipt: %w", err) } - if err := os.Chmod(payloadDir, 0o755); err != nil { - return result, "", fmt.Errorf("could not prepare package directory for publication: %w", err) - } - if verify != nil { - if err := verify(payloadDir, manifest); err != nil { - return result, "", fmt.Errorf("package verification failed: %w", err) - } - } return manifest, payloadDir, nil } From f204f97dbba907bfd8ea742d31964e53d7144b80 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 06:21:26 +0000 Subject: [PATCH 021/115] feat: integrate verified package installs --- client_methods.go | 34 ++--- client_methods_test.go | 113 +++++++++++++- cmd/dbc/install.go | 167 +++++++++++---------- cmd/dbc/install_test.go | 139 ++++++++++++++++-- cmd/dbc/search_test.go | 10 +- cmd/dbc/sync.go | 68 ++++----- cmd/dbc/sync_test.go | 42 ++++++ cmd/dbc/testdata/test_index.yaml | 2 +- cmd/dbc/uninstall_test.go | 49 +++++-- config/config.go | 8 + config/dirs_unixlike.go | 29 ++-- config/dirs_windows.go | 45 +++--- config/install_transaction_test.go | 228 +++++++++++++++++++++++++++++ config/package_archive.go | 76 ++++++++++ package_signature.go | 58 ++++++++ 15 files changed, 866 insertions(+), 202 deletions(-) create mode 100644 package_signature.go diff --git a/client_methods.go b/client_methods.go index 2efe1817..edf32f33 100644 --- a/client_methods.go +++ b/client_methods.go @@ -285,11 +285,7 @@ func (c *Client) Install(ctx context.Context, cfg config.Config, driverName stri } defer f.Close() defer os.RemoveAll(filepath.Dir(f.Name())) - - var manifest config.Manifest - if hasMetadata { - manifest, err = config.InstallPackageArchive(cfg, f, expected) - } else { + if !hasMetadata { packageManifest, inspectErr := config.InspectPackageManifest(f) if inspectErr != nil { return nil, fmt.Errorf("failed to inspect package for driver %s: %w", driverName, inspectErr) @@ -297,16 +293,17 @@ func (c *Client) Install(ctx context.Context, cfg config.Config, driverName stri if packageManifest.PackageVersion == 2 { return nil, fmt.Errorf("failed to install driver %s: registry package v2 requires archive hash and size metadata", driverName) } - 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 } @@ -316,9 +313,6 @@ func expectedRegistryPackageMetadata(pkg PkgInfo) (config.ExpectedPackageMetadat if hasHash != hasSize { return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata must include both archive hash and size") } - if !hasHash { - return config.ExpectedPackageMetadata{}, false, nil - } if pkg.Version == nil { return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata is missing its version") } @@ -331,11 +325,17 @@ func expectedRegistryPackageMetadata(pkg PkgInfo) (config.ExpectedPackageMetadat if strings.TrimSpace(pkg.Driver.Path) == "" { return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata is missing its driver ID") } - return config.ExpectedPackageMetadata{ - ID: pkg.Driver.Path, Version: pkg.Version.String(), Platform: pkg.PlatformTuple, + expected := config.ExpectedPackageMetadata{ + ID: pkg.Driver.Path, + Version: pkg.Version.String(), + Platform: pkg.PlatformTuple, SourceType: "registry", SourceIdentity: pkg.Driver.Registry.BaseURL.String(), - ArchiveHash: pkg.ArtifactHash, ArchiveSize: *pkg.ArtifactSize, - }, true, nil + } + if hasHash { + expected.ArchiveHash = pkg.ArtifactHash + expected.ArchiveSize = *pkg.ArtifactSize + } + return expected, hasHash, nil } // Uninstall uninstalls a driver with the given name from the specified configuration. diff --git a/client_methods_test.go b/client_methods_test.go index 248378e1..9c8b6459 100644 --- a/client_methods_test.go +++ b/client_methods_test.go @@ -98,6 +98,31 @@ func newV2InstallServer(t *testing.T, archive []byte, metadata string) *httptest 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( @@ -114,7 +139,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) { @@ -200,6 +225,89 @@ 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 TestClientInstallPackageV2UsesRegistryMetadata(t *testing.T) { archive := makeClientPackageV2Archive(t, config.PlatformTuple()) srv := newV2InstallServer(t, archive, "complete") @@ -213,7 +321,7 @@ func TestClientInstallPackageV2UsesRegistryMetadata(t *testing.T) { assert.Equal(t, 2, manifest.PackageVersion) var receipt config.InstallReceipt - receiptBytes, err := os.ReadFile(filepath.Join(root, "v2-driver", "dbc-install-receipt.json")) + 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) @@ -237,7 +345,6 @@ func TestClientInstallPackageV2WithoutRegistryMetadataIsRejected(t *testing.T) { _, err := c.Install(t.Context(), cfg, "v2-driver") require.Error(t, err) assert.Contains(t, err.Error(), "registry package v2 requires archive hash and size metadata") - assert.NoDirExists(t, filepath.Join(root, "v2-driver")) assert.NoFileExists(t, filepath.Join(root, "v2-driver.toml")) } diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index 18bb44b8..31b8d4ae 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -19,7 +19,6 @@ import ( "errors" "fmt" "io" - "io/fs" "os" "path/filepath" "strings" @@ -111,44 +110,14 @@ func (c InstallCmd) GetModel() tea.Model { return c.GetModelCustom(defaultBaseModel()) } -func verifySignature(m config.Manifest, noVerify bool) error { - if m.PackageVersion == 2 || 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) - } - - return nil -} - type writeDriverManifestMsg struct { DriverInfo config.DriverInfo } +type installVerificationFailedMsg struct { + err error +} + type localInstallMsg struct{} // alreadyInstalledChecksumMsg carries the checksum computed for an already-installed driver. @@ -484,24 +453,19 @@ func (m progressiveInstallModel) startInstalling(downloaded *os.File) (tea.Model } defer downloaded.Close() - if m.conflictingInfo.ID != "" { - if err := config.UninstallDriver(m.cfg, m.conflictingInfo); err != nil { - return err - } - } - var ( - manifest config.Manifest - err error + expected config.ExpectedPackageMetadata + runtimeID string + verify func(string, config.Manifest) error + err error ) if !m.isLocal { - expected, hasMetadata, metadataErr := expectedRegistryPackageMetadata(m.DriverPackage) - if metadataErr != nil { - return metadataErr + var hasMetadata bool + expected, hasMetadata, err = expectedRegistryPackageMetadata(m.DriverPackage) + if err != nil { + return err } - if hasMetadata { - manifest, err = config.InstallPackageArchive(m.cfg, downloaded, expected) - } else { + if !hasMetadata { packageManifest, inspectErr := config.InspectPackageManifest(downloaded) if inspectErr != nil { return inspectErr @@ -509,13 +473,46 @@ func (m progressiveInstallModel) startInstalling(downloaded *os.File) (tea.Model if packageManifest.PackageVersion == 2 { return errors.New("registry package v2 requires archive hash and size metadata") } - manifest, err = config.InstallDriver(m.cfg, m.Driver, downloaded) } + runtimeID = expected.ID } else { - manifest, err = config.InstallDriver(m.cfg, m.Driver, downloaded) + packageManifest, inspectErr := config.InspectPackageManifest(downloaded) + if inspectErr != nil { + return inspectErr + } + runtimeID = m.Driver + if packageManifest.PackageVersion == 2 { + runtimeID = packageManifest.ID + } + if runtimeID == "" { + return errors.New("local package has no runtime driver id") + } + version := "" + if packageManifest.Version != nil { + version = packageManifest.Version.String() + } + sourcePath, absErr := filepath.Abs(downloaded.Name()) + if absErr != nil { + return fmt.Errorf("could not resolve local package path: %w", absErr) + } + expected = config.ExpectedPackageMetadata{ + ID: runtimeID, Version: version, Platform: config.PlatformTuple(), + SourceType: "local", SourceIdentity: sourcePath, + } + } + if !m.NoVerify { + verify = func(stagingDir string, manifest config.Manifest) error { + return dbc.VerifyPackageSignature(stagingDir, manifest) + } } + manifest, err := config.InstallPackage(m.cfg, runtimeID, downloaded, expected, config.InstallOptions{ + Verify: verify, + }) if err != nil { - return err + if isPackageVerificationFailure(err) { + return installVerificationFailedMsg{err: packageVerificationError(err)} + } + return packageVerificationError(err) } return manifest } @@ -527,9 +524,6 @@ func expectedRegistryPackageMetadata(pkg dbc.PkgInfo) (config.ExpectedPackageMet if hasHash != hasSize { return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata must include both archive hash and size") } - if !hasHash { - return config.ExpectedPackageMetadata{}, false, nil - } if pkg.Version == nil { return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata is missing its version") } @@ -542,11 +536,42 @@ func expectedRegistryPackageMetadata(pkg dbc.PkgInfo) (config.ExpectedPackageMet if strings.TrimSpace(pkg.Driver.Path) == "" { return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata is missing its driver ID") } - return config.ExpectedPackageMetadata{ - ID: pkg.Driver.Path, Version: pkg.Version.String(), Platform: pkg.PlatformTuple, + expected := config.ExpectedPackageMetadata{ + ID: pkg.Driver.Path, + Version: pkg.Version.String(), + Platform: pkg.PlatformTuple, SourceType: "registry", SourceIdentity: pkg.Driver.Registry.BaseURL.String(), - ArchiveHash: pkg.ArtifactHash, ArchiveSize: *pkg.ArtifactSize, - }, true, nil + } + if hasHash { + expected.ArchiveHash = pkg.ArtifactHash + expected.ArchiveSize = *pkg.ArtifactSize + } + return expected, hasHash, nil +} + +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 isPackageVerificationFailure(err error) bool { + return strings.HasPrefix(err.Error(), "package verification failed: ") +} + +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) { @@ -607,17 +632,13 @@ func (m progressiveInstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.DriverPackage.Version == nil { m.DriverPackage = manifestToPackageInfo(msg) } + m.Driver = msg.ID 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 - } return writeDriverManifestMsg{DriverInfo: msg.DriverInfo} } case writeDriverManifestMsg: @@ -625,19 +646,13 @@ func (m progressiveInstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.installedDriverInfo = msg.DriverInfo m = m.addEvent("verify.complete") m = m.addEvent("manifest.create") - return m, tea.Sequence(func() tea.Msg { - return config.CreateManifest(m.cfg, msg.DriverInfo) - }, tea.Quit) + return m, tea.Quit + case installVerificationFailedMsg: + m = m.addEvent("extract.complete") + m = m.addEvent("verify.start") + return m.fail(msg.err) 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.fail(msg) } base, cmd := m.baseModel.Update(msg) diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index 419a1ad4..6dfa74b5 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -100,9 +100,15 @@ func (suite *SubcommandTestSuite) TestReinstallUpdateVersion() { "\nRemoved conflicting driver: test-driver-1 (version: 1.0.0)\nInstalled test-driver-1 1.1.0 to "+suite.tempdir, suite.runCmd(m)) - suite.Equal([]string{"test-driver-1.1/dbc-install-receipt.json", - "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: config.ConfigEnv, 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) + suite.Equal([]string{filepath.ToSlash(filepath.Join(relDir, "dbc-install-receipt.json")), + relLibrary, relLibrary + ".sig", "test-driver-1.toml"}, suite.getFilesInTempDir()) } func (suite *SubcommandTestSuite) TestReinstallDowngradeVersion() { @@ -111,6 +117,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.Config{Level: config.ConfigEnv, Location: suite.Dir()}, "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()) @@ -119,9 +128,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.Config{Level: config.ConfigEnv, Location: suite.Dir()}, "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) } @@ -203,7 +219,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() { @@ -545,6 +561,73 @@ func openInstallArchive(t *testing.T, data []byte) *os.File { return f } +func TestInstallSignatureFailurePreservesExistingInstallation(t *testing.T) { + oldArchive, err := os.ReadFile(filepath.Join("testdata", "test-driver-1.tar.gz")) + if err != nil { + t.Fatal(err) + } + oldExpected := config.ExpectedPackageMetadata{ + ID: "test-driver-1", Version: "1.0.0", Platform: config.PlatformTuple(), + SourceType: "registry", SourceIdentity: testRegistry.BaseURL.String(), + } + root := t.TempDir() + cfg := config.Config{Level: config.ConfigEnv, Location: root} + oldManifest, err := config.InstallPackage(cfg, "test-driver-1", openInstallArchive(t, oldArchive), oldExpected, config.InstallOptions{ + Verify: func(stagingDir string, manifest config.Manifest) error { + return dbc.VerifyPackageSignature(stagingDir, manifest) + }, + }) + if err != nil { + t.Fatal(err) + } + oldManifestBytes, err := os.ReadFile(filepath.Join(root, "test-driver-1.toml")) + if err != nil { + t.Fatal(err) + } + oldLibraryPath := oldManifest.Driver.Shared.Get(config.PlatformTuple()) + oldLibraryBytes, err := os.ReadFile(oldLibraryPath) + if err != nil { + t.Fatal(err) + } + + badArchive, err := os.ReadFile(filepath.Join("testdata", "test-driver-no-sig.tar.gz")) + if err != nil { + t.Fatal(err) + } + model := progressiveInstallModel{ + Driver: "test-driver-1", + cfg: cfg, + DriverPackage: dbc.PkgInfo{ + Driver: dbc.Driver{Path: "test-driver-1", Registry: &testRegistry}, + Version: semver.MustParse("1.1.0"), + PlatformTuple: config.PlatformTuple(), + }, + } + _, install := model.startInstalling(openInstallArchive(t, badArchive)) + message := install() + installErr, ok := message.(error) + if !ok { + t.Fatalf("startInstalling returned %T, want signature error", message) + } + if !strings.Contains(installErr.Error(), "signature file 'test-driver-1-not-valid.so.sig' for driver is missing") { + t.Fatalf("unexpected install error: %v", installErr) + } + manifestBytes, err := os.ReadFile(filepath.Join(root, "test-driver-1.toml")) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(oldManifestBytes, manifestBytes) { + t.Fatal("runtime manifest changed after signature failure") + } + libraryBytes, err := os.ReadFile(oldLibraryPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(oldLibraryBytes, libraryBytes) { + t.Fatal("installed library changed after signature failure") + } +} + func TestStartInstallingRegistryPackageV2RequiresAndUsesMetadata(t *testing.T) { archive := packageV2ArchiveForInstall(t, "example", "1.2.3", config.PlatformTuple()) digest := sha256.Sum256(archive) @@ -574,11 +657,11 @@ func TestStartInstallingRegistryPackageV2RequiresAndUsesMetadata(t *testing.T) { if manifest.PackageVersion != 2 { t.Fatalf("package marker = %d, want 2", manifest.PackageVersion) } - if err := verifySignature(manifest, false); err != nil { + if err := dbc.VerifyPackageSignature("", manifest); err != nil { t.Fatalf("package v2 must not require legacy PGP signature: %v", err) } var receipt config.InstallReceipt - receiptData, err := os.ReadFile(filepath.Join(root, "example", "dbc-install-receipt.json")) + receiptData, err := os.ReadFile(filepath.Join(filepath.Dir(manifest.Driver.Shared.Get(config.PlatformTuple())), "dbc-install-receipt.json")) if err != nil { t.Fatal(err) } @@ -611,8 +694,42 @@ func TestStartInstallingRegistryV2WithoutArchiveMetadataIsRejected(t *testing.T) if !ok || !strings.Contains(installErr.Error(), "requires archive hash and size metadata") { t.Fatalf("startInstalling returned %v, want missing metadata error", message) } - if _, err := os.Stat(filepath.Join(root, "example")); !os.IsNotExist(err) { - t.Fatalf("package was published despite missing registry metadata: stat error = %v", err) + if _, err := os.Stat(filepath.Join(root, "example.toml")); !os.IsNotExist(err) { + t.Fatalf("runtime manifest was published despite missing registry metadata: %v", err) + } +} + +func TestStartInstallingLocalV2UsesManifestID(t *testing.T) { + archive := packageV2ArchiveForInstall(t, "declared-driver", "1.2.3", config.PlatformTuple()) + root := t.TempDir() + packagePath := filepath.Join(root, "filename-driver.tar.gz") + if err := os.WriteFile(packagePath, archive, 0o600); err != nil { + t.Fatal(err) + } + downloaded, err := os.Open(packagePath) + if err != nil { + t.Fatal(err) + } + model := progressiveInstallModel{ + Driver: packagePath, + isLocal: true, + localPackagePath: packagePath, + cfg: config.Config{Level: config.ConfigEnv, Location: root}, + } + _, install := model.startInstalling(downloaded) + message := install() + manifest, ok := message.(config.Manifest) + if !ok { + t.Fatalf("startInstalling returned %T, want config.Manifest", message) + } + if manifest.ID != "declared-driver" { + t.Fatalf("installed runtime ID = %q, want MANIFEST ID", manifest.ID) + } + if _, err := os.Stat(filepath.Join(root, "declared-driver.toml")); err != nil { + t.Fatalf("runtime manifest for MANIFEST ID was not registered: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "filename-driver.toml")); !os.IsNotExist(err) { + t.Fatalf("filename-derived runtime manifest was unexpectedly registered: %v", err) } } 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/sync.go b/cmd/dbc/sync.go index 7a1b6b29..5d33cd12 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -21,7 +21,6 @@ import ( "io/fs" "net/url" "os" - "path" "path/filepath" "strings" "time" @@ -366,8 +365,6 @@ type alreadyInstalledDrvMsg struct { func (s syncModel) installDriver(cfg config.Config, item installItem) tea.Cmd { return func() tea.Msg { - // TODO: Factor this out into config package, remove duplication with - // config.InstallDriver var removedDriver *config.DriverInfo if cfg.Exists { // is driver installed already? @@ -379,10 +376,7 @@ func (s syncModel) installDriver(cfg config.Config, item installItem) tea.Cmd { } if item.Checksum != "" { - // A v1 checksum proves only the installed library for its - // recorded platform. A v2 archive digest is intentionally not - // compared with this installed file; a separate install receipt - // will be needed to establish that relationship. + // A lockfile checksum proves only the installed library. if chksum != item.Checksum { return fmt.Errorf("checksum mismatch for driver %s: %s != %s", item.Driver.Path, chksum, item.Checksum) @@ -400,9 +394,6 @@ func (s syncModel) installDriver(cfg config.Config, item installItem) tea.Cmd { return alreadyInstalledDrvMsg{info: drv, item: item} } else { - if err := config.UninstallDriver(cfg, drv); err != nil { - return fmt.Errorf("failed when deleting driver %s-%s: %w", drv.ID, drv.Version, err) - } removedDriver = &drv } } @@ -421,40 +412,37 @@ func (s syncModel) installDriver(cfg config.Config, item installItem) tea.Cmd { return } - var loc string - if loc, err = config.EnsureLocation(cfg); err != nil { - prog.Send(fmt.Errorf("failed to ensure config location: %w", err)) - return - } - - base := strings.TrimSuffix(path.Base(item.Package.Path.Path), ".tar.gz") - finalDir := filepath.Join(loc, base) - if err := os.MkdirAll(finalDir, 0o755); err != nil { - prog.Send(fmt.Errorf("failed to create driver directory %s: %w", finalDir, err)) - return - } - - output.Seek(0, io.SeekStart) - manifest, err := config.InflateTarball(output, finalDir) + expected, hasMetadata, err := expectedRegistryPackageMetadata(item.Package) if err != nil { - prog.Send(fmt.Errorf("failed to extract tarball: %w", err)) + prog.Send(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 !hasMetadata { + packageManifest, inspectErr := config.InspectPackageManifest(output) + if inspectErr != nil { + prog.Send(inspectErr) + return + } + if packageManifest.PackageVersion == 2 { + prog.Send(errors.New("registry package v2 requires archive hash and size metadata")) + return + } } - - if err := config.CreateManifest(cfg, manifest.DriverInfo); err != nil { - prog.Send(fmt.Errorf("failed to create driver manifest: %w", err)) + var verify func(string, config.Manifest) error + if !s.NoVerify { + verify = func(stagingDir string, manifest config.Manifest) error { + return dbc.VerifyPackageSignature(stagingDir, manifest) + } + } + manifest, err := config.InstallPackage(cfg, item.Driver.Path, output, expected, config.InstallOptions{ + Verify: verify, + }) + if err != nil { + if isPackageVerificationFailure(err) { + prog.Send(fmt.Errorf("failed to verify signature: %w", packageVerificationError(err))) + } else { + prog.Send(fmt.Errorf("failed to install driver: %w", err)) + } return } diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index e9f41027..749194a5 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -21,7 +21,9 @@ import ( "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/jsonschema" ) @@ -148,6 +150,46 @@ func (suite *SubcommandTestSuite) TestSyncInstallFailSig() { suite.Equal([]string{"dbc.toml"}, suite.getFilesInTempDir()) } +func (suite *SubcommandTestSuite) TestSyncSignatureFailurePreservesExistingInstallation() { + oldArchive, err := os.Open(filepath.Join("testdata", "test-driver-1.tar.gz")) + suite.Require().NoError(err) + cfg := config.Config{Level: config.ConfigEnv, Location: suite.Dir()} + oldManifest, err := config.InstallPackage(cfg, "test-driver-no-sig", oldArchive, config.ExpectedPackageMetadata{ + ID: "test-driver-no-sig", Version: "1.0.0", Platform: config.PlatformTuple(), + SourceType: "registry", SourceIdentity: testRegistry.BaseURL.String(), + }, config.InstallOptions{ + Verify: func(stagingDir string, manifest config.Manifest) error { + return dbc.VerifyPackageSignature(stagingDir, manifest) + }, + }) + suite.Require().NoError(err) + oldManifest.Version = semver.MustParse("0.9.0") + suite.Require().NoError(config.CreateManifest(cfg, oldManifest.DriverInfo)) + oldManifestBytes, err := os.ReadFile(filepath.Join(suite.Dir(), "test-driver-no-sig.toml")) + suite.Require().NoError(err) + oldLibraryPath := oldManifest.Driver.Shared.Get(config.PlatformTuple()) + oldLibraryBytes, err := os.ReadFile(oldLibraryPath) + suite.Require().NoError(err) + + 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()) + out := suite.runCmdErr(m) + suite.Contains(out, "signature file 'test-driver-1-not-valid.so.sig' for driver is missing") + + manifestBytes, err := os.ReadFile(filepath.Join(suite.Dir(), "test-driver-no-sig.toml")) + suite.Require().NoError(err) + suite.Equal(oldManifestBytes, manifestBytes) + libraryBytes, err := os.ReadFile(oldLibraryPath) + suite.Require().NoError(err) + suite.Equal(oldLibraryBytes, libraryBytes) + installed, err := config.GetDriver(cfg, "test-driver-no-sig") + suite.Require().NoError(err) + suite.Equal("0.9.0", installed.Version.String()) +} + func (suite *SubcommandTestSuite) TestSyncInstallNoVerify() { m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() suite.runCmd(m) 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 397bdca2..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 @@ -204,7 +214,7 @@ func (suite *SubcommandTestSuite) TestUninstallInvalidManifest() { suite.FileExists(manifestPath) // The legacy package explicitly declares a relative runtime load path. It must - // survive installation and remain separate from the package sidecar directory. + // remain separate from the managed package generation. type partialManifest struct { Driver struct { Shared map[string]string `toml:"shared"` @@ -218,6 +228,10 @@ func (suite *SubcommandTestSuite) TestUninstallInvalidManifest() { suite.Require().NoError(f.Close()) value := invalidManifest.Driver.Shared[config.PlatformTuple()] 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) @@ -227,16 +241,21 @@ func (suite *SubcommandTestSuite) TestUninstallInvalidManifest() { 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")) - // Legacy uninstall currently leaves the package sidecar and library because - // Driver.shared is a relative runtime name rather than an installed path. - packageDir := filepath.Join(suite.Dir(), "test-driver-invalid-manifest") - suite.DirExists(packageDir) - suite.FileExists(filepath.Join(packageDir, "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 6dbae4cc..c19c584e 100644 --- a/config/config.go +++ b/config/config.go @@ -403,3 +403,11 @@ func UninstallDriverShared(info DriverInfo) error { return nil } + +func cleanupUninstalledDriverPackages(cfg Config, info DriverInfo) { + location, err := uninstallPackageCleanupLocation(cfg, info) + if err != nil { + return + } + cleanupUninstalledManagedPackageDirectories(location, info.ID) +} diff --git a/config/dirs_unixlike.go b/config/dirs_unixlike.go index 8e019f0f..aeab2bb6 100644 --- a/config/dirs_unixlike.go +++ b/config/dirs_unixlike.go @@ -112,20 +112,23 @@ 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 err := UninstallDriverShared(info); err != nil { + return fmt.Errorf("failed to delete driver shared object: %w", err) + } + cleanupUninstalledDriverPackages(cfg, info) - return nil + return nil + }) } diff --git a/config/dirs_windows.go b/config/dirs_windows.go index 841e5177..7665bf03 100644 --- a/config/dirs_windows.go +++ b/config/dirs_windows.go @@ -404,29 +404,32 @@ func registrationFailure(writeErr, rollbackErr error) error { } func UninstallDriver(cfg Config, info DriverInfo) error { - if err := UninstallDriverShared(info); err != nil { - return fmt.Errorf("failed to delete driver shared object: %w", err) - } - - if cfg.Level != ConfigEnv { - k, err := registry.OpenKey(cfg.Level.key(), regKeyADBC, registry.ALL_ACCESS) - if err != nil { - return err + return uninstallDriverWithInstallLock(cfg, info, func() error { + 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) - } - } 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 cfg.Level != ConfigEnv { + k, err := registry.OpenKey(cfg.Level.key(), regKeyADBC, registry.ALL_ACCESS) + if err != nil { + return err + } + defer k.Close() - // TODO: Remove this when the driver managers are fixed (>=1.8.1). - removeManifestSymlink(info.FilePath, info.ID) - } + 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) + } - return nil + // TODO: Remove this when the driver managers are fixed (>=1.8.1). + removeManifestSymlink(info.FilePath, info.ID) + } + cleanupUninstalledDriverPackages(cfg, info) + + return nil + }) } diff --git a/config/install_transaction_test.go b/config/install_transaction_test.go index 02c2c38f..3c957914 100644 --- a/config/install_transaction_test.go +++ b/config/install_transaction_test.go @@ -29,6 +29,7 @@ import ( "strings" "sync" "testing" + "time" ) type installArchiveEntry struct { @@ -443,6 +444,233 @@ func TestInstallPackageConcurrentGenerationsShareDriverLock(t *testing.T) { } } +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) + } +} + +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 install destination", + cfg: Config{Level: ConfigEnv, Location: first + string(os.PathListSeparator) + second}, + info: DriverInfo{ID: "example", FilePath: second}, + want: first, + }, + { + 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) + } + }) + } +} + +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") diff --git a/config/package_archive.go b/config/package_archive.go index 3bc8adbb..12b92220 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -383,6 +383,62 @@ func acquireDriverInstallLock(location, runtimeID string) (func(), error) { return acquirePackageInstallLock(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 { + paths := splitConfigList(cfg.Location) + if len(paths) == 0 { + return "", errors.New("ADBC_DRIVER_PATH is empty, must be set to valid path to use") + } + location = paths[0] + } else if 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() + return uninstall() +} + func managedPackageDirectory(location, runtimeID string, info DriverInfo) (string, bool) { if info.Source != "dbc" { return "", false @@ -439,6 +495,26 @@ func cleanupManagedPackageDirectories(location, runtimeID, currentDir string, cu } } +func cleanupUninstalledManagedPackageDirectories(location, runtimeID string) { + if err := validateFlatName(runtimeID); err != nil { + return + } + entries, err := os.ReadDir(location) + if err != nil { + return + } + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), ".dbc-package-"+runtimeID+"-") { + continue + } + directory := filepath.Join(location, entry.Name()) + if _, ok := readManagedPackageReceipt(location, runtimeID, directory); !ok { + continue + } + _ = os.RemoveAll(directory) + } +} + func readManagedPackageReceipt(location, runtimeID, directory string) (InstallReceipt, bool) { var receipt InstallReceipt absLocation, err := filepath.Abs(location) diff --git a/package_signature.go b/package_signature.go new file mode 100644 index 00000000..d46f0a6a --- /dev/null +++ b/package_signature.go @@ -0,0 +1,58 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dbc + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/columnar-tech/dbc/config" +) + +// VerifyPackageSignature verifies a legacy package's library signature while +// the extracted package is still in its private staging directory. Version 2 +// packages and packages without a library file do not use legacy signatures. +func VerifyPackageSignature(stagingDir string, manifest config.Manifest) error { + if manifest.PackageVersion == 2 || 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 +} From 2f8ef693058ff320dcc50b32429031b26f655485 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 06:42:12 +0000 Subject: [PATCH 022/115] fix: coordinate legacy package replacement --- config/driver.go | 20 +- config/driver_test.go | 30 +++ config/install_transaction_test.go | 355 ++++++++++++++++++++++++++++- config/package_archive.go | 151 +++++++++++- 4 files changed, 544 insertions(+), 12 deletions(-) diff --git a/config/driver.go b/config/driver.go index b37300aa..8c1663b2 100644 --- a/config/driver.go +++ b/config/driver.go @@ -190,8 +190,24 @@ 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 + } + if !filepath.IsAbs(target) { + target = filepath.Join(parentDir, target) + } + expected, expectedErr := filepath.Abs(filepath.Join(filePath, safeDriverID+".toml")) + actual, actualErr := filepath.Abs(target) + if expectedErr == nil && actualErr == nil && filepath.Clean(actual) == filepath.Clean(expected) { + _ = os.Remove(symlink) } } diff --git a/config/driver_test.go b/config/driver_test.go index 6c94ee9a..092777be 100644 --- a/config/driver_test.go +++ b/config/driver_test.go @@ -108,6 +108,36 @@ 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 TestLoadDriverFromUnsupportedManifest(t *testing.T) { prefix := t.TempDir() driverName := "test_driver" diff --git a/config/install_transaction_test.go b/config/install_transaction_test.go index 3c957914..77d43f1b 100644 --- a/config/install_transaction_test.go +++ b/config/install_transaction_test.go @@ -26,10 +26,13 @@ import ( "io" "os" "path/filepath" + "runtime" "strings" "sync" "testing" "time" + + "github.com/Masterminds/semver/v3" ) type installArchiveEntry struct { @@ -532,10 +535,10 @@ func TestDriverInstallLockLocation(t *testing.T) { want string }{ { - name: "environment uses install destination", + name: "environment uses actual registration location", cfg: Config{Level: ConfigEnv, Location: first + string(os.PathListSeparator) + second}, info: DriverInfo{ID: "example", FilePath: second}, - want: first, + want: second, }, { name: "filesystem manifest uses its actual location", @@ -861,3 +864,351 @@ func countInstallGenerations(t *testing.T, location, runtimeID string) int { } 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 +} + +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/package_archive.go b/config/package_archive.go index 12b92220..9a0aa484 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -26,6 +26,7 @@ import ( "io/fs" "os" "path/filepath" + "reflect" "strings" "github.com/Masterminds/semver/v3" @@ -320,7 +321,8 @@ func installPackage(cfg Config, runtimeID string, downloaded *os.File, expected } defer releaseLock() - if _, err := loadInstalledDriver(cfg, loc, runtimeID); err != nil { + previous, err := loadInstalledDriver(cfg, loc, runtimeID) + if err != nil { return Manifest{}, fmt.Errorf("could not inspect existing driver registration: %w", err) } workDir, err := os.MkdirTemp(loc, ".dbc-install-") @@ -357,6 +359,7 @@ func installPackage(cfg Config, runtimeID string, downloaded *os.File, expected } cleanupManagedPackageDirectories(loc, runtimeID, finalDir, manifest.DriverInfo) + cleanupLegacyPackageDirectory(loc, runtimeID, previous, manifest.DriverInfo) return manifest, nil } @@ -386,13 +389,10 @@ func acquireDriverInstallLock(location, runtimeID string) (func(), error) { 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 { - paths := splitConfigList(cfg.Location) - if len(paths) == 0 { - return "", errors.New("ADBC_DRIVER_PATH is empty, must be set to valid path to use") - } - location = paths[0] - } else if registryPath || location == "" { + 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() @@ -436,9 +436,42 @@ func uninstallDriverWithInstallLock(cfg Config, info DriverInfo, uninstall func( 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 { + first.FilePath = normalizedRegistrationPath(first.FilePath) + second.FilePath = normalizedRegistrationPath(second.FilePath) + return reflect.DeepEqual(first, second) +} + +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) { if info.Source != "dbc" { return "", false @@ -495,6 +528,108 @@ func cleanupManagedPackageDirectories(location, runtimeID, currentDir string, cu } } +// cleanupLegacyPackageDirectory removes a pre-receipt package generation only +// when the previous runtime registration and its on-disk layout jointly prove +// ownership. Ambiguous or externally managed paths are deliberately retained. +func cleanupLegacyPackageDirectory(location, runtimeID string, previous *DriverInfo, current DriverInfo) { + if previous == nil || previous.Source != "dbc" || previous.ID != runtimeID || previous.Version == nil { + return + } + if validateFlatName(runtimeID) != nil { + return + } + root, err := filepath.Abs(location) + if err != nil { + return + } + rootInfo, err := os.Stat(root) + if err != nil || !rootInfo.IsDir() { + return + } + + 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{}) + 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 := os.RemoveAll(candidateAbs); err == nil { + removed[candidateAbs] = struct{}{} + } + } +} + +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 cleanupUninstalledManagedPackageDirectories(location, runtimeID string) { if err := validateFlatName(runtimeID); err != nil { return From 7e6606fa376cbda4639b9e7cc23476e77458990b Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 06:50:41 +0000 Subject: [PATCH 023/115] fix: normalize manifest symlink targets --- config/driver.go | 31 +++++++++++++++++++++----- config/driver_test.go | 51 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/config/driver.go b/config/driver.go index 8c1663b2..d8de84b0 100644 --- a/config/driver.go +++ b/config/driver.go @@ -180,7 +180,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) } } @@ -201,12 +210,24 @@ func removeManifestSymlink(filePath, driverID string) { if err != nil { return } + 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 := targetsExpectedManifest(target) if !filepath.IsAbs(target) { - target = filepath.Join(parentDir, target) + // New links are relative to their parent directory. Older links may + // contain the original relative manifestPath, interpreted from the + // process working directory when the link was created. + isTargetRegistration = isTargetRegistration || targetsExpectedManifest(filepath.Join(parentDir, target)) + legacyManifestPath := filepath.Join(filePath, safeDriverID+".toml") + isTargetRegistration = isTargetRegistration || filepath.Clean(target) == filepath.Clean(legacyManifestPath) } - expected, expectedErr := filepath.Abs(filepath.Join(filePath, safeDriverID+".toml")) - actual, actualErr := filepath.Abs(target) - if expectedErr == nil && actualErr == nil && filepath.Clean(actual) == filepath.Clean(expected) { + if isTargetRegistration { _ = os.Remove(symlink) } } diff --git a/config/driver_test.go b/config/driver_test.go index 092777be..f6d9371d 100644 --- a/config/driver_test.go +++ b/config/driver_test.go @@ -138,6 +138,57 @@ func TestRemoveManifestSymlinkOnlyRemovesTargetRegistration(t *testing.T) { assert.Equal(t, otherManifest, target) } +func TestManifestSymlinkHandlesRelativeNestedLocations(t *testing.T) { + root := t.TempDir() + 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 TestLoadDriverFromUnsupportedManifest(t *testing.T) { prefix := t.TempDir() driverName := "test_driver" From d949d20856a508dc24855de0b4dae8e776601fe0 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 06:55:30 +0000 Subject: [PATCH 024/115] fix: restrict legacy symlink cleanup --- config/driver.go | 15 +++++++++------ config/driver_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/config/driver.go b/config/driver.go index d8de84b0..360589df 100644 --- a/config/driver.go +++ b/config/driver.go @@ -218,14 +218,17 @@ func removeManifestSymlink(filePath, driverID string) { actual, err := filepath.Abs(candidate) return err == nil && filepath.Clean(actual) == filepath.Clean(expected) } - isTargetRegistration := targetsExpectedManifest(target) - if !filepath.IsAbs(target) { + 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, interpreted from the - // process working directory when the link was created. - isTargetRegistration = isTargetRegistration || targetsExpectedManifest(filepath.Join(parentDir, target)) + // 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. + isTargetRegistration = targetsExpectedManifest(filepath.Join(parentDir, target)) legacyManifestPath := filepath.Join(filePath, safeDriverID+".toml") - isTargetRegistration = isTargetRegistration || filepath.Clean(target) == filepath.Clean(legacyManifestPath) + isTargetRegistration = isTargetRegistration || target == legacyManifestPath } if isTargetRegistration { _ = os.Remove(symlink) diff --git a/config/driver_test.go b/config/driver_test.go index f6d9371d..56aa14c0 100644 --- a/config/driver_test.go +++ b/config/driver_test.go @@ -189,6 +189,41 @@ func TestManifestSymlinkHandlesRelativeNestedLocations(t *testing.T) { 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)) + 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" From ee6a2eec7532038a5a749cd05607b9ee9437aee5 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 07:13:07 +0000 Subject: [PATCH 025/115] fix: require ownership proof for package cleanup --- config/config.go | 50 ++------- config/config_api_test.go | 10 +- config/dirs_unixlike.go | 6 +- config/dirs_windows.go | 6 +- config/install_transaction_test.go | 168 +++++++++++++++++++++++++++++ config/package_archive.go | 46 ++------ 6 files changed, 204 insertions(+), 82 deletions(-) diff --git a/config/config.go b/config/config.go index c19c584e..b0a23bc0 100644 --- a/config/config.go +++ b/config/config.go @@ -333,6 +333,10 @@ func UninstallDriverShared(info DriverInfo) error { } else if strings.Contains(info.FilePath, "HKLM\\") { filesystemLocation = ConfigSystem.ConfigLocation() } + if info.Source == "dbc" { + cleanupOwnedPackageDirectories(filesystemLocation, info.ID, &info, "", DriverInfo{}) + return nil + } root, err := os.OpenRoot(filesystemLocation) if err != nil { @@ -350,49 +354,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) @@ -409,5 +381,5 @@ func cleanupUninstalledDriverPackages(cfg Config, info DriverInfo) { if err != nil { return } - cleanupUninstalledManagedPackageDirectories(location, info.ID) + cleanupOwnedPackageDirectories(location, info.ID, &info, "", DriverInfo{}) } 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 aeab2bb6..7efbf35b 100644 --- a/config/dirs_unixlike.go +++ b/config/dirs_unixlike.go @@ -124,8 +124,10 @@ func UninstallDriver(cfg Config, info DriverInfo) error { // 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) + } } cleanupUninstalledDriverPackages(cfg, info) diff --git a/config/dirs_windows.go b/config/dirs_windows.go index 7665bf03..cbcf8c2c 100644 --- a/config/dirs_windows.go +++ b/config/dirs_windows.go @@ -405,8 +405,10 @@ func registrationFailure(writeErr, rollbackErr error) error { func UninstallDriver(cfg Config, info DriverInfo) error { return uninstallDriverWithInstallLock(cfg, info, func() error { - 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 cfg.Level != ConfigEnv { diff --git a/config/install_transaction_test.go b/config/install_transaction_test.go index 77d43f1b..9da075f1 100644 --- a/config/install_transaction_test.go +++ b/config/install_transaction_test.go @@ -516,6 +516,9 @@ func TestUninstallDriverWaitsForInstallLock(t *testing.T) { 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) { @@ -684,6 +687,10 @@ func TestInstallPackageManifestOnlyKeepsExternalLibrary(t *testing.T) { 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" @@ -720,6 +727,167 @@ shared = %q 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 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) { + absRoot := filepath.Join(t.TempDir(), "nested", "drivers") + workingDirectory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + relativeRoot, err := filepath.Rel(workingDirectory, 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 TestManagedPackageDirectoryRequiresReceiptLibraryRelationship(t *testing.T) { diff --git a/config/package_archive.go b/config/package_archive.go index 9a0aa484..82692346 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -358,8 +358,7 @@ func installPackage(cfg Config, runtimeID string, downloaded *os.File, expected return Manifest{}, fmt.Errorf("could not register driver manifest: %w", err) } - cleanupManagedPackageDirectories(loc, runtimeID, finalDir, manifest.DriverInfo) - cleanupLegacyPackageDirectory(loc, runtimeID, previous, manifest.DriverInfo) + cleanupOwnedPackageDirectories(loc, runtimeID, previous, finalDir, manifest.DriverInfo) return manifest, nil } @@ -508,6 +507,13 @@ func managedPackageDirectory(location, runtimeID string, info DriverInfo) (strin } 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) { + if validateFlatName(runtimeID) != nil { + return + } entries, err := os.ReadDir(location) if err != nil { return @@ -518,7 +524,7 @@ func cleanupManagedPackageDirectories(location, runtimeID, currentDir string, cu continue } dir := filepath.Join(location, entry.Name()) - if filepath.Clean(dir) == currentDir || runtimeReferencesDirectory(current, dir) { + if (currentDir != "." && filepath.Clean(dir) == currentDir) || runtimeReferencesPackageDirectory(current, location, dir) { continue } if _, ok := readManagedPackageReceipt(location, runtimeID, dir); !ok { @@ -526,6 +532,7 @@ func cleanupManagedPackageDirectories(location, runtimeID, currentDir string, cu } _ = os.RemoveAll(dir) } + cleanupLegacyPackageDirectory(location, runtimeID, previous, current) } // cleanupLegacyPackageDirectory removes a pre-receipt package generation only @@ -630,26 +637,6 @@ func runtimeReferencesPackageDirectory(info DriverInfo, location, directory stri return false } -func cleanupUninstalledManagedPackageDirectories(location, runtimeID string) { - if err := validateFlatName(runtimeID); err != nil { - return - } - entries, err := os.ReadDir(location) - if err != nil { - return - } - for _, entry := range entries { - if !strings.HasPrefix(entry.Name(), ".dbc-package-"+runtimeID+"-") { - continue - } - directory := filepath.Join(location, entry.Name()) - if _, ok := readManagedPackageReceipt(location, runtimeID, directory); !ok { - continue - } - _ = os.RemoveAll(directory) - } -} - func readManagedPackageReceipt(location, runtimeID, directory string) (InstallReceipt, bool) { var receipt InstallReceipt absLocation, err := filepath.Abs(location) @@ -720,19 +707,6 @@ func parseReceiptMetadata(receipt InstallReceipt) error { return nil } -func runtimeReferencesDirectory(info DriverInfo, directory string) bool { - for sharedPath := range info.Driver.Shared.Paths() { - if sharedPath == "" { - continue - } - sharedPath = filepath.Clean(sharedPath) - if sharedPath == filepath.Clean(directory) || filepath.Dir(sharedPath) == filepath.Clean(directory) { - return true - } - } - return false -} - func validateExpectedPackage(expected ExpectedPackageMetadata) error { if err := validateFlatName(expected.ID); err != nil { return fmt.Errorf("invalid expected package id: %w", err) From a648873f4b8096814f22b5dc7af238934354da70 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 07:32:06 +0000 Subject: [PATCH 026/115] fix: clean up receipt-backed legacy installs --- config/install_transaction_test.go | 64 ++++++++++++++++++++++++++++++ config/package_archive.go | 46 ++++++++++++++++----- 2 files changed, 101 insertions(+), 9 deletions(-) diff --git a/config/install_transaction_test.go b/config/install_transaction_test.go index 9da075f1..ac44a626 100644 --- a/config/install_transaction_test.go +++ b/config/install_transaction_test.go @@ -890,6 +890,70 @@ func TestRelativeNestedEnvironmentInstallUpdateUninstall(t *testing.T) { } } +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} diff --git a/config/package_archive.go b/config/package_archive.go index 82692346..5848fb76 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -472,7 +472,7 @@ func normalizedRegistrationPath(path string) string { } func managedPackageDirectory(location, runtimeID string, info DriverInfo) (string, bool) { - if info.Source != "dbc" { + if info.Source != "dbc" || info.ID != runtimeID || info.Version == nil { return "", false } for sharedPath := range info.Driver.Shared.Paths() { @@ -482,9 +482,12 @@ func managedPackageDirectory(location, runtimeID string, info DriverInfo) (strin if !filepath.IsAbs(sharedPath) { sharedPath = filepath.Join(location, sharedPath) } - dir := filepath.Clean(filepath.Dir(sharedPath)) - receipt, ok := readManagedPackageReceipt(location, runtimeID, dir) - if !ok || receipt.InstalledLibrary == "" || info.ID != runtimeID || info.Version == nil || receipt.DriverVersion != info.Version.String() { + 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 := info.Driver.Shared.defaultPath @@ -514,17 +517,27 @@ func cleanupOwnedPackageDirectories(location, runtimeID string, previous *Driver if validateFlatName(runtimeID) != nil { return } + ownedReceiptDirectory := "" + if previous != nil { + if directory, ok := managedPackageDirectory(location, runtimeID, *previous); ok && !packageDirectoryIsCurrent(location, currentDir, current, directory) { + ownedReceiptDirectory = filepath.Clean(directory) + _ = os.RemoveAll(ownedReceiptDirectory) + } + } entries, err := os.ReadDir(location) if err != nil { return } - currentDir = filepath.Clean(currentDir) for _, entry := range entries { if !strings.HasPrefix(entry.Name(), ".dbc-package-"+runtimeID+"-") { continue } dir := filepath.Join(location, entry.Name()) - if (currentDir != "." && filepath.Clean(dir) == currentDir) || runtimeReferencesPackageDirectory(current, location, dir) { + absDir, err := filepath.Abs(dir) + if err != nil || (ownedReceiptDirectory != "" && filepath.Clean(absDir) == ownedReceiptDirectory) { + continue + } + if packageDirectoryIsCurrent(location, currentDir, current, absDir) { continue } if _, ok := readManagedPackageReceipt(location, runtimeID, dir); !ok { @@ -535,6 +548,17 @@ func cleanupOwnedPackageDirectories(location, runtimeID string, previous *Driver cleanupLegacyPackageDirectory(location, runtimeID, previous, current) } +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) +} + // cleanupLegacyPackageDirectory removes a pre-receipt package generation only // when the previous runtime registration and its on-disk layout jointly prove // ownership. Ambiguous or externally managed paths are deliberately retained. @@ -638,6 +662,13 @@ func runtimeReferencesPackageDirectory(info DriverInfo, location, directory stri } 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) { var receipt InstallReceipt absLocation, err := filepath.Abs(location) if err != nil { @@ -651,9 +682,6 @@ func readManagedPackageReceipt(location, runtimeID, directory string) (InstallRe if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || strings.Contains(rel, string(filepath.Separator)) { return receipt, false } - if !strings.HasPrefix(filepath.Base(absDirectory), ".dbc-package-"+runtimeID+"-") { - return receipt, false - } dirInfo, err := os.Lstat(absDirectory) if err != nil || !dirInfo.IsDir() || dirInfo.Mode()&os.ModeSymlink != 0 { return receipt, false From 698faafd4ca3636fb38cfc4e9e52e19829722200 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 07:41:58 +0000 Subject: [PATCH 027/115] test: align signature failure expectation --- cmd/dbc/install_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index 6dfa74b5..7bed3cf0 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -605,10 +605,11 @@ func TestInstallSignatureFailurePreservesExistingInstallation(t *testing.T) { } _, install := model.startInstalling(openInstallArchive(t, badArchive)) message := install() - installErr, ok := message.(error) + verificationFailure, ok := message.(installVerificationFailedMsg) if !ok { - t.Fatalf("startInstalling returned %T, want signature error", message) + t.Fatalf("startInstalling returned %T, want installVerificationFailedMsg", message) } + installErr := verificationFailure.err if !strings.Contains(installErr.Error(), "signature file 'test-driver-1-not-valid.so.sig' for driver is missing") { t.Fatalf("unexpected install error: %v", installErr) } From ec75b25a9b0830cf30e0c30a54e2d1e3687d8645 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 13:14:40 +0000 Subject: [PATCH 028/115] fix: report incomplete package cleanup --- config/config.go | 28 +++++-- config/dirs_unixlike.go | 4 +- config/dirs_windows.go | 4 +- config/install_transaction_test.go | 104 +++++++++++++++++++++++ config/package_archive.go | 130 +++++++++++++++++++---------- 5 files changed, 219 insertions(+), 51 deletions(-) diff --git a/config/config.go b/config/config.go index b0a23bc0..55deaf25 100644 --- a/config/config.go +++ b/config/config.go @@ -334,8 +334,7 @@ func UninstallDriverShared(info DriverInfo) error { filesystemLocation = ConfigSystem.ConfigLocation() } if info.Source == "dbc" { - cleanupOwnedPackageDirectories(filesystemLocation, info.ID, &info, "", DriverInfo{}) - return nil + return cleanupOwnedPackageDirectories(filesystemLocation, info.ID, &info, "", DriverInfo{}) } root, err := os.OpenRoot(filesystemLocation) @@ -376,10 +375,29 @@ func UninstallDriverShared(info DriverInfo) error { return nil } -func cleanupUninstalledDriverPackages(cfg Config, info DriverInfo) { +func cleanupUninstalledDriverPackagesWithRemoveAll(cfg Config, info DriverInfo, removeAll func(string) error) error { location, err := uninstallPackageCleanupLocation(cfg, info) if err != nil { - return + 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 } - cleanupOwnedPackageDirectories(location, info.ID, &info, "", DriverInfo{}) + return fmt.Errorf("driver registration was removed but owned package cleanup was incomplete: %w", err) } diff --git a/config/dirs_unixlike.go b/config/dirs_unixlike.go index 7efbf35b..fc86dc2f 100644 --- a/config/dirs_unixlike.go +++ b/config/dirs_unixlike.go @@ -129,7 +129,9 @@ func UninstallDriver(cfg Config, info DriverInfo) error { return fmt.Errorf("failed to delete driver shared object: %w", err) } } - cleanupUninstalledDriverPackages(cfg, info) + if err := cleanupUninstalledDriverPackagesAfterRegistrationRemoval(cfg, info); err != nil { + return err + } return nil }) diff --git a/config/dirs_windows.go b/config/dirs_windows.go index cbcf8c2c..50db781e 100644 --- a/config/dirs_windows.go +++ b/config/dirs_windows.go @@ -430,7 +430,9 @@ func UninstallDriver(cfg Config, info DriverInfo) error { // TODO: Remove this when the driver managers are fixed (>=1.8.1). removeManifestSymlink(info.FilePath, info.ID) } - cleanupUninstalledDriverPackages(cfg, info) + if err := cleanupUninstalledDriverPackagesAfterRegistrationRemoval(cfg, info); err != nil { + return err + } return nil }) diff --git a/config/install_transaction_test.go b/config/install_transaction_test.go index ac44a626..6106794e 100644 --- a/config/install_transaction_test.go +++ b/config/install_transaction_test.go @@ -27,6 +27,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "strings" "sync" "testing" @@ -383,6 +384,39 @@ func TestInstallPackageCleansOldGenerationOnlyAfterRegistration(t *testing.T) { } } +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} @@ -761,6 +795,76 @@ func TestUninstallDriverRemovesOnlyProvenLegacyPackageGeneration(t *testing.T) { } } +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 TestUninstallDriverRetainsUnprovenLegacyDirectories(t *testing.T) { for _, test := range []struct { name string diff --git a/config/package_archive.go b/config/package_archive.go index 5848fb76..e69f80b5 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -272,6 +272,10 @@ func InstallPackage(cfg Config, runtimeID string, downloaded *os.File, expected } 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") } @@ -358,7 +362,10 @@ func installPackage(cfg Config, runtimeID string, downloaded *os.File, expected return Manifest{}, fmt.Errorf("could not register driver manifest: %w", err) } - cleanupOwnedPackageDirectories(loc, runtimeID, previous, finalDir, manifest.DriverInfo) + // 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, nil } @@ -472,9 +479,19 @@ func normalizedRegistrationPath(path string) string { } func managedPackageDirectory(location, runtimeID string, info DriverInfo) (string, bool) { - if info.Source != "dbc" || info.ID != runtimeID || info.Version == nil { + 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 @@ -490,13 +507,12 @@ func managedPackageDirectory(location, runtimeID string, info DriverInfo) (strin if !ok || receipt.InstalledLibrary == "" || receipt.DriverVersion != info.Version.String() { continue } - registeredPath := info.Driver.Shared.defaultPath - if registeredPath == "" { - var found bool - registeredPath, found = info.Driver.Shared.platformMap[receipt.Platform] - if !found { - 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) @@ -504,48 +520,73 @@ func managedPackageDirectory(location, runtimeID string, info DriverInfo) (strin if filepath.Clean(registeredPath) != filepath.Clean(sharedPath) || filepath.Base(sharedPath) != receipt.InstalledLibrary { continue } - return dir, true + dir = filepath.Clean(dir) + if _, ok := seen[dir]; ok { + continue + } + seen[dir] = struct{}{} + directories = append(directories, dir) } - return "", false + return directories } func cleanupManagedPackageDirectories(location, runtimeID, currentDir string, current DriverInfo) { - cleanupOwnedPackageDirectories(location, runtimeID, nil, currentDir, current) + _ = cleanupOwnedPackageDirectories(location, runtimeID, nil, currentDir, current) } -func cleanupOwnedPackageDirectories(location, runtimeID string, previous *DriverInfo, currentDir string, current DriverInfo) { +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 + 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)) + } } - ownedReceiptDirectory := "" + candidates := make(map[string]struct{}) if previous != nil { - if directory, ok := managedPackageDirectory(location, runtimeID, *previous); ok && !packageDirectoryIsCurrent(location, currentDir, current, directory) { - ownedReceiptDirectory = filepath.Clean(directory) - _ = os.RemoveAll(ownedReceiptDirectory) + for _, directory := range managedPackageDirectories(location, runtimeID, *previous) { + if !packageDirectoryIsCurrent(location, currentDir, current, directory) { + directory = filepath.Clean(directory) + candidates[directory] = struct{}{} + } } } entries, err := os.ReadDir(location) if err != nil { - return - } - for _, entry := range entries { - if !strings.HasPrefix(entry.Name(), ".dbc-package-"+runtimeID+"-") { - continue + if !errors.Is(err, fs.ErrNotExist) { + cleanupErr = errors.Join(cleanupErr, fmt.Errorf("could not inspect package directory %s: %w", location, err)) } - dir := filepath.Join(location, entry.Name()) - absDir, err := filepath.Abs(dir) - if err != nil || (ownedReceiptDirectory != "" && filepath.Clean(absDir) == ownedReceiptDirectory) { - continue - } - if packageDirectoryIsCurrent(location, currentDir, current, absDir) { - continue - } - if _, ok := readManagedPackageReceipt(location, runtimeID, dir); !ok { - continue + } 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{}{} } - _ = os.RemoveAll(dir) } - cleanupLegacyPackageDirectory(location, runtimeID, previous, current) + 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 { @@ -559,23 +600,20 @@ func packageDirectoryIsCurrent(location, currentDir string, current DriverInfo, return runtimeReferencesPackageDirectory(current, location, directory) } -// cleanupLegacyPackageDirectory removes a pre-receipt package generation only -// when the previous runtime registration and its on-disk layout jointly prove -// ownership. Ambiguous or externally managed paths are deliberately retained. -func cleanupLegacyPackageDirectory(location, runtimeID string, previous *DriverInfo, current DriverInfo) { +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 + return nil } if validateFlatName(runtimeID) != nil { - return + return nil } root, err := filepath.Abs(location) if err != nil { - return + return nil } rootInfo, err := os.Stat(root) if err != nil || !rootInfo.IsDir() { - return + return nil } platformPaths := make(map[string]string) @@ -588,6 +626,7 @@ func cleanupLegacyPackageDirectory(location, runtimeID string, previous *DriverI } removed := make(map[string]struct{}) + var cleanupErr error for platform, sharedPath := range platformPaths { if sharedPath == "" || validatePlatformIdentifier(platform) != nil { continue @@ -623,10 +662,13 @@ func cleanupLegacyPackageDirectory(location, runtimeID string, previous *DriverI if !legacySharedFileIsOwned(root, candidateAbs, sharedPath) { continue } - if err := os.RemoveAll(candidateAbs); err == nil { + 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 { From db6ccde7afda60d5d71817f5ded89dab9553c3df Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 13:48:23 +0000 Subject: [PATCH 029/115] feat: separate package v2 metadata --- client_methods_test.go | 2 +- cmd/dbc/install_test.go | 8 +- config/driver.go | 5 +- config/install_transaction_test.go | 2 +- config/package_archive.go | 248 ++++++++++++++++++----------- config/package_archive_test.go | 138 +++++++++++++++- docs/guides/installing.md | 22 +++ 7 files changed, 321 insertions(+), 104 deletions(-) diff --git a/client_methods_test.go b/client_methods_test.go index 9c8b6459..6554fcf7 100644 --- a/client_methods_test.go +++ b/client_methods_test.go @@ -57,7 +57,7 @@ driver = "driver.so" for _, entry := range []struct { name string data []byte - }{{"MANIFEST", []byte(manifest)}, {"driver.so", []byte("v2 library")}} { + }{{"dbc-package.toml", []byte(manifest)}, {"driver.so", []byte("v2 library")}} { require.NoError(t, tw.WriteHeader(&tar.Header{Name: entry.name, Mode: 0o644, Size: int64(len(entry.data)), Typeflag: tar.TypeReg})) _, err := tw.Write(entry.data) require.NoError(t, err) diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index 7bed3cf0..bcf6dff0 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -529,7 +529,7 @@ driver = "driver.so" for _, entry := range []struct { name string data []byte - }{{"MANIFEST", []byte(manifest)}, {"driver.so", []byte("driver library")}} { + }{{"dbc-package.toml", []byte(manifest)}, {"driver.so", []byte("driver library")}} { if err := tw.WriteHeader(&tar.Header{Name: entry.name, Mode: 0o644, Size: int64(len(entry.data)), Typeflag: tar.TypeReg}); err != nil { t.Fatal(err) } @@ -700,7 +700,7 @@ func TestStartInstallingRegistryV2WithoutArchiveMetadataIsRejected(t *testing.T) } } -func TestStartInstallingLocalV2UsesManifestID(t *testing.T) { +func TestStartInstallingLocalV2UsesPackageMetadataID(t *testing.T) { archive := packageV2ArchiveForInstall(t, "declared-driver", "1.2.3", config.PlatformTuple()) root := t.TempDir() packagePath := filepath.Join(root, "filename-driver.tar.gz") @@ -724,10 +724,10 @@ func TestStartInstallingLocalV2UsesManifestID(t *testing.T) { t.Fatalf("startInstalling returned %T, want config.Manifest", message) } if manifest.ID != "declared-driver" { - t.Fatalf("installed runtime ID = %q, want MANIFEST ID", manifest.ID) + t.Fatalf("installed runtime ID = %q, want package metadata ID", manifest.ID) } if _, err := os.Stat(filepath.Join(root, "declared-driver.toml")); err != nil { - t.Fatalf("runtime manifest for MANIFEST ID was not registered: %v", err) + t.Fatalf("runtime manifest for package metadata ID was not registered: %v", err) } if _, err := os.Stat(filepath.Join(root, "filename-driver.toml")); !os.IsNotExist(err) { t.Fatalf("filename-derived runtime manifest was unexpectedly registered: %v", err) diff --git a/config/driver.go b/config/driver.go index 360589df..a7da6644 100644 --- a/config/driver.go +++ b/config/driver.go @@ -121,8 +121,9 @@ func (d driverMap) String() string { return sb.String() } -// runtimeManifestWire is the ADBC Driver Manager manifest format. Package -// MANIFEST files use packageManifestV2Wire/legacyPackageManifestWire instead. +// runtimeManifestWire is the installed ADBC Driver Manifest format. A legacy +// package's MANIFEST uses legacyPackageManifestWire, while dbc-package.toml +// uses packageManifestV2Wire. type runtimeManifestWire struct { PackageVersion *int64 `toml:"package_version,omitempty"` ManifestVersion int32 `toml:"manifest_version"` diff --git a/config/install_transaction_test.go b/config/install_transaction_test.go index 6106794e..3c891ced 100644 --- a/config/install_transaction_test.go +++ b/config/install_transaction_test.go @@ -56,7 +56,7 @@ entrypoint = "AdbcDriverExampleInit" driver = %q `, id, version, PlatformTuple(), library)) return makeInstallArchiveWithEntries(t, - installArchiveEntry{name: "MANIFEST", data: manifest}, + installArchiveEntry{name: "dbc-package.toml", data: manifest}, installArchiveEntry{name: library, data: libraryData}, ) } diff --git a/config/package_archive.go b/config/package_archive.go index e69f80b5..60e42d05 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -33,7 +33,12 @@ import ( "github.com/pelletier/go-toml/v2" ) -const installReceiptName = "dbc-install-receipt.json" +const ( + installReceiptName = "dbc-install-receipt.json" + legacyPackageManifestName = "MANIFEST" + packageV2MetadataName = "dbc-package.toml" + maxPackageMetadataSize = 1 << 20 +) // ExpectedPackageMetadata describes the resolution that selected an archive. // Hashes use the canonical form "sha256:". ArchiveSize is the @@ -121,69 +126,92 @@ type legacyPackageManifestWire struct { } `toml:"PostInstall,omitempty"` } -func decodePackageManifest(data []byte) (packageManifest, error) { +func decodePackageManifest(name string, data []byte) (packageManifest, error) { + switch name { + case packageV2MetadataName: + return decodePackageV2Metadata(data) + case legacyPackageManifestName: + return decodeLegacyPackageManifest(data) + default: + return packageManifest{}, fmt.Errorf("unsupported package metadata filename %q", name) + } +} + +func decodePackageV2Metadata(data []byte) (packageManifest, error) { var root map[string]any if err := toml.Unmarshal(data, &root); err != nil { - return packageManifest{}, fmt.Errorf("%w: error decoding package manifest: %v", ErrInvalidManifest, err) + return packageManifest{}, fmt.Errorf("%w: error decoding package v2 metadata: %v", ErrInvalidManifest, err) } - if rawVersion, hasDiscriminator := root["package_version"]; hasDiscriminator { - version, ok := rawVersion.(int64) - if !ok { - return packageManifest{}, fmt.Errorf("%w: package_version must be an integer", ErrInvalidManifest) - } - if version != 2 { - return packageManifest{}, fmt.Errorf("%w: package version %d is unsupported", ErrInvalidManifest, version) - } + rawVersion, hasDiscriminator := root["package_version"] + if !hasDiscriminator { + return packageManifest{}, fmt.Errorf("%w: package_version = 2 is required in %s", ErrInvalidManifest, packageV2MetadataName) + } + version, ok := rawVersion.(int64) + if !ok { + return packageManifest{}, fmt.Errorf("%w: package_version must be an integer", ErrInvalidManifest) + } + if version != 2 { + return packageManifest{}, fmt.Errorf("%w: package version %d is unsupported", ErrInvalidManifest, version) + } - var wire packageManifestV2Wire - if err := toml.Unmarshal(data, &wire); err != nil { - return packageManifest{}, fmt.Errorf("%w: error decoding package v2 manifest: %v", ErrInvalidManifest, err) - } - if wire.PackageVersion != 2 { - return packageManifest{}, fmt.Errorf("%w: package_version must be 2", ErrInvalidManifest) - } - if wire.ManifestVersion != nil { - return packageManifest{}, fmt.Errorf("%w: package v2 must not set runtime manifest_version", ErrInvalidManifest) - } - if err := validateFlatName(wire.ID); err != nil { - return packageManifest{}, fmt.Errorf("%w: invalid package id: %v", ErrInvalidManifest, err) - } - if strings.TrimSpace(wire.Name) == "" { - return packageManifest{}, fmt.Errorf("%w: name is required", ErrInvalidManifest) - } - if wire.Version == nil { - return packageManifest{}, fmt.Errorf("%w: version is required", ErrInvalidManifest) - } - if err := validatePlatformIdentifier(wire.Platform); err != nil { - return packageManifest{}, fmt.Errorf("%w: invalid platform: %v", ErrInvalidManifest, err) - } - if strings.TrimSpace(wire.Driver.Entrypoint) == "" { - return packageManifest{}, fmt.Errorf("%w: Driver.entrypoint is required", ErrInvalidManifest) - } - if err := validateFlatName(wire.Files.Driver); err != nil { - return packageManifest{}, fmt.Errorf("%w: invalid Files.driver: %v", ErrInvalidManifest, err) - } + var wire packageManifestV2Wire + if err := toml.Unmarshal(data, &wire); err != nil { + return packageManifest{}, fmt.Errorf("%w: error decoding package v2 metadata: %v", ErrInvalidManifest, err) + } + if wire.PackageVersion != 2 { + return packageManifest{}, fmt.Errorf("%w: package_version must be 2", ErrInvalidManifest) + } + if wire.ManifestVersion != nil { + return packageManifest{}, fmt.Errorf("%w: package v2 must not set runtime manifest_version", ErrInvalidManifest) + } + if err := validateFlatName(wire.ID); err != nil { + return packageManifest{}, fmt.Errorf("%w: invalid package id: %v", ErrInvalidManifest, err) + } + if strings.TrimSpace(wire.Name) == "" { + return packageManifest{}, fmt.Errorf("%w: name is required", ErrInvalidManifest) + } + if wire.Version == nil { + return packageManifest{}, fmt.Errorf("%w: version is required", ErrInvalidManifest) + } + if err := validatePlatformIdentifier(wire.Platform); err != nil { + return packageManifest{}, fmt.Errorf("%w: invalid platform: %v", ErrInvalidManifest, err) + } + if strings.TrimSpace(wire.Driver.Entrypoint) == "" { + return packageManifest{}, fmt.Errorf("%w: Driver.entrypoint is required", ErrInvalidManifest) + } + if err := validateFlatName(wire.Files.Driver); err != nil { + return packageManifest{}, fmt.Errorf("%w: invalid Files.driver: %v", ErrInvalidManifest, err) + } - return packageManifest{ - manifest: Manifest{ - PackageVersion: 2, - DriverInfo: DriverInfo{ - ID: wire.ID, - Name: wire.Name, - Version: wire.Version, - Driver: struct { - Entrypoint string - Shared driverMap - }{Entrypoint: wire.Driver.Entrypoint}, - }, - Files: struct { - Driver string `toml:"driver,omitempty"` - Signature string `toml:"signature,omitempty"` - }{Driver: wire.Files.Driver}, + return packageManifest{ + manifest: Manifest{ + PackageVersion: 2, + DriverInfo: DriverInfo{ + ID: wire.ID, + Name: wire.Name, + Version: wire.Version, + Driver: struct { + Entrypoint string + Shared driverMap + }{Entrypoint: wire.Driver.Entrypoint}, }, - id: wire.ID, platform: wire.Platform, v2: true, - }, nil + Files: struct { + Driver string `toml:"driver,omitempty"` + Signature string `toml:"signature,omitempty"` + }{Driver: wire.Files.Driver}, + }, + id: wire.ID, platform: wire.Platform, v2: true, + }, nil +} + +func decodeLegacyPackageManifest(data []byte) (packageManifest, error) { + var root map[string]any + if err := toml.Unmarshal(data, &root); err != nil { + return packageManifest{}, fmt.Errorf("%w: error decoding legacy package manifest: %v", ErrInvalidManifest, err) + } + if _, hasPackageVersion := root["package_version"]; hasPackageVersion { + return packageManifest{}, fmt.Errorf("%w: package_version is not allowed in legacy %s; use %s for package v2 metadata", ErrInvalidManifest, legacyPackageManifestName, packageV2MetadataName) } var wire legacyPackageManifestWire @@ -228,6 +256,34 @@ func decodePackageManifest(data []byte) (packageManifest, error) { }, nil } +func classifyPackageMetadataName(name string) (string, bool, error) { + for _, expected := range []string{legacyPackageManifestName, packageV2MetadataName} { + 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] + v2, hasV2 := metadata[packageV2MetadataName] + if hasLegacy && hasV2 { + return "", nil, fmt.Errorf("package archive must contain either %s or %s, not both", legacyPackageManifestName, packageV2MetadataName) + } + if hasLegacy { + return legacyPackageManifestName, legacy, nil + } + if hasV2 { + return packageV2MetadataName, v2, nil + } + return "", nil, fmt.Errorf("package archive must contain exactly one of %s or %s", legacyPackageManifestName, packageV2MetadataName) +} + func decodeDriverShared(value any, required bool) (driverMap, error) { var result driverMap switch shared := value.(type) { @@ -806,10 +862,10 @@ func validateExpectedPackage(expected ExpectedPackageMetadata) error { return nil } -// InspectPackageManifest decodes the archive's MANIFEST without installing -// files. It is intended to classify legacy and versioned package wire formats -// before choosing an install policy; the installer still validates the full -// archive before publication. The archive remains open for the caller. +// InspectPackageManifest decodes the archive's package metadata without +// installing files. It is intended to classify legacy and versioned package +// formats before choosing an install policy; the installer still validates +// the full archive before publication. The archive remains open for the caller. func InspectPackageManifest(downloaded *os.File) (Manifest, error) { if downloaded == nil { return Manifest{}, errors.New("package archive is nil") @@ -835,10 +891,11 @@ func InspectPackageManifest(downloaded *os.File) (Manifest, error) { 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) { - return Manifest{}, errors.New("package archive has no MANIFEST") + break } if err != nil { return Manifest{}, fmt.Errorf("error reading package archive: %w", err) @@ -857,31 +914,37 @@ func InspectPackageManifest(downloaded *os.File) (Manifest, error) { return Manifest{}, fmt.Errorf("archive entries %q and %q collide by name", previous, header.Name) } seen[folded] = header.Name - if strings.EqualFold(header.Name, "MANIFEST") && header.Name != "MANIFEST" { - return Manifest{}, errors.New("package manifest must be named exactly MANIFEST") + metadataName, isMetadata, err := classifyPackageMetadataName(header.Name) + if err != nil { + return Manifest{}, err } - if header.Name != "MANIFEST" { + 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 > 1<<20 { - return Manifest{}, errors.New("package manifest size is invalid or exceeds 1 MiB") + 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, (1<<20)+1)) + data, err := io.ReadAll(io.LimitReader(reader, maxPackageMetadataSize+1)) if err != nil { - return Manifest{}, fmt.Errorf("could not read package manifest: %w", err) + return Manifest{}, fmt.Errorf("could not read package metadata: %w", err) } if int64(len(data)) != header.Size { - return Manifest{}, errors.New("package manifest size does not match its tar header") - } - manifest, err := decodePackageManifest(data) - if err != nil { - return Manifest{}, err + return Manifest{}, errors.New("package metadata size does not match its tar header") } - return manifest.manifest, nil + 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.manifest, nil } func installPackageArchive(cfg Config, targetName, runtimeID string, downloaded *os.File, expected ExpectedPackageMetadata) (Manifest, error) { @@ -1065,8 +1128,7 @@ func extractPackageArchive(archivePath, payloadDir string) (Manifest, packageMan defer gz.Close() reader := tar.NewReader(gz) seen := make(map[string]string) - manifestCount := 0 - var parsed packageManifest + metadata := make(map[string][]byte) files := make(map[string]string) for { header, err := reader.Next() @@ -1098,25 +1160,22 @@ func extractPackageArchive(archivePath, payloadDir string) (Manifest, packageMan return empty, packageManifest{}, nil, fmt.Errorf("archive entries %q and %q collide by name", previous, header.Name) } seen[folded] = header.Name - if strings.EqualFold(header.Name, "MANIFEST") && header.Name != "MANIFEST" { - return empty, packageManifest{}, nil, fmt.Errorf("package manifest must be named exactly MANIFEST") + metadataName, isMetadata, err := classifyPackageMetadataName(header.Name) + if err != nil { + return empty, packageManifest{}, nil, err } - if header.Name == "MANIFEST" { - manifestCount++ - if header.Size > 1<<20 { - return empty, packageManifest{}, nil, errors.New("package manifest exceeds 1 MiB") + if isMetadata { + if header.Size > maxPackageMetadataSize { + return empty, packageManifest{}, nil, errors.New("package metadata exceeds 1 MiB") } - data, err := io.ReadAll(io.LimitReader(reader, (1<<20)+1)) + data, err := io.ReadAll(io.LimitReader(reader, maxPackageMetadataSize+1)) if err != nil { - return empty, packageManifest{}, nil, fmt.Errorf("could not read package manifest: %w", err) + return empty, packageManifest{}, nil, fmt.Errorf("could not read package metadata: %w", err) } if int64(len(data)) != header.Size { - return empty, packageManifest{}, nil, errors.New("package manifest size does not match its tar header") - } - parsed, err = decodePackageManifest(data) - if err != nil { - return empty, packageManifest{}, nil, err + return empty, packageManifest{}, nil, errors.New("package metadata size does not match its tar header") } + metadata[metadataName] = data continue } filePath := filepath.Join(payloadDir, header.Name) @@ -1144,8 +1203,13 @@ func extractPackageArchive(archivePath, payloadDir string) (Manifest, packageMan } files[folded] = header.Name } - if manifestCount != 1 { - return empty, packageManifest{}, nil, fmt.Errorf("package archive must contain exactly one MANIFEST; found %d", manifestCount) + metadataName, data, err := selectPackageMetadata(metadata) + if err != nil { + return empty, packageManifest{}, nil, err + } + parsed, err := decodePackageManifest(metadataName, data) + if err != nil { + return empty, packageManifest{}, nil, err } if _, err := io.Copy(io.Discard, gz); err != nil { return empty, packageManifest{}, nil, fmt.Errorf("could not verify gzip stream: %w", err) diff --git a/config/package_archive_test.go b/config/package_archive_test.go index 0c7a9c56..40332fe4 100644 --- a/config/package_archive_test.go +++ b/config/package_archive_test.go @@ -110,7 +110,7 @@ func validV2Archive(t *testing.T, contents []byte) []byte { t.Helper() manifest := packageV2Manifest("example", "1.2.3", config.PlatformTuple(), "libexample.so") return makePackageArchive(t, - archiveEntry{name: "MANIFEST", data: manifest}, + archiveEntry{name: "dbc-package.toml", data: manifest}, archiveEntry{name: "libexample.so", data: contents}, archiveEntry{name: "LICENSE", data: []byte("license")}, ) @@ -130,7 +130,7 @@ func TestPackageArchiveManifestVersions(t *testing.T) { t.Run("package v2 is decoded separately from runtime v1", func(t *testing.T) { data := makePackageArchive(t, - archiveEntry{name: "MANIFEST", data: packageV2Manifest("example", "1.2.3", "linux_amd64", "libexample.so")}, + archiveEntry{name: "dbc-package.toml", data: packageV2Manifest("example", "1.2.3", "linux_amd64", "libexample.so")}, archiveEntry{name: "libexample.so", data: []byte("library")}, ) f := openPackageArchive(t, data) @@ -160,7 +160,7 @@ func TestPackageArchiveManifestVersions(t *testing.T) { t.Run("unknown discriminator does not fall back to legacy", func(t *testing.T) { manifest := []byte("package_version = 3\nname = 'Legacy-looking name'\nversion = '1.0.0'\n\n[Files]\ndriver = 'driver.so'\n") data := makePackageArchive(t, - archiveEntry{name: "MANIFEST", data: manifest}, + archiveEntry{name: "dbc-package.toml", data: manifest}, archiveEntry{name: "driver.so", data: []byte("library")}, ) f := openPackageArchive(t, data) @@ -172,7 +172,7 @@ func TestPackageArchiveManifestVersions(t *testing.T) { t.Run("malformed discriminator does not fall back to legacy", func(t *testing.T) { manifest := []byte("package_version = '2'\nname = 'Legacy-looking name'\nversion = '1.0.0'\n\n[Files]\ndriver = 'driver.so'\n") data := makePackageArchive(t, - archiveEntry{name: "MANIFEST", data: manifest}, + archiveEntry{name: "dbc-package.toml", data: manifest}, archiveEntry{name: "driver.so", data: []byte("library")}, ) f := openPackageArchive(t, data) @@ -234,6 +234,136 @@ entrypoint = "AdbcDriverLegacyTableInit" }) } +func TestPackageArchiveMetadataFilenames(t *testing.T) { + legacy := []byte("name = 'Legacy Driver'\nversion = '1.0.0'\n") + v2 := packageV2Manifest("example", "1.2.3", config.PlatformTuple(), "driver.so") + v2WithRuntimeVersion := bytes.Replace(v2, []byte("id = "), []byte("manifest_version = 1\nid = "), 1) + tests := []struct { + name string + entries []archiveEntry + wantMessage string + }{ + { + name: "missing metadata", + entries: []archiveEntry{{name: "driver.so", data: []byte("library")}}, + wantMessage: "must contain exactly one of MANIFEST or dbc-package.toml", + }, + { + name: "both formats", + entries: []archiveEntry{ + {name: "MANIFEST", data: legacy}, + {name: "dbc-package.toml", data: v2}, + {name: "driver.so", data: []byte("library")}, + }, + wantMessage: "not both", + }, + { + name: "legacy filename requires legacy format", + entries: []archiveEntry{{name: "MANIFEST", data: v2}, {name: "driver.so", data: []byte("library")}}, + wantMessage: "package_version is not allowed in legacy MANIFEST", + }, + { + name: "v2 filename requires package version", + entries: []archiveEntry{{name: "dbc-package.toml", data: []byte("name = 'Legacy-looking name'\nversion = '1.0.0'\n")}, {name: "driver.so", data: []byte("library")}}, + wantMessage: "package_version = 2 is required", + }, + { + name: "v2 metadata rejects runtime manifest version", + entries: []archiveEntry{{name: "dbc-package.toml", data: v2WithRuntimeVersion}, {name: "driver.so", data: []byte("library")}}, + wantMessage: "must not set runtime manifest_version", + }, + { + name: "legacy filename case mismatch", + entries: []archiveEntry{{name: "manifest", data: legacy}}, + wantMessage: `must be named exactly "MANIFEST"`, + }, + { + name: "v2 filename case mismatch", + entries: []archiveEntry{{name: "dbc-Package.toml", data: v2}}, + 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", + }, + { + name: "v2 case collision", + entries: []archiveEntry{ + {name: "dbc-package.toml", data: v2}, + {name: "DBC-PACKAGE.TOML", data: v2}, + }, + wantMessage: "collide by name", + }, + { + name: "duplicate v2 metadata", + entries: []archiveEntry{ + {name: "dbc-package.toml", data: v2}, + {name: "dbc-package.toml", data: v2}, + }, + 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) error{ + "inspect": func(file *os.File) error { + _, err := config.InspectPackageManifest(file) + return err + }, + "extract": func(file *os.File) error { + _, err := config.InflateTarball(file, t.TempDir()) + return err + }, + } { + t.Run(name, func(t *testing.T) { + err := inspect(openPackageArchive(t, archive)) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantMessage) + }) + } + }) + } +} + +func TestInspectPackageManifestScansPastMetadata(t *testing.T) { + archive := makePackageArchive(t, + archiveEntry{name: "dbc-package.toml", data: packageV2Manifest("example", "1.2.3", config.PlatformTuple(), "driver.so")}, + archiveEntry{name: "driver.so", data: []byte("library")}, + archiveEntry{name: "nested/file", data: []byte("unsafe")}, + ) + _, err := config.InspectPackageManifest(openPackageArchive(t, archive)) + require.Error(t, err) + assert.Contains(t, err.Error(), "path separators") +} + +func TestPackageMetadataSizeLimitAppliesToBothFormats(t *testing.T) { + oversized := bytes.Repeat([]byte("x"), (1<<20)+1) + for _, name := range []string{"MANIFEST", "dbc-package.toml"} { + t.Run(name, func(t *testing.T) { + archive := makePackageArchive(t, archiveEntry{name: name, data: oversized}) + for _, inspect := range []func(*os.File) error{ + func(file *os.File) error { + _, err := config.InspectPackageManifest(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) diff --git a/docs/guides/installing.md b/docs/guides/installing.md index 1c54353d..ccaa1077 100644 --- a/docs/guides/installing.md +++ b/docs/guides/installing.md @@ -269,6 +269,28 @@ Installed mysql 0.1.0 to /opt/homebrew/Caskroom/miniforge/base/envs/my-adbc-proj 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. +### Package archive metadata + +The dbc 0.4.0 package format uses a root-level file named exactly `dbc-package.toml` with `package_version = 2` to describe its contents. The dbc 0.3.x format uses a root-level file named exactly `MANIFEST` and remains accepted for compatibility. These package metadata files are separate from Packslip release metadata and the installed `.toml` ADBC Driver Manifest (`manifest_version = 1`). + +For example, `dbc-package.toml` can contain: + +```toml +package_version = 2 +id = "example" +name = "Example ADBC Driver" +version = "1.0.0" +platform = "linux_amd64" + +[Driver] +entrypoint = "AdbcDriverExampleInit" + +[Files] +driver = "libadbc_driver_example.so" +``` + +The archive must be flat: place `dbc-package.toml` and the library file at its root. `id` is the installed runtime ID, `name` is the display name, `version` is a SemVer version, `platform` is the target tuple, `Driver.entrypoint` is the exported initialization symbol, and `Files.driver` names the library file in the archive root. + 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 From f83240e24330f5b6936fc03dde2bf1050f050cec Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 14:16:49 +0000 Subject: [PATCH 030/115] feat: canonicalize locked artifact targets --- cmd/dbc/lockfile.go | 110 ++++++++------- cmd/dbc/lockfile_v2.go | 112 +++++++--------- cmd/dbc/lockfile_v2_test.go | 145 ++++++++++++++++---- cmd/dbc/sync.go | 18 ++- internal/packslip/model.go | 80 ++++++----- internal/packslip/model_test.go | 6 + internal/packslip/resolver_native.go | 17 ++- internal/packslip/resolver_native_test.go | 16 ++- internal/packslip/types.go | 9 +- internal/resolution/types.go | 156 +++++++++++++++++++--- internal/resolution/types_test.go | 69 +++++++--- registry_artifact.go | 12 +- registry_artifact_test.go | 35 ++++- 13 files changed, 532 insertions(+), 253 deletions(-) diff --git a/cmd/dbc/lockfile.go b/cmd/dbc/lockfile.go index 2562ffe0..d21d7757 100644 --- a/cmd/dbc/lockfile.go +++ b/cmd/dbc/lockfile.go @@ -71,17 +71,21 @@ type lockEvidence struct { } type lockArtifact struct { - Platform string `toml:"platform,omitempty"` - OS string `toml:"os,omitempty"` - Arch string `toml:"arch,omitempty"` - LibC string `toml:"libc,omitempty"` - Variant string `toml:"variant,omitempty"` + Target resolution.Target `toml:"target"` Format string `toml:"format,omitempty"` URL string `toml:"url,omitempty"` Path string `toml:"path,omitempty"` Hash string `toml:"hash"` Size *int64 `toml:"size"` HostRequirements lockHostRequirements `toml:"host_requirements,omitempty"` + // These fields exist only to detect and reject obsolete v2 wire selectors. + // New lock entries never populate or serialize them; Target is the only + // artifact selector accepted for version 2 files. + LegacyPlatform *string `toml:"platform,omitempty"` + LegacyOS *string `toml:"os,omitempty"` + LegacyArch *string `toml:"arch,omitempty"` + LegacyLibC *string `toml:"libc,omitempty"` + LegacyVariant *string `toml:"variant,omitempty"` } type lockHostRequirements struct { @@ -195,7 +199,7 @@ func normalizeLockFileV1(wire lockFileV1) (LockFile, error) { } func (d lockInfo) legacyChecksumFor(platform string) string { - if d.Legacy == nil || d.Legacy.Platform != platform { + if d.Legacy == nil || !samePlatformTarget(d.Legacy.Platform, platform) { return "" } return d.Legacy.LibraryHash @@ -279,14 +283,18 @@ func validateHTTPURL(field, raw string) error { } func validateLockArtifacts(artifacts []lockArtifact) error { - seenSelectors := make(map[string]struct{}, len(artifacts)) - seenURLs := make(map[string]struct{}, len(artifacts)) + seenTargets := make(map[resolution.Target]struct{}, len(artifacts)) + seenLocations := make(map[string]lockArtifact, len(artifacts)) for i, artifact := range artifacts { - if artifact.Platform == "" && (artifact.OS == "" || artifact.Arch == "") { - return fmt.Errorf("artifact %d must identify a platform or both OS and architecture", i) + if artifact.LegacyPlatform != nil || artifact.LegacyOS != nil || artifact.LegacyArch != nil || + artifact.LegacyLibC != nil || artifact.LegacyVariant != nil { + return fmt.Errorf("artifact %d uses obsolete selector fields; use the target table", i) } - if (artifact.OS == "") != (artifact.Arch == "") { - return fmt.Errorf("artifact %d must provide OS and architecture together", i) + 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 (artifact.URL == "") == (artifact.Path == "") { return fmt.Errorf("artifact %d must have exactly one of URL or path", i) @@ -305,72 +313,60 @@ func validateLockArtifacts(artifacts []lockArtifact) error { return fmt.Errorf("artifact %d: %w", i, err) } } - selector := artifactSelectorIdentity(artifact) - if _, exists := seenSelectors[selector]; exists { - return fmt.Errorf("duplicate or ambiguous artifact selector %q", selector) + if _, exists := seenTargets[artifact.Target]; exists { + return fmt.Errorf("duplicate artifact target %q", artifactSelectorIdentity(artifact)) } - seenSelectors[selector] = struct{}{} - location := artifact.URL + seenTargets[artifact.Target] = struct{}{} + location := "url:" + artifact.URL if artifact.Path != "" { location = "path:" + artifact.Path } - if _, exists := seenURLs[location]; exists { - return fmt.Errorf("duplicate artifact location %q", location) + if prior, exists := seenLocations[location]; exists { + if prior.Hash != artifact.Hash || !sameLockSize(prior.Size, artifact.Size) { + return fmt.Errorf("artifacts sharing location %q have conflicting hash or size", location) + } + } else { + seenLocations[location] = artifact } - seenURLs[location] = struct{}{} } return nil } +func sameLockSize(left, right *int64) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + return *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 { - osName, arch, libc, variant := artifact.OS, artifact.Arch, artifact.LibC, artifact.Variant - if platformOS, platformArch, platformLibC, platformVariant, ok := parsePlatformTuple(artifact.Platform); ok { - if osName == "" { - osName = platformOS - } - if arch == "" { - arch = platformArch - } - if libc == "" { - libc = platformLibC - } - if variant == "" { - variant = platformVariant - } - } - if osName == "linux" && libc == "" { - libc = "gnu" - } - return fmt.Sprintf("%s\x00%s\x00%s\x00%s", osName, arch, libc, variant) + return targetIdentity(artifact.Target) } func parsePlatformTuple(platform string) (osName, arch, libc, variant string, ok bool) { - if platform == "" { - return "", "", "", "", false - } - parts := strings.Split(platform, "_") - if len(parts) < 2 { + target, err := resolution.TargetFromPlatformTuple(platform) + if err != nil { return "", "", "", "", false } - osName, arch = parts[0], parts[1] - for _, part := range parts[2:] { - switch part { - case "gnu", "musl": - libc = part - default: - if variant == "" { - variant = part - } else { - variant += "_" + part - } - } + 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 osName, arch, libc, variant, true + return leftTarget == rightTarget } func validateLegacyLibraryHash(hash string) error { diff --git a/cmd/dbc/lockfile_v2.go b/cmd/dbc/lockfile_v2.go index f60bb266..bbd21417 100644 --- a/cmd/dbc/lockfile_v2.go +++ b/cmd/dbc/lockfile_v2.go @@ -18,6 +18,7 @@ import ( "bytes" "errors" "fmt" + "reflect" "sort" "github.com/Masterminds/semver/v3" @@ -121,15 +122,11 @@ func lockInfoFromResolvedRelease(name string, release resolution.ResolvedRelease func lockArtifactFromResolved(artifact resolution.Artifact) lockArtifact { result := lockArtifact{ - Platform: artifact.Platform, - OS: artifact.OS, - Arch: artifact.Arch, - LibC: artifact.LibC, - Variant: artifact.Variant, - Format: artifact.Format, - URL: artifact.URL, - Hash: artifact.Hash, - Size: cloneInt64(artifact.Size), + Target: resolution.CanonicalTarget(artifact.Target), + Format: artifact.Format, + URL: artifact.URL, + Hash: artifact.Hash, + Size: cloneInt64(artifact.Size), HostRequirements: lockHostRequirements{ OSMin: artifact.HostRequirements.OSMin, GLibCMin: artifact.HostRequirements.GLibCMin, @@ -171,15 +168,11 @@ func (d lockInfo) resolvedRelease() resolution.ResolvedRelease { artifactURL = artifact.Path } resolved := resolution.Artifact{ - Platform: artifact.Platform, - OS: artifact.OS, - Arch: artifact.Arch, - LibC: artifact.LibC, - Variant: artifact.Variant, - Format: artifact.Format, - URL: artifactURL, - Hash: artifact.Hash, - Size: cloneInt64(artifact.Size), + Target: artifact.Target, + Format: artifact.Format, + URL: artifactURL, + Hash: artifact.Hash, + Size: cloneInt64(artifact.Size), HostRequirements: resolution.HostRequirements{ OSMin: artifact.HostRequirements.OSMin, GLibCMin: artifact.HostRequirements.GLibCMin, @@ -259,8 +252,8 @@ func migrateV1Entry(old lockInfo, release resolution.ResolvedRelease, currentPla 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 && old.Legacy.Platform == currentPlatform { - if verified == nil || verified.Platform != old.Legacy.Platform || verified.LibraryHash != old.Legacy.LibraryHash { + 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) } } @@ -278,7 +271,7 @@ func migrateV1Entry(old lockInfo, release resolution.ResolvedRelease, currentPla // 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 || entry.Legacy.Platform != platform { + if entry.Legacy == nil || !samePlatformTarget(entry.Legacy.Platform, platform) { return nil } if installedLibraryHash != entry.Legacy.LibraryHash { @@ -289,7 +282,7 @@ func verifyLegacyLibraryProof(entry lockInfo, platform, installedLibraryHash str // refreshLockEntry merges metadata for the same source and version. Existing // artifacts are immutable: a refresh may add a target but cannot replace an -// artifact whose digest or size contradicts the current lock. +// existing target's location, format, digest, size, or host requirements. func refreshLockEntry(existing, refreshed lockInfo) (lockInfo, error) { if existing.Name != refreshed.Name || existing.Version == nil || refreshed.Version == nil || !existing.Version.Equal(refreshed.Version) { return lockInfo{}, errors.New("metadata refresh must keep the locked driver version") @@ -313,7 +306,9 @@ func refreshLockEntry(existing, refreshed lockInfo) (lockInfo, error) { continue } found = true - if prior.Hash != candidate.Hash || *prior.Size != *candidate.Size { + if prior.URL != candidate.URL || prior.Path != candidate.Path || 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 @@ -333,6 +328,27 @@ func refreshLockEntry(existing, refreshed lockInfo) (lockInfo, error) { 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) { @@ -354,10 +370,13 @@ func upgradeLockEntry(existing lockInfo, upgraded lockInfo) (lockInfo, error) { // 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 - for _, artifact := range entry.Artifacts { - if artifactMatchesPlatform(artifact, platform) { - matches = append(matches, artifact) + if targetErr == nil { + for _, artifact := range entry.Artifacts { + if artifact.Target == target { + matches = append(matches, artifact) + } } } if len(matches) > 1 { @@ -372,47 +391,6 @@ func selectLockedArtifact(entry lockInfo, platform string, locked bool) (lockArt return lockArtifact{}, &LockRefreshRequiredError{DriverID: entry.Name, Platform: platform} } -func artifactMatchesPlatform(artifact lockArtifact, target string) bool { - targetOS, targetArch, targetLibC, targetVariant, ok := parsePlatformTuple(target) - if !ok { - return false - } - artifactOS, artifactArch, artifactLibC, artifactVariant, parsed := parsePlatformTuple(artifact.Platform) - if !parsed { - artifactOS, artifactArch, artifactLibC, artifactVariant = artifact.OS, artifact.Arch, "", "" - } - if artifact.OS != "" { - artifactOS = artifact.OS - } - if artifact.Arch != "" { - artifactArch = artifact.Arch - } - if artifact.LibC != "" { - artifactLibC = artifact.LibC - } - if artifact.Variant != "" { - artifactVariant = artifact.Variant - } - if artifactOS != targetOS || artifactArch != targetArch { - return false - } - // Existing dbc Linux tuples mean GNU by default; a musl build must not - // silently satisfy an ordinary linux_amd64 target. - if targetOS == "linux" && targetLibC == "" { - targetLibC = "gnu" - } - if targetOS == "linux" && artifactLibC == "" { - artifactLibC = "gnu" - } - if artifactLibC != targetLibC { - return false - } - if artifactVariant != "" && artifactVariant != targetVariant { - return false - } - return true -} - 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) diff --git a/cmd/dbc/lockfile_v2_test.go b/cmd/dbc/lockfile_v2_test.go index db073c24..0b792d46 100644 --- a/cmd/dbc/lockfile_v2_test.go +++ b/cmd/dbc/lockfile_v2_test.go @@ -34,14 +34,22 @@ import ( func testLockArtifact(platform, digest string, size int64) lockArtifact { return lockArtifact{ - Platform: platform, - URL: "https://example.test/" + platform + ".tar.gz", - Hash: "sha256:" + strings.Repeat(digest, 64), - Size: &size, - Format: "tar.gz", + Target: testTarget(platform), + URL: "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{ @@ -54,7 +62,7 @@ func testLockEntry() lockInfo { }, Artifacts: []lockArtifact{ { - Platform: "linux_amd64", OS: "linux", Arch: "amd64", LibC: "gnu", Variant: "v1", Format: "tar.gz", + Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu", Variant: "v1"}, Format: "tar.gz", URL: "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"}, @@ -62,7 +70,7 @@ func testLockEntry() lockInfo { }, }, { - Platform: "macos_arm64", OS: "macos", Arch: "arm64", Format: "tar.gz", + Target: resolution.Target{OS: "macos", Arch: "arm64"}, Format: "tar.gz", URL: "https://example.test/macos.tar.gz", Hash: "sha256:" + strings.Repeat("b", 64), Size: int64Pointer(256), }, }, @@ -78,7 +86,7 @@ func TestLockFileV2DeterministicRoundTripPreservesArtifactMetadata(t *testing.T) Name: "local", Version: semver.MustParse("0.1.0"), Source: lockSource{Type: "path", Path: "./packages/local.tar.gz"}, Artifacts: []lockArtifact{{ - Platform: "windows_amd64", Path: "./packages/local.tar.gz", Hash: "sha256:" + strings.Repeat("c", 64), Size: int64Pointer(0), Format: "tar.gz", + Target: resolution.Target{OS: "windows", Arch: "amd64"}, Path: "./packages/local.tar.gz", Hash: "sha256:" + strings.Repeat("c", 64), Size: int64Pointer(0), Format: "tar.gz", }}, }}} firstPath := filepath.Join(dir, "first.lock") @@ -98,12 +106,14 @@ func TestLockFileV2DeterministicRoundTripPreservesArtifactMetadata(t *testing.T) assert.Equal(t, lockFileVersion, loaded.Version) assert.Len(t, loaded.Drivers, 2) linux := loaded.lockinfo["example"].Artifacts[0] - assert.Equal(t, "gnu", linux.LibC) - assert.Equal(t, "v1", linux.Variant) + assert.Equal(t, "gnu", linux.Target.LibC) + assert.Equal(t, "v1", linux.Target.Variant) 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, "./packages/local.tar.gz", loaded.lockinfo["local"].Artifacts[0].Path) + assert.NotContains(t, string(first), "platform =") + assert.Contains(t, string(first), "[drivers.artifacts.target]") assert.Zero(t, *loaded.lockinfo["local"].Artifacts[0].Size, "an explicit zero size is distinct from an omitted size") localRelease := loaded.lockinfo["local"].resolvedRelease() localRoundTrip, err := lockInfoFromResolvedRelease("local", localRelease) @@ -118,6 +128,8 @@ func TestLockFileV2AllowsPartialArtifactSetAndReplayNeedsNoDiscovery(t *testing. 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 @@ -156,10 +168,38 @@ func TestLockFileV2UsesTypedSourceFieldsAndRequiresEvidencePairs(t *testing.T) { assert.Contains(t, err.Error(), "both bundle URL and hash") } +func TestLockFileV2RejectsObsoleteArtifactSelectorFields(t *testing.T) { + fields := []struct{ name, value string }{ + {name: "platform", value: `platform = "linux_amd64"`}, + {name: "os", value: `os = "linux"`}, + {name: "arch", value: `arch = "amd64"`}, + {name: "libc", value: `libc = "gnu"`}, + {name: "variant", value: `variant = "v1"`}, + } + for _, field := range fields { + for _, withTarget := range []bool{false, true} { + targetCase := "alone" + if withTarget { + targetCase = "with_target" + } + t.Run(field.name+"/"+targetCase, func(t *testing.T) { + contents := "version = 2\nrevision = 0\n\n[[drivers]]\nname = 'example'\nversion = '1.2.3'\n\n[drivers.source]\ntype = 'registry'\nurl = 'https://registry.example.test'\n\n[[drivers.artifacts]]\nformat = 'tar.gz'\nurl = 'https://assets.example.test/archive.tar.gz'\nhash = 'sha256:" + strings.Repeat("a", 64) + "'\nsize = 1\n" + field.value + "\n" + if withTarget { + contents += "[drivers.artifacts.target]\nos = 'linux'\narch = 'amd64'\nlibc = 'gnu'\n" + } + path := filepath.Join(t.TempDir(), "dbc.lock") + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + _, err := loadLockFile(path) + assert.ErrorContains(t, err, "obsolete selector fields") + }) + } + } +} + 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 = \"macos_arm64\"\nchecksum = \"" + legacyHash + "\"\n" + 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) @@ -175,9 +215,11 @@ func TestLockFileV1MigrationPreservesLibraryProofAndRequiresSamePlatformVerifica 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) @@ -188,7 +230,7 @@ func TestLockFileV1MigrationPreservesLibraryProofAndRequiresSamePlatformVerifica 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, "macos_arm64", otherPlatform.Legacy.Platform) + assert.Equal(t, "darwin_aarch64", otherPlatform.Legacy.Platform) } func TestAtomicLockWriterKeepsOldFileWhenReplacementFails(t *testing.T) { @@ -219,6 +261,61 @@ func TestRefreshRejectsArtifactContradictionAndAllowsNewTarget(t *testing.T) { assert.Len(t, merged.Artifacts, 3) } +func TestRefreshCannotReplaceSameTargetArtifactMetadata(t *testing.T) { + tests := []struct { + name string + mutate func(*lockArtifact) + }{ + {name: "location", mutate: func(a *lockArtifact) { a.URL = "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.URL = first.URL + assert.NoError(t, validateLockArtifacts([]lockArtifact{first, second})) + 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") +} + func TestVersionUpgradeIsSeparateFromMetadataRefresh(t *testing.T) { old := testLockEntry() old.Legacy = &legacyLibraryProof{Platform: "macos_arm64", LibraryHash: strings.Repeat("d", 64)} @@ -247,13 +344,11 @@ func TestLockSnapshotRequiresFinalizedArtifactMetadata(t *testing.T) { release.Artifacts = append(release.Artifacts, release.Artifacts[0]) _, err = lockInfoFromResolvedRelease("example", release) require.Error(t, err) - assert.Contains(t, err.Error(), "duplicate artifact identity") + assert.Contains(t, err.Error(), "duplicate target") } func TestSyncAdapterReusesCompleteV2SnapshotWithoutRegistryHashes(t *testing.T) { platform := config.PlatformTuple() - osName, arch, libc, variant, ok := parsePlatformTuple(platform) - require.True(t, ok) registryURL, err := url.Parse("https://registry.example.test") require.NoError(t, err) entry := lockInfo{ @@ -261,8 +356,8 @@ func TestSyncAdapterReusesCompleteV2SnapshotWithoutRegistryHashes(t *testing.T) Version: semver.MustParse("1.2.3"), Source: lockSource{Type: "registry", URL: registryURL.String()}, Artifacts: []lockArtifact{{ - Platform: platform, OS: osName, Arch: arch, LibC: libc, Variant: variant, - URL: "https://assets.example.test/archive.tar.gz", Hash: "sha256:" + strings.Repeat("a", 64), Size: int64Pointer(10), Format: "tar.gz", + Target: testTarget(platform), + URL: "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]) @@ -302,8 +397,6 @@ func TestSyncAdapterRejectsFreshEntryWithoutArchiveMetadata(t *testing.T) { func TestSyncAdapterVerifiesLegacyLibraryProofBeforeReusingV2Entry(t *testing.T) { platform := config.PlatformTuple() - osName, arch, libc, variant, ok := parsePlatformTuple(platform) - require.True(t, ok) registryURL, err := url.Parse("https://registry.example.test") require.NoError(t, err) packageURL, err := url.Parse("https://registry.example.test/archive.tar.gz") @@ -314,8 +407,8 @@ func TestSyncAdapterVerifiesLegacyLibraryProofBeforeReusingV2Entry(t *testing.T) Source: lockSource{Type: "registry", URL: registryURL.String()}, Legacy: &legacyLibraryProof{Platform: platform, LibraryHash: strings.Repeat("d", 64)}, Artifacts: []lockArtifact{{ - Platform: platform, OS: osName, Arch: arch, LibC: libc, Variant: variant, - URL: "https://registry.example.test/archive.tar.gz", Hash: "sha256:" + strings.Repeat("a", 64), Size: int64Pointer(10), + Target: testTarget(platform), + URL: "https://registry.example.test/archive.tar.gz", Hash: "sha256:" + strings.Repeat("a", 64), Size: int64Pointer(10), }}, } item := installItem{ @@ -428,16 +521,12 @@ func TestV2RegistryReplaySkipsDiscoveryAndUsesLockedURLAndMetadata(t *testing.T) } func testRegistryLockEntryForPlatform(platform string) lockInfo { - osName, arch, libc, variant, ok := parsePlatformTuple(platform) - if !ok { - panic("invalid test platform tuple: " + platform) - } return lockInfo{ Name: "example", Version: semver.MustParse("1.2.3"), Source: lockSource{Type: "registry", URL: "https://registry.example.test"}, Artifacts: []lockArtifact{{ - Platform: platform, OS: osName, Arch: arch, LibC: libc, Variant: variant, + Target: testTarget(platform), Format: "tar.gz", URL: "https://assets.example.test/" + platform + ".tar.gz", Hash: "sha256:" + strings.Repeat("a", 64), Size: int64Pointer(10), }}, @@ -507,7 +596,7 @@ func TestV2RegistryReplayAllowsExplicitPrereleaseConstraintOffline(t *testing.T) func TestLockReplayRejectsMuslForGenericLinuxTargetAndAmbiguousArtifacts(t *testing.T) { entry := testLockEntry() entry.Artifacts = []lockArtifact{{ - Platform: "linux_amd64", LibC: "musl", URL: "https://example.test/musl.tar.gz", + Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "musl"}, URL: "https://example.test/musl.tar.gz", Hash: "sha256:" + strings.Repeat("a", 64), Size: int64Pointer(10), }} _, err := selectLockedArtifact(entry, "linux_amd64", false) @@ -542,7 +631,7 @@ func testResolvedRelease() resolution.ResolvedRelease { BundleURL: "https://example.test/bundle", BundleHash: "sha256:" + strings.Repeat("e", 64), }, Artifacts: []resolution.Artifact{{ - Platform: "linux_amd64", OS: "linux", Arch: "amd64", LibC: "gnu", Variant: "v1", Format: "tar.gz", + Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu", Variant: "v1"}, Format: "tar.gz", URL: "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"}, diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 5d33cd12..1403047a 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -535,7 +535,7 @@ func snapshotDownloadedArchive(item *installItem, archive *os.File) error { } func lockEntryForItem(item installItem) (lockInfo, error) { - if item.LockEntry != nil && item.LockEntry.Legacy != nil && item.LockEntry.Legacy.Platform == config.PlatformTuple() { + if item.LockEntry != nil && item.LockEntry.Legacy != nil && samePlatformTarget(item.LockEntry.Legacy.Platform, config.PlatformTuple()) { if err := verifyLegacyLibraryProof(*item.LockEntry, config.PlatformTuple(), item.InstalledLibraryHash); err != nil { return lockInfo{}, err } @@ -550,6 +550,10 @@ func lockEntryForItem(item installItem) (lockInfo, error) { if err != nil { return lockInfo{}, err } + target, err := resolution.TargetFromPlatformTuple(item.Package.PlatformTuple) + if err != nil { + return lockInfo{}, fmt.Errorf("invalid resolved package target %q: %w", item.Package.PlatformTuple, err) + } hash := item.ArchiveHash if hash == "" { hash = item.Package.ArtifactHash @@ -563,11 +567,11 @@ func lockEntryForItem(item installItem) (lockInfo, error) { Version: item.Package.Version.String(), Source: resolution.SourceSpec{Type: source.Type, Reference: source.URL}, Artifacts: []resolution.Artifact{{ - Platform: item.Package.PlatformTuple, - Format: "tar.gz", - URL: item.Package.Path.String(), - Hash: hash, - Size: &size, + Target: target, + Format: "tar.gz", + URL: item.Package.Path.String(), + Hash: hash, + Size: &size, }}, } candidate, err := lockInfoFromResolvedRelease(item.Driver.Path, release) @@ -586,7 +590,7 @@ func lockEntryForItem(item installItem) (lockInfo, error) { } if item.LockEntry.Legacy != nil { var verified *VerifiedLegacyLibrary - if item.LockEntry.Legacy.Platform == config.PlatformTuple() { + if samePlatformTarget(item.LockEntry.Legacy.Platform, config.PlatformTuple()) { verified = &VerifiedLegacyLibrary{Platform: config.PlatformTuple(), LibraryHash: item.InstalledLibraryHash} } return migrateV1Entry(*item.LockEntry, release, config.PlatformTuple(), verified) diff --git a/internal/packslip/model.go b/internal/packslip/model.go index b03ed14f..f5587fb3 100644 --- a/internal/packslip/model.go +++ b/internal/packslip/model.go @@ -259,6 +259,7 @@ func parseRelease(payload []byte, expectedProject string) (*parsedRelease, error if artifact.Format == nil || !validToken(*artifact.Format) { return nil, fmt.Errorf("packslip artifact %q has an invalid or missing format", artifact.Name) } + canonicalizeArtifactAliases(artifact) for field, value := range map[string]*string{ "os": artifact.OS, "arch": artifact.Arch, "libc": artifact.LibC, "variant": artifact.Variant, } { @@ -804,6 +805,10 @@ func normalizeLooseTagVersion(value string) (string, bool) { } func selectArtifact(release *parsedRelease, target Target) (*releaseArtifact, error) { + target = resolution.CanonicalTarget(target) + if err := resolution.ValidateConcreteTarget(target); err != nil { + return nil, err + } formats := supportedArchiveFormats bestSpecificity, bestFormat := -1, len(formats) var chosen, tied *releaseArtifact @@ -908,36 +913,24 @@ func selectorsOverlap(left, right *releaseArtifact) bool { return true } -func convertSupportedArtifacts(release *parsedRelease, assets []githubAsset) ([]resolution.Artifact, error) { - result := make([]resolution.Artifact, 0, len(release.predicate.Artifacts)) - for i := range release.predicate.Artifacts { - artifact := &release.predicate.Artifacts[i] - if !supportedArchiveFormat(stringValue(artifact.Format)) { - continue - } - 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 nil, fmt.Errorf("packslip artifact %q has no unambiguous matching GitHub release asset", artifact.Name) +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) } - artifactURL = matches[0] - } - if err := validateHTTPSURL(artifactURL); err != nil { - return nil, fmt.Errorf("packslip artifact %q URL: %w", artifact.Name, err) } - converted, err := convertArtifact(release, artifact, artifactURL) - if err != nil { - return nil, err + if len(matches) != 1 { + return resolution.Artifact{}, fmt.Errorf("packslip artifact %q has no unambiguous matching GitHub release asset", artifact.Name) } - result = append(result, converted) + artifactURL = matches[0] } - return result, nil + 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 { @@ -947,6 +940,26 @@ func artifactSelectorKey(artifact *releaseArtifact) string { }, "|") } +func canonicalizeArtifactAliases(artifact *releaseArtifact) { + if artifact.OS != nil && *artifact.OS == "darwin" { + value := "macos" + artifact.OS = &value + } + if artifact.Arch == nil { + return + } + var value string + switch *artifact.Arch { + case "x86_64": + value = "amd64" + case "aarch64": + value = "arm64" + default: + return + } + artifact.Arch = &value +} + func fitsTarget(value *string, target string) bool { if value == nil { return true @@ -954,7 +967,7 @@ func fitsTarget(value *string, target string) bool { return target != "" && *value == target } -func convertArtifact(release *parsedRelease, artifact *releaseArtifact, artifactURL string) (resolution.Artifact, error) { +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) } @@ -967,14 +980,11 @@ func convertArtifact(release *parsedRelease, artifact *releaseArtifact, artifact } size := int64(*artifact.Size) result := resolution.Artifact{ - OS: stringValue(artifact.OS), - Arch: stringValue(artifact.Arch), - LibC: stringValue(artifact.LibC), - Variant: stringValue(artifact.Variant), - Format: stringValue(artifact.Format), - URL: artifactURL, - Hash: "sha256:" + subject.Digest["sha256"], - Size: &size, + Target: resolution.CanonicalTarget(target), + Format: stringValue(artifact.Format), + URL: artifactURL, + Hash: "sha256:" + subject.Digest["sha256"], + Size: &size, } if artifact.Requires != nil { requires := artifact.Requires diff --git a/internal/packslip/model_test.go b/internal/packslip/model_test.go index 3928fa28..836fd110 100644 --- a/internal/packslip/model_test.go +++ b/internal/packslip/model_test.go @@ -147,6 +147,7 @@ func TestSelectArtifactUsesPackslipOrderAndRejectsAmbiguity(t *testing.T) { 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) @@ -168,6 +169,11 @@ func TestSelectArtifactUsesPackslipOrderAndRejectsAmbiguity(t *testing.T) { 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 optionalToken(value string) *string { diff --git a/internal/packslip/resolver_native.go b/internal/packslip/resolver_native.go index 18c87f0b..c41b2683 100644 --- a/internal/packslip/resolver_native.go +++ b/internal/packslip/resolver_native.go @@ -88,11 +88,9 @@ func (r *nativeResolver) Resolve(ctx context.Context, source PackslipSource, req 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) } - if !validToken(request.Target.OS) || !validToken(request.Target.Arch) { - return resolution.ResolvedRelease{}, errors.New("packslip target requires valid OS and architecture tokens") - } - if request.Target.LibC != "" && !validToken(request.Target.LibC) || request.Target.Variant != "" && !validToken(request.Target.Variant) { - return resolution.ResolvedRelease{}, errors.New("packslip target has an invalid libc or variant token") + request.Target = resolution.CanonicalTarget(request.Target) + if err := resolution.ValidateConcreteTarget(request.Target); err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("invalid packslip target: %w", err) } source.Project = project identityPolicy, err := githubIdentityPolicy(project) @@ -332,13 +330,14 @@ func (r *nativeResolver) verifyReleaseBundle(ctx context.Context, bundleBytes [] } 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 _, err := selectArtifact(verified.statement, request.Target); err != nil { + if err := validateSupportedArtifactSet(verified.statement); err != nil { return resolution.ResolvedRelease{}, err } - if err := validateSupportedArtifactSet(verified.statement); err != nil { + selected, err := selectArtifact(verified.statement, request.Target) + if err != nil { return resolution.ResolvedRelease{}, err } - artifacts, err := convertSupportedArtifacts(verified.statement, assets) + artifact, err := convertSelectedArtifact(verified.statement, selected, assets, request.Target) if err != nil { return resolution.ResolvedRelease{}, err } @@ -355,7 +354,7 @@ func (r *nativeResolver) finishResolved(ctx context.Context, project string, req BundleURL: bundleURL, BundleHash: checksum, ReleaseListURL: listURL, ReleaseListHash: listHash, }, - Artifacts: artifacts, + Artifacts: []resolution.Artifact{artifact}, } if err := resolution.ValidateResolvedRelease(result); err != nil { return resolution.ResolvedRelease{}, fmt.Errorf("invalid resolved packslip release: %w", err) diff --git a/internal/packslip/resolver_native_test.go b/internal/packslip/resolver_native_test.go index d59de56b..4aa44a84 100644 --- a/internal/packslip/resolver_native_test.go +++ b/internal/packslip/resolver_native_test.go @@ -193,7 +193,7 @@ func TestResolverUsesSignedListDigestAndBuildsResolvedRelease(t *testing.T) { 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: "iceberg", Version: "1.0.0", Target: Target{OS: "linux", Arch: "x86_64", LibC: "gnu"}}) + resolved, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "iceberg", Version: "1.0.0", Target: Target{OS: "linux", Arch: "amd64", LibC: "gnu"}}) require.NoError(t, err) require.Equal(t, "iceberg", resolved.DriverID) require.Equal(t, "1.0.0", resolved.Version) @@ -203,13 +203,19 @@ func TestResolverUsesSignedListDigestAndBuildsResolvedRelease(t *testing.T) { require.Equal(t, "sha256:"+digestHex(listBytes), resolved.Evidence.ReleaseListHash) require.Equal(t, server.URL+"/acme/driver/HEAD/.well-known/packslip.json", resolved.Evidence.ReleaseListURL) require.Equal(t, server.URL+"/assets/release.json", resolved.Evidence.BundleURL) - require.Len(t, resolved.Artifacts, 3, "a resolution must preserve all supported platforms") + require.Len(t, resolved.Artifacts, 1, "a resolution contains the artifact selected for its requested target") require.Equal(t, "https://downloads.example/driver-linux.tar.gz", resolved.Artifacts[0].URL) require.Equal(t, "sha256:"+strings.Repeat("a", 64), resolved.Artifacts[0].Hash) require.Equal(t, int64(10), *resolved.Artifacts[0].Size) - require.Equal(t, "darwin", resolved.Artifacts[1].OS) - require.Equal(t, "windows", resolved.Artifacts[2].OS) - require.Equal(t, int32(1), fixture.apiHits.Load()) + require.Equal(t, Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, resolved.Artifacts[0].Target) + macos, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{ + DriverID: "iceberg", Version: "1.0.0", Target: Target{OS: "macos", Arch: "arm64"}, + }) + require.NoError(t, err) + require.Len(t, macos.Artifacts, 1) + require.Equal(t, Target{OS: "macos", Arch: "arm64"}, macos.Artifacts[0].Target) + require.Equal(t, "https://downloads.example/driver-macos.tgz", macos.Artifacts[0].URL) + require.Equal(t, int32(2), fixture.apiHits.Load()) } func TestResolverRetainsSignedListEvidenceWhenReleaseIsAbsentFromList(t *testing.T) { diff --git a/internal/packslip/types.go b/internal/packslip/types.go index 09569214..5016eff0 100644 --- a/internal/packslip/types.go +++ b/internal/packslip/types.go @@ -47,13 +47,8 @@ type PackslipSource struct { Project string } -// Target is the host tuple used to select one archive from a release. -type Target struct { - OS string - Arch string - LibC string - Variant string -} +// Target is the canonical host tuple used to select one archive from a release. +type Target = resolution.Target // Request identifies one driver release and the archive needed by this host. type Request struct { diff --git a/internal/resolution/types.go b/internal/resolution/types.go index df5827fa..6d9544ce 100644 --- a/internal/resolution/types.go +++ b/internal/resolution/types.go @@ -22,11 +22,121 @@ import ( "strings" ) +// 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 +} + // Requirement describes the driver release and target requested by a caller. type Requirement struct { DriverID string VersionConstraint string - Platform string + Target Target } // SourceSpec identifies a source and its source-specific reference. Reference @@ -38,7 +148,7 @@ type SourceSpec struct { } // ResolvedRelease is a source-independent release snapshot. Artifacts may be a -// partial platform set; callers must not infer that omitted platforms exist. +// partial target set; callers must not infer that omitted targets exist. type ResolvedRelease struct { DriverID string Version string @@ -57,15 +167,10 @@ type Evidence struct { ReleaseListHash string } -// Artifact describes one downloadable archive. Hash and Size are optional so -// older registry entries and locally resolved partial sets remain representable. +// Artifact describes one downloadable archive for one concrete target. Hash and +// Size are optional so older registry entries remain representable. type Artifact struct { - // Platform retains the registry tuple during migration to explicit selectors. - Platform string - OS string - Arch string - LibC string - Variant string + Target Target Format string URL string Hash string @@ -123,17 +228,29 @@ func ValidateResolvedRelease(release ResolvedRelease) error { return fmt.Errorf("resolved release must contain at least one artifact") } - // Source models do not yet assign stable artifact IDs, so the resolved URL - // is the identity used to reject duplicate records within one release. - seen := make(map[string]struct{}, len(release.Artifacts)) + seenTargets := make(map[Target]struct{}, len(release.Artifacts)) + seenLocations := make(map[string]Artifact, len(release.Artifacts)) for i, artifact := range release.Artifacts { if artifact.URL == "" { return fmt.Errorf("artifact %d has no resolved URL", i) } - if _, ok := seen[artifact.URL]; ok { - return fmt.Errorf("resolved release contains duplicate artifact identity") + 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.URL]; ok { + if prior.Hash != artifact.Hash || !sameSize(prior.Size, artifact.Size) { + return fmt.Errorf("artifacts sharing a location have conflicting hash or size") + } + } else { + seenLocations[artifact.URL] = artifact } - seen[artifact.URL] = struct{}{} if artifact.Hash == "" { return fmt.Errorf("artifact %d has no finalized hash", i) @@ -147,3 +264,10 @@ func ValidateResolvedRelease(release ResolvedRelease) error { } return nil } + +func sameSize(left, right *int64) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + return *left == *right +} diff --git a/internal/resolution/types_test.go b/internal/resolution/types_test.go index 3f6c6ae3..31826874 100644 --- a/internal/resolution/types_test.go +++ b/internal/resolution/types_test.go @@ -36,14 +36,11 @@ func TestValidateResolvedRelease(t *testing.T) { BundleHash: "sha256:" + strings.Repeat("a", 64), }, Artifacts: []Artifact{{ - Platform: "linux_amd64", - OS: "linux", - Arch: "amd64", - LibC: "gnu", - Format: "tar.gz", - URL: "https://example.test/driver.tar.gz", - Hash: "sha256:" + strings.Repeat("b", 64), - Size: &size, + Target: Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, + Format: "tar.gz", + URL: "https://example.test/driver.tar.gz", + Hash: "sha256:" + strings.Repeat("b", 64), + Size: &size, HostRequirements: HostRequirements{ OSMin: "1.0", GLibCMin: "2.17", @@ -62,9 +59,10 @@ func TestValidateResolvedRelease(t *testing.T) { func TestValidateResolvedReleaseRequiresFinalizedArtifacts(t *testing.T) { size := int64(12) base := ResolvedRelease{Artifacts: []Artifact{{ - URL: "https://example.test/driver.tar.gz", - Hash: "sha256:" + strings.Repeat("a", 64), - Size: &size, + Target: Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, + URL: "https://example.test/driver.tar.gz", + Hash: "sha256:" + strings.Repeat("a", 64), + Size: &size, }}} tests := []struct { @@ -75,11 +73,10 @@ func TestValidateResolvedReleaseRequiresFinalizedArtifacts(t *testing.T) { {name: "missing hash", mutate: func(r *ResolvedRelease) { r.Artifacts[0].Hash = "" }, wantErr: "no finalized hash"}, {name: "missing size", mutate: func(r *ResolvedRelease) { r.Artifacts[0].Size = nil }, wantErr: "no finalized size"}, {name: "missing URL", mutate: func(r *ResolvedRelease) { r.Artifacts[0].URL = "" }, wantErr: "no resolved URL"}, - {name: "duplicate artifact URL", mutate: func(r *ResolvedRelease) { + {name: "duplicate target", mutate: func(r *ResolvedRelease) { duplicate := r.Artifacts[0] - duplicate.Platform = "macos_arm64" r.Artifacts = append(r.Artifacts, duplicate) - }, wantErr: "duplicate artifact identity"}, + }, wantErr: "duplicate target"}, } for _, tt := range tests { @@ -95,10 +92,50 @@ func TestValidateResolvedReleaseRequiresFinalizedArtifacts(t *testing.T) { func TestValidateResolvedReleaseKeepsRegistryCandidateWithoutHashValid(t *testing.T) { size := int64(12) candidate := ResolvedRelease{Artifacts: []Artifact{{ - URL: "https://registry.example.test/driver.tar.gz", - Size: &size, + Target: Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, + URL: "https://registry.example.test/driver.tar.gz", + Size: &size, }}} assert.ErrorContains(t, ValidateResolvedRelease(candidate), "no finalized hash") 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"}, URL: "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"}, URL: base.URL, Hash: base.Hash, Size: &size, + }}} + assert.NoError(t, ValidateResolvedRelease(release)) + release.Artifacts[1].Hash = "sha256:" + strings.Repeat("b", 64) + assert.ErrorContains(t, ValidateResolvedRelease(release), "conflicting hash or size") +} diff --git a/registry_artifact.go b/registry_artifact.go index 212c90d8..62eead79 100644 --- a/registry_artifact.go +++ b/registry_artifact.go @@ -119,11 +119,15 @@ func (p registryPackage) resolveArtifact() (resolution.Artifact, error) { if err := resolution.ValidateArtifactMetadata(hash, size); err != nil { return resolution.Artifact{}, err } + target, err := resolution.TargetFromPlatformTuple(p.PlatformTuple) + if err != nil { + return resolution.Artifact{}, err + } return resolution.Artifact{ - Platform: p.PlatformTuple, - URL: p.URL, - Hash: hash, - Size: size, + Target: target, + URL: p.URL, + Hash: hash, + Size: size, }, nil } diff --git a/registry_artifact_test.go b/registry_artifact_test.go index 64daad84..95c83422 100644 --- a/registry_artifact_test.go +++ b/registry_artifact_test.go @@ -20,6 +20,7 @@ import ( "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" @@ -196,14 +197,44 @@ func TestResolvedRegistryReleaseMatchesGetPackageURLs(t *testing.T) { resolved, err := release.resolvedRelease(driver) require.NoError(t, err) - for _, artifact := range resolved.Artifacts { - pkg, err := driver.GetPackage(version, artifact.Platform, false) + 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, pkg.Path.String(), artifact.URL) } } +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, "https://registry.example.test/example-driver/1.2.3/example-driver_linux_x86_64-1.2.3.tar.gz", resolved.Artifacts[0].URL, + "implicit asset filenames retain the raw registry tuple") +} + func TestRegistryArtifactMetadataRejectsInvalidValues(t *testing.T) { tests := []struct { name string From 314910f1667b500d9066e79842f440cee71dcff4 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 14:24:58 +0000 Subject: [PATCH 031/115] fix: migrate trusted provenance aliases --- internal/packslip/truststore_native.go | 59 ++++++++++++++-- internal/packslip/truststore_native_test.go | 78 +++++++++++++++++++++ 2 files changed, 130 insertions(+), 7 deletions(-) diff --git a/internal/packslip/truststore_native.go b/internal/packslip/truststore_native.go index 8cdb89ec..ab43ad58 100644 --- a/internal/packslip/truststore_native.go +++ b/internal/packslip/truststore_native.go @@ -193,8 +193,10 @@ func applyReleaseAcceptance(state *projectTrust, project string, candidate relea if state.AttestedBy == "vendor" && candidate.Attested == "repackager" { return fmt.Errorf("release attestation changed from vendor to repackager for %s", project) } - for selector, wasPresent := range state.Provenance { - if wasPresent && !candidate.Provenance[selector] { + 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) } } @@ -203,17 +205,60 @@ func applyReleaseAcceptance(state *projectTrust, project string, candidate relea if state.AttestedBy == "" || candidate.Attested == "vendor" { state.AttestedBy = candidate.Attested } - if state.Provenance == nil { - state.Provenance = make(map[string]bool) + mergedProvenance := previousProvenance + if mergedProvenance == nil && candidateProvenance != nil { + mergedProvenance = make(map[string]bool, len(candidateProvenance)) } - for selector, present := range candidate.Provenance { - if present || !state.Provenance[selector] { - state.Provenance[selector] = present + 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 diff --git a/internal/packslip/truststore_native_test.go b/internal/packslip/truststore_native_test.go index 6be9b479..10287312 100644 --- a/internal/packslip/truststore_native_test.go +++ b/internal/packslip/truststore_native_test.go @@ -63,6 +63,23 @@ func acceptTestList(t *testing.T, store *TrustStore, identity trustedIdentity, i }, 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") @@ -100,6 +117,67 @@ func TestTrustStoreRejectsAttestationAndProvenanceDowngrades(t *testing.T) { 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") From 36ac874a60b1388928f7df095ef1cf1554e2eb03 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 14:30:21 +0000 Subject: [PATCH 032/115] fix: skip package cleanup for external drivers --- config/config.go | 3 ++ config/install_transaction_test.go | 46 ++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/config/config.go b/config/config.go index 55deaf25..c68293ce 100644 --- a/config/config.go +++ b/config/config.go @@ -376,6 +376,9 @@ func UninstallDriverShared(info DriverInfo) error { } 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) diff --git a/config/install_transaction_test.go b/config/install_transaction_test.go index 3c891ced..01cbe5d6 100644 --- a/config/install_transaction_test.go +++ b/config/install_transaction_test.go @@ -865,6 +865,52 @@ func TestUninstallPackageCleanupReturnsProvenRemovalFailures(t *testing.T) { } } +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 From 14e8d350c52146881b82036ca9ce722604fda972 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 14:37:46 +0000 Subject: [PATCH 033/115] refactor: clarify package metadata inspection --- client_methods.go | 2 +- cmd/dbc/install.go | 4 ++-- cmd/dbc/sync.go | 2 +- config/package_archive.go | 4 ++-- config/package_archive_test.go | 14 +++++++------- docs/guides/installing.md | 12 ++++++------ 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/client_methods.go b/client_methods.go index edf32f33..84c84034 100644 --- a/client_methods.go +++ b/client_methods.go @@ -286,7 +286,7 @@ func (c *Client) Install(ctx context.Context, cfg config.Config, driverName stri defer f.Close() defer os.RemoveAll(filepath.Dir(f.Name())) if !hasMetadata { - packageManifest, inspectErr := config.InspectPackageManifest(f) + packageManifest, inspectErr := config.InspectPackageMetadata(f) if inspectErr != nil { return nil, fmt.Errorf("failed to inspect package for driver %s: %w", driverName, inspectErr) } diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index 31b8d4ae..8c8abd7c 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -466,7 +466,7 @@ func (m progressiveInstallModel) startInstalling(downloaded *os.File) (tea.Model return err } if !hasMetadata { - packageManifest, inspectErr := config.InspectPackageManifest(downloaded) + packageManifest, inspectErr := config.InspectPackageMetadata(downloaded) if inspectErr != nil { return inspectErr } @@ -476,7 +476,7 @@ func (m progressiveInstallModel) startInstalling(downloaded *os.File) (tea.Model } runtimeID = expected.ID } else { - packageManifest, inspectErr := config.InspectPackageManifest(downloaded) + packageManifest, inspectErr := config.InspectPackageMetadata(downloaded) if inspectErr != nil { return inspectErr } diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 1403047a..4b22ba71 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -418,7 +418,7 @@ func (s syncModel) installDriver(cfg config.Config, item installItem) tea.Cmd { return } if !hasMetadata { - packageManifest, inspectErr := config.InspectPackageManifest(output) + packageManifest, inspectErr := config.InspectPackageMetadata(output) if inspectErr != nil { prog.Send(inspectErr) return diff --git a/config/package_archive.go b/config/package_archive.go index 60e42d05..4cf09582 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -862,11 +862,11 @@ func validateExpectedPackage(expected ExpectedPackageMetadata) error { return nil } -// InspectPackageManifest decodes the archive's package metadata without +// InspectPackageMetadata decodes the archive's package metadata without // installing files. It is intended to classify legacy and versioned package // formats before choosing an install policy; the installer still validates // the full archive before publication. The archive remains open for the caller. -func InspectPackageManifest(downloaded *os.File) (Manifest, error) { +func InspectPackageMetadata(downloaded *os.File) (Manifest, error) { if downloaded == nil { return Manifest{}, errors.New("package archive is nil") } diff --git a/config/package_archive_test.go b/config/package_archive_test.go index 40332fe4..714bcd42 100644 --- a/config/package_archive_test.go +++ b/config/package_archive_test.go @@ -116,7 +116,7 @@ func validV2Archive(t *testing.T, contents []byte) []byte { ) } -func TestPackageArchiveManifestVersions(t *testing.T) { +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) @@ -142,7 +142,7 @@ func TestPackageArchiveManifestVersions(t *testing.T) { assert.Equal(t, "libexample.so", manifest.Files.Driver) assert.Empty(t, manifest.Driver.Shared.Get("linux_amd64")) - inspected, err := config.InspectPackageManifest(openPackageArchive(t, data)) + inspected, err := config.InspectPackageMetadata(openPackageArchive(t, data)) require.NoError(t, err) assert.Equal(t, 2, inspected.PackageVersion) }) @@ -152,7 +152,7 @@ func TestPackageArchiveManifestVersions(t *testing.T) { f, err := os.Open(archivePath) require.NoError(t, err) defer f.Close() - manifest, err := config.InspectPackageManifest(f) + manifest, err := config.InspectPackageMetadata(f) require.NoError(t, err) assert.Zero(t, manifest.PackageVersion) }) @@ -312,7 +312,7 @@ func TestPackageArchiveMetadataFilenames(t *testing.T) { archive := makePackageArchive(t, tt.entries...) for name, inspect := range map[string]func(*os.File) error{ "inspect": func(file *os.File) error { - _, err := config.InspectPackageManifest(file) + _, err := config.InspectPackageMetadata(file) return err }, "extract": func(file *os.File) error { @@ -330,13 +330,13 @@ func TestPackageArchiveMetadataFilenames(t *testing.T) { } } -func TestInspectPackageManifestScansPastMetadata(t *testing.T) { +func TestInspectPackageMetadataScansPastMetadata(t *testing.T) { archive := makePackageArchive(t, archiveEntry{name: "dbc-package.toml", data: packageV2Manifest("example", "1.2.3", config.PlatformTuple(), "driver.so")}, archiveEntry{name: "driver.so", data: []byte("library")}, archiveEntry{name: "nested/file", data: []byte("unsafe")}, ) - _, err := config.InspectPackageManifest(openPackageArchive(t, archive)) + _, err := config.InspectPackageMetadata(openPackageArchive(t, archive)) require.Error(t, err) assert.Contains(t, err.Error(), "path separators") } @@ -348,7 +348,7 @@ func TestPackageMetadataSizeLimitAppliesToBothFormats(t *testing.T) { archive := makePackageArchive(t, archiveEntry{name: name, data: oversized}) for _, inspect := range []func(*os.File) error{ func(file *os.File) error { - _, err := config.InspectPackageManifest(file) + _, err := config.InspectPackageMetadata(file) return err }, func(file *os.File) error { diff --git a/docs/guides/installing.md b/docs/guides/installing.md index ccaa1077..b6270673 100644 --- a/docs/guides/installing.md +++ b/docs/guides/installing.md @@ -267,11 +267,11 @@ 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 -The dbc 0.4.0 package format uses a root-level file named exactly `dbc-package.toml` with `package_version = 2` to describe its contents. The dbc 0.3.x format uses a root-level file named exactly `MANIFEST` and remains accepted for compatibility. These package metadata files are separate from Packslip release metadata and the installed `.toml` ADBC Driver Manifest (`manifest_version = 1`). +dbc 0.4.0 (unreleased) archives use a root-level file named exactly `dbc-package.toml` with `package_version = 2` to describe their contents. Archives for dbc 0.3.0 and earlier use a root-level file named exactly `MANIFEST` and remain accepted for compatibility. These package metadata files are separate from Packslip release metadata and the installed `.toml` ADBC Driver Manifest (`manifest_version = 1`). For example, `dbc-package.toml` can contain: @@ -294,18 +294,18 @@ The archive must be flat: place `dbc-package.toml` and the library file at its r 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")`. + With dbc 0.4.0, use the `id` from `dbc-package.toml` (`example` above) as the driver name when connecting through a [Driver Manager](../concepts/driver_manager.md). With dbc 0.3.0 and earlier, the runtime ID may be derived from the archive filename. ## GitHub Actions From 4652122f6336fbffefb8df6edad9d6d0a0ee8c6a Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 15:06:44 +0000 Subject: [PATCH 034/115] refactor: type artifact locations --- cmd/dbc/lockfile.go | 40 +++--- cmd/dbc/lockfile_v2.go | 33 ++--- cmd/dbc/lockfile_v2_test.go | 145 ++++++++++++++++++---- cmd/dbc/sync.go | 24 ++-- drivers.go | 2 - internal/packslip/model.go | 10 +- internal/packslip/resolver_native_test.go | 5 +- internal/resolution/types.go | 71 ++++++++++- internal/resolution/types_test.go | 72 ++++++++--- registry_artifact.go | 6 +- registry_artifact_test.go | 28 ++++- 11 files changed, 326 insertions(+), 110 deletions(-) diff --git a/cmd/dbc/lockfile.go b/cmd/dbc/lockfile.go index d21d7757..676f477b 100644 --- a/cmd/dbc/lockfile.go +++ b/cmd/dbc/lockfile.go @@ -71,13 +71,15 @@ type lockEvidence struct { } type lockArtifact struct { - Target resolution.Target `toml:"target"` - Format string `toml:"format,omitempty"` - URL string `toml:"url,omitempty"` - Path string `toml:"path,omitempty"` - Hash string `toml:"hash"` - Size *int64 `toml:"size"` - HostRequirements lockHostRequirements `toml:"host_requirements,omitempty"` + 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"` + // These fields detect and reject the pre-location v2 wire form. + LegacyURL *string `toml:"url,omitempty"` + LegacyPath *string `toml:"path,omitempty"` // These fields exist only to detect and reject obsolete v2 wire selectors. // New lock entries never populate or serialize them; Target is the only // artifact selector accepted for version 2 files. @@ -284,8 +286,11 @@ func validateHTTPURL(field, raw string) error { func validateLockArtifacts(artifacts []lockArtifact) error { seenTargets := make(map[resolution.Target]struct{}, len(artifacts)) - seenLocations := make(map[string]lockArtifact, len(artifacts)) + seenLocations := make(map[resolution.ArtifactLocation]lockArtifact, len(artifacts)) for i, artifact := range artifacts { + if artifact.LegacyURL != nil || artifact.LegacyPath != nil { + return fmt.Errorf("artifact %d uses obsolete direct URL or path fields; use the location table", i) + } if artifact.LegacyPlatform != nil || artifact.LegacyOS != nil || artifact.LegacyArch != nil || artifact.LegacyLibC != nil || artifact.LegacyVariant != nil { return fmt.Errorf("artifact %d uses obsolete selector fields; use the target table", i) @@ -296,8 +301,8 @@ func validateLockArtifacts(artifacts []lockArtifact) error { if canonical := resolution.CanonicalTarget(artifact.Target); canonical != artifact.Target { return fmt.Errorf("artifact %d target is not canonical", i) } - if (artifact.URL == "") == (artifact.Path == "") { - return fmt.Errorf("artifact %d must have exactly one of URL or path", i) + if err := resolution.ValidateArtifactLocation(artifact.Location); err != nil { + return fmt.Errorf("artifact %d has invalid location: %w", i, err) } if artifact.Hash == "" || artifact.Size == nil { return fmt.Errorf("artifact %d must have a finalized hash and size", i) @@ -308,25 +313,16 @@ func validateLockArtifacts(artifacts []lockArtifact) error { if *artifact.Size < 0 { return fmt.Errorf("artifact %d has negative size", i) } - if artifact.URL != "" { - if err := validateHTTPURL("artifact URL", artifact.URL); err != nil { - return fmt.Errorf("artifact %d: %w", i, err) - } - } if _, exists := seenTargets[artifact.Target]; exists { return fmt.Errorf("duplicate artifact target %q", artifactSelectorIdentity(artifact)) } seenTargets[artifact.Target] = struct{}{} - location := "url:" + artifact.URL - if artifact.Path != "" { - location = "path:" + artifact.Path - } - if prior, exists := seenLocations[location]; exists { + if prior, exists := seenLocations[artifact.Location]; exists { if prior.Hash != artifact.Hash || !sameLockSize(prior.Size, artifact.Size) { - return fmt.Errorf("artifacts sharing location %q have conflicting hash or size", location) + return fmt.Errorf("artifacts sharing location %q have conflicting hash or size", artifact.Location.Value) } } else { - seenLocations[location] = artifact + seenLocations[artifact.Location] = artifact } } return nil diff --git a/cmd/dbc/lockfile_v2.go b/cmd/dbc/lockfile_v2.go index bbd21417..2953ef15 100644 --- a/cmd/dbc/lockfile_v2.go +++ b/cmd/dbc/lockfile_v2.go @@ -107,12 +107,7 @@ func lockInfoFromResolvedRelease(name string, release resolution.ResolvedRelease return lockInfo{}, fmt.Errorf("unsupported resolved source type %q", release.Source.Type) } for _, artifact := range release.Artifacts { - lockedArtifact := lockArtifactFromResolved(artifact) - if release.Source.Type == "path" { - lockedArtifact.Path = lockedArtifact.URL - lockedArtifact.URL = "" - } - entry.Artifacts = append(entry.Artifacts, lockedArtifact) + entry.Artifacts = append(entry.Artifacts, lockArtifactFromResolved(artifact)) } if err := validateLockInfo(entry); err != nil { return lockInfo{}, err @@ -122,11 +117,11 @@ func lockInfoFromResolvedRelease(name string, release resolution.ResolvedRelease func lockArtifactFromResolved(artifact resolution.Artifact) lockArtifact { result := lockArtifact{ - Target: resolution.CanonicalTarget(artifact.Target), - Format: artifact.Format, - URL: artifact.URL, - Hash: artifact.Hash, - Size: cloneInt64(artifact.Size), + Target: resolution.CanonicalTarget(artifact.Target), + Format: artifact.Format, + Location: artifact.Location, + Hash: artifact.Hash, + Size: cloneInt64(artifact.Size), HostRequirements: lockHostRequirements{ OSMin: artifact.HostRequirements.OSMin, GLibCMin: artifact.HostRequirements.GLibCMin, @@ -163,16 +158,12 @@ func (d lockInfo) resolvedRelease() resolution.ResolvedRelease { release.Version = d.Version.String() } for _, artifact := range d.Artifacts { - artifactURL := artifact.URL - if artifact.Path != "" { - artifactURL = artifact.Path - } resolved := resolution.Artifact{ - Target: artifact.Target, - Format: artifact.Format, - URL: artifactURL, - Hash: artifact.Hash, - Size: cloneInt64(artifact.Size), + Target: artifact.Target, + Format: artifact.Format, + Location: artifact.Location, + Hash: artifact.Hash, + Size: cloneInt64(artifact.Size), HostRequirements: resolution.HostRequirements{ OSMin: artifact.HostRequirements.OSMin, GLibCMin: artifact.HostRequirements.GLibCMin, @@ -306,7 +297,7 @@ func refreshLockEntry(existing, refreshed lockInfo) (lockInfo, error) { continue } found = true - if prior.URL != candidate.URL || prior.Path != candidate.Path || prior.Format != candidate.Format || + 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) diff --git a/cmd/dbc/lockfile_v2_test.go b/cmd/dbc/lockfile_v2_test.go index 0b792d46..35ffc5a2 100644 --- a/cmd/dbc/lockfile_v2_test.go +++ b/cmd/dbc/lockfile_v2_test.go @@ -21,6 +21,7 @@ import ( "net/url" "os" "path/filepath" + "strconv" "strings" "testing" @@ -34,11 +35,11 @@ import ( func testLockArtifact(platform, digest string, size int64) lockArtifact { return lockArtifact{ - Target: testTarget(platform), - URL: "https://example.test/" + platform + ".tar.gz", - Hash: "sha256:" + strings.Repeat(digest, 64), - Size: &size, - Format: "tar.gz", + 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", } } @@ -63,7 +64,7 @@ func testLockEntry() lockInfo { Artifacts: []lockArtifact{ { Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu", Variant: "v1"}, Format: "tar.gz", - URL: "https://example.test/linux.tar.gz", Hash: "sha256:" + strings.Repeat("a", 64), Size: int64Pointer(128), + 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"}}, @@ -71,7 +72,7 @@ func testLockEntry() lockInfo { }, { Target: resolution.Target{OS: "macos", Arch: "arm64"}, Format: "tar.gz", - URL: "https://example.test/macos.tar.gz", Hash: "sha256:" + strings.Repeat("b", 64), Size: int64Pointer(256), + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/macos.tar.gz"}, Hash: "sha256:" + strings.Repeat("b", 64), Size: int64Pointer(256), }, }, } @@ -82,11 +83,14 @@ 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" 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"}, Path: "./packages/local.tar.gz", Hash: "sha256:" + strings.Repeat("c", 64), Size: int64Pointer(0), Format: "tar.gz", + 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") @@ -108,17 +112,59 @@ func TestLockFileV2DeterministicRoundTripPreservesArtifactMetadata(t *testing.T) 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, "./packages/local.tar.gz", loaded.lockinfo["local"].Artifacts[0].Path) + 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]") - assert.Zero(t, *loaded.lockinfo["local"].Artifacts[0].Size, "an explicit zero size is distinct from an omitted size") + 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) - assert.Equal(t, "./packages/local.tar.gz", localRoundTrip.Artifacts[0].Path) + 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) { @@ -183,7 +229,7 @@ func TestLockFileV2RejectsObsoleteArtifactSelectorFields(t *testing.T) { targetCase = "with_target" } t.Run(field.name+"/"+targetCase, func(t *testing.T) { - contents := "version = 2\nrevision = 0\n\n[[drivers]]\nname = 'example'\nversion = '1.2.3'\n\n[drivers.source]\ntype = 'registry'\nurl = 'https://registry.example.test'\n\n[[drivers.artifacts]]\nformat = 'tar.gz'\nurl = 'https://assets.example.test/archive.tar.gz'\nhash = 'sha256:" + strings.Repeat("a", 64) + "'\nsize = 1\n" + field.value + "\n" + contents := "version = 2\nrevision = 0\n\n[[drivers]]\nname = 'example'\nversion = '1.2.3'\n\n[drivers.source]\ntype = 'registry'\nurl = 'https://registry.example.test'\n\n[[drivers.artifacts]]\nformat = 'tar.gz'\nhash = 'sha256:" + strings.Repeat("a", 64) + "'\nsize = 1\n" + field.value + "\n\n[drivers.artifacts.location]\nkind = 'url'\nvalue = 'https://assets.example.test/archive.tar.gz'\n" if withTarget { contents += "[drivers.artifacts.target]\nos = 'linux'\narch = 'amd64'\nlibc = 'gnu'\n" } @@ -196,6 +242,24 @@ func TestLockFileV2RejectsObsoleteArtifactSelectorFields(t *testing.T) { } } +func TestLockFileV2RejectsObsoleteDirectArtifactLocationFields(t *testing.T) { + for _, field := range []string{"url", "path"} { + for _, withLocation := range []bool{false, true} { + t.Run(field+"/with_location="+strconv.FormatBool(withLocation), func(t *testing.T) { + contents := "version = 2\nrevision = 0\n\n[[drivers]]\nname = 'example'\nversion = '1.2.3'\n\n[drivers.source]\ntype = 'registry'\nurl = 'https://registry.example.test'\n\n[[drivers.artifacts]]\n" + contents += field + " = ''\nformat = 'tar.gz'\nhash = 'sha256:" + strings.Repeat("a", 64) + "'\nsize = 1\n[drivers.artifacts.target]\nos = 'linux'\narch = 'amd64'\nlibc = 'gnu'\n" + if withLocation { + contents += "\n[drivers.artifacts.location]\nkind = 'url'\nvalue = 'https://assets.example.test/archive.tar.gz'\n" + } + path := filepath.Join(t.TempDir(), "dbc.lock") + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + _, err := loadLockFile(path) + assert.ErrorContains(t, err, "obsolete direct URL or path") + }) + } + } +} + func TestLockFileV1MigrationPreservesLibraryProofAndRequiresSamePlatformVerification(t *testing.T) { path := filepath.Join(t.TempDir(), "dbc.lock") legacyHash := strings.Repeat("d", 64) @@ -266,7 +330,7 @@ func TestRefreshCannotReplaceSameTargetArtifactMetadata(t *testing.T) { name string mutate func(*lockArtifact) }{ - {name: "location", mutate: func(a *lockArtifact) { a.URL = "https://other.example.test/archive.tar.gz" }}, + {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" }}, } @@ -307,13 +371,16 @@ func TestRefreshAcceptsEquivalentHostRequirementsAfterLockRoundTrip(t *testing.T func TestLockArtifactsAllowSharedLocationOnlyWithConsistentMetadata(t *testing.T) { first := testLockArtifact("linux_amd64", "a", 10) second := testLockArtifact("macos_arm64", "a", 10) - second.URL = first.URL + second.Location = first.Location assert.NoError(t, validateLockArtifacts([]lockArtifact{first, second})) 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 TestVersionUpgradeIsSeparateFromMetadataRefresh(t *testing.T) { @@ -356,8 +423,8 @@ func TestSyncAdapterReusesCompleteV2SnapshotWithoutRegistryHashes(t *testing.T) Version: semver.MustParse("1.2.3"), Source: lockSource{Type: "registry", URL: registryURL.String()}, Artifacts: []lockArtifact{{ - Target: testTarget(platform), - URL: "https://assets.example.test/archive.tar.gz", Hash: "sha256:" + strings.Repeat("a", 64), Size: int64Pointer(10), Format: "tar.gz", + 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]) @@ -381,6 +448,33 @@ func TestSyncAdapterReusesCompleteV2SnapshotWithoutRegistryHashes(t *testing.T) "post-install lock rewrite must keep the archive snapshot rather than the installed-library hash") } +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) { registryURL, err := url.Parse("https://registry.example.test") require.NoError(t, err) @@ -395,6 +489,13 @@ func TestSyncAdapterRejectsFreshEntryWithoutArchiveMetadata(t *testing.T) { assert.Contains(t, err.Error(), "no finalized hash") } +func TestSyncAdapterRejectsPathArtifactForRegistryReplay(t *testing.T) { + entry := testRegistryLockEntryForPlatform(config.PlatformTuple()) + entry.Artifacts[0].Location = resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: "./archive.tar.gz"} + _, err := installItemFromLockedArtifact("example", entry, entry.Artifacts[0]) + assert.ErrorContains(t, err, "not a remote URL artifact") +} + func TestSyncAdapterVerifiesLegacyLibraryProofBeforeReusingV2Entry(t *testing.T) { platform := config.PlatformTuple() registryURL, err := url.Parse("https://registry.example.test") @@ -407,8 +508,8 @@ func TestSyncAdapterVerifiesLegacyLibraryProofBeforeReusingV2Entry(t *testing.T) Source: lockSource{Type: "registry", URL: registryURL.String()}, Legacy: &legacyLibraryProof{Platform: platform, LibraryHash: strings.Repeat("d", 64)}, Artifacts: []lockArtifact{{ - Target: testTarget(platform), - URL: "https://registry.example.test/archive.tar.gz", Hash: "sha256:" + strings.Repeat("a", 64), Size: int64Pointer(10), + 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 := installItem{ @@ -502,7 +603,7 @@ func TestV2RegistryReplaySkipsDiscoveryAndUsesLockedURLAndMetadata(t *testing.T) require.True(t, ok, "expected lock replay install items, got %T", msg) require.Len(t, items, 1) assert.Zero(t, discoveryCalls, "registry discovery must not run for a complete v2 registry artifact") - assert.Equal(t, entry.Artifacts[0].URL, items[0].Package.Path.String()) + assert.Equal(t, entry.Artifacts[0].Location.Value, items[0].Package.Path.String()) assert.Equal(t, entry.Artifacts[0].Hash, items[0].Package.ArtifactHash) assert.Equal(t, *entry.Artifacts[0].Size, *items[0].Package.ArtifactSize) assert.Equal(t, entry.Source.URL, items[0].Driver.Registry.BaseURL.String()) @@ -527,7 +628,7 @@ func testRegistryLockEntryForPlatform(platform string) lockInfo { Source: lockSource{Type: "registry", URL: "https://registry.example.test"}, Artifacts: []lockArtifact{{ Target: testTarget(platform), - Format: "tar.gz", URL: "https://assets.example.test/" + platform + ".tar.gz", + 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), }}, } @@ -596,7 +697,7 @@ func TestV2RegistryReplayAllowsExplicitPrereleaseConstraintOffline(t *testing.T) func TestLockReplayRejectsMuslForGenericLinuxTargetAndAmbiguousArtifacts(t *testing.T) { entry := testLockEntry() entry.Artifacts = []lockArtifact{{ - Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "musl"}, URL: "https://example.test/musl.tar.gz", + 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) @@ -632,7 +733,7 @@ func testResolvedRelease() resolution.ResolvedRelease { }, Artifacts: []resolution.Artifact{{ Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu", Variant: "v1"}, Format: "tar.gz", - URL: "https://example.test/linux.tar.gz", Hash: "sha256:" + strings.Repeat("a", 64), Size: &size, + 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/sync.go b/cmd/dbc/sync.go index 4b22ba71..0137e404 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -319,15 +319,15 @@ func lockVersionSatisfiesSpec(entry lockInfo, spec driverSpec) bool { } func installItemFromLockedArtifact(name string, entry lockInfo, artifact lockArtifact) (installItem, error) { - if artifact.Path != "" || artifact.URL == "" { + if artifact.Location.Kind != resolution.ArtifactLocationURL || artifact.Location.Value == "" { return installItem{}, fmt.Errorf("locked registry artifact for %s is not a remote URL artifact", name) } if artifact.Format != "" && artifact.Format != "tar.gz" { return installItem{}, fmt.Errorf("locked artifact format %q for %s is not supported by sync yet", artifact.Format, name) } - packageURL, err := url.Parse(artifact.URL) + packageURL, err := url.Parse(artifact.Location.Value) if err != nil || !packageURL.IsAbs() || packageURL.Hostname() == "" { - return installItem{}, fmt.Errorf("invalid locked artifact URL for %s: %q", name, artifact.URL) + return installItem{}, fmt.Errorf("invalid locked artifact URL for %s: %q", name, artifact.Location.Value) } registryURL, err := url.Parse(entry.Source.URL) if err != nil || !registryURL.IsAbs() || registryURL.Hostname() == "" { @@ -472,10 +472,14 @@ func canReuseLockedEntry(item installItem) bool { return false } artifact, err := selectLockedArtifact(*item.LockEntry, config.PlatformTuple(), false) - if err != nil || artifact.URL == "" || item.Package.Path == nil || item.Package.ArtifactSize == nil { + if err != nil || artifact.Location.Kind != resolution.ArtifactLocationURL || artifact.Location.Value == "" || item.Package.Path == nil || item.Package.ArtifactSize == nil { return false } - return item.Package.Path.String() == artifact.URL && + lockedURL, err := url.Parse(artifact.Location.Value) + if err != nil || lockedURL == nil { + return false + } + return item.Package.Path.String() == lockedURL.String() && item.Package.ArtifactHash == artifact.Hash && *item.Package.ArtifactSize == *artifact.Size } @@ -567,11 +571,11 @@ func lockEntryForItem(item installItem) (lockInfo, error) { Version: item.Package.Version.String(), Source: resolution.SourceSpec{Type: source.Type, Reference: source.URL}, Artifacts: []resolution.Artifact{{ - Target: target, - Format: "tar.gz", - URL: item.Package.Path.String(), - Hash: hash, - Size: &size, + Target: target, + Format: "tar.gz", + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: item.Package.Path.String()}, + Hash: hash, + Size: &size, }}, } candidate, err := lockInfoFromResolvedRelease(item.Driver.Path, release) diff --git a/drivers.go b/drivers.go index 717e35f9..a03b9614 100644 --- a/drivers.go +++ b/drivers.go @@ -346,8 +346,6 @@ func (p pkginfo) GetPackage(d Driver, platformTuple string) (PkgInfo, error) { if err != nil { return PkgInfo{}, err } - artifact.URL = uri.String() - return PkgInfo{ Driver: d, Version: p.Version, diff --git a/internal/packslip/model.go b/internal/packslip/model.go index f5587fb3..8fa170ef 100644 --- a/internal/packslip/model.go +++ b/internal/packslip/model.go @@ -980,11 +980,11 @@ func convertArtifact(release *parsedRelease, artifact *releaseArtifact, artifact } size := int64(*artifact.Size) result := resolution.Artifact{ - Target: resolution.CanonicalTarget(target), - Format: stringValue(artifact.Format), - URL: artifactURL, - Hash: "sha256:" + subject.Digest["sha256"], - Size: &size, + 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 diff --git a/internal/packslip/resolver_native_test.go b/internal/packslip/resolver_native_test.go index 4aa44a84..5d79b20c 100644 --- a/internal/packslip/resolver_native_test.go +++ b/internal/packslip/resolver_native_test.go @@ -32,6 +32,7 @@ import ( "time" "github.com/columnar-tech/dbc/internal/packslipverify" + "github.com/columnar-tech/dbc/internal/resolution" "github.com/stretchr/testify/require" ) @@ -204,7 +205,7 @@ func TestResolverUsesSignedListDigestAndBuildsResolvedRelease(t *testing.T) { require.Equal(t, server.URL+"/acme/driver/HEAD/.well-known/packslip.json", resolved.Evidence.ReleaseListURL) require.Equal(t, server.URL+"/assets/release.json", resolved.Evidence.BundleURL) require.Len(t, resolved.Artifacts, 1, "a resolution contains the artifact selected for its requested target") - require.Equal(t, "https://downloads.example/driver-linux.tar.gz", resolved.Artifacts[0].URL) + 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) @@ -214,7 +215,7 @@ func TestResolverUsesSignedListDigestAndBuildsResolvedRelease(t *testing.T) { require.NoError(t, err) require.Len(t, macos.Artifacts, 1) require.Equal(t, Target{OS: "macos", Arch: "arm64"}, macos.Artifacts[0].Target) - require.Equal(t, "https://downloads.example/driver-macos.tgz", macos.Artifacts[0].URL) + require.Equal(t, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://downloads.example/driver-macos.tgz"}, macos.Artifacts[0].Location) require.Equal(t, int32(2), fixture.apiHits.Load()) } diff --git a/internal/resolution/types.go b/internal/resolution/types.go index 6d9544ce..82ccf4c7 100644 --- a/internal/resolution/types.go +++ b/internal/resolution/types.go @@ -19,9 +19,68 @@ 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 { @@ -172,7 +231,7 @@ type Evidence struct { type Artifact struct { Target Target Format string - URL string + Location ArtifactLocation Hash string Size *int64 HostRequirements HostRequirements @@ -229,10 +288,10 @@ func ValidateResolvedRelease(release ResolvedRelease) error { } seenTargets := make(map[Target]struct{}, len(release.Artifacts)) - seenLocations := make(map[string]Artifact, len(release.Artifacts)) + seenLocations := make(map[ArtifactLocation]Artifact, len(release.Artifacts)) for i, artifact := range release.Artifacts { - if artifact.URL == "" { - return fmt.Errorf("artifact %d has no resolved URL", i) + 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) @@ -244,12 +303,12 @@ func ValidateResolvedRelease(release ResolvedRelease) error { return fmt.Errorf("resolved release contains duplicate target") } seenTargets[artifact.Target] = struct{}{} - if prior, ok := seenLocations[artifact.URL]; ok { + if prior, ok := seenLocations[artifact.Location]; ok { if prior.Hash != artifact.Hash || !sameSize(prior.Size, artifact.Size) { return fmt.Errorf("artifacts sharing a location have conflicting hash or size") } } else { - seenLocations[artifact.URL] = artifact + seenLocations[artifact.Location] = artifact } if artifact.Hash == "" { diff --git a/internal/resolution/types_test.go b/internal/resolution/types_test.go index 31826874..da445314 100644 --- a/internal/resolution/types_test.go +++ b/internal/resolution/types_test.go @@ -36,11 +36,11 @@ func TestValidateResolvedRelease(t *testing.T) { BundleHash: "sha256:" + strings.Repeat("a", 64), }, Artifacts: []Artifact{{ - Target: Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, - Format: "tar.gz", - URL: "https://example.test/driver.tar.gz", - Hash: "sha256:" + strings.Repeat("b", 64), - Size: &size, + 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", @@ -59,10 +59,10 @@ func TestValidateResolvedRelease(t *testing.T) { func TestValidateResolvedReleaseRequiresFinalizedArtifacts(t *testing.T) { size := int64(12) base := ResolvedRelease{Artifacts: []Artifact{{ - Target: Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, - URL: "https://example.test/driver.tar.gz", - Hash: "sha256:" + strings.Repeat("a", 64), - Size: &size, + 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 { @@ -72,7 +72,7 @@ func TestValidateResolvedReleaseRequiresFinalizedArtifacts(t *testing.T) { }{ {name: "missing hash", mutate: func(r *ResolvedRelease) { r.Artifacts[0].Hash = "" }, wantErr: "no finalized hash"}, {name: "missing size", mutate: func(r *ResolvedRelease) { r.Artifacts[0].Size = nil }, wantErr: "no finalized size"}, - {name: "missing URL", mutate: func(r *ResolvedRelease) { r.Artifacts[0].URL = "" }, wantErr: "no resolved URL"}, + {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) @@ -92,9 +92,9 @@ func TestValidateResolvedReleaseRequiresFinalizedArtifacts(t *testing.T) { func TestValidateResolvedReleaseKeepsRegistryCandidateWithoutHashValid(t *testing.T) { size := int64(12) candidate := ResolvedRelease{Artifacts: []Artifact{{ - Target: Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, - URL: "https://registry.example.test/driver.tar.gz", - Size: &size, + Target: Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, + Location: ArtifactLocation{Kind: ArtifactLocationURL, Value: "https://registry.example.test/driver.tar.gz"}, + Size: &size, }}} assert.ErrorContains(t, ValidateResolvedRelease(candidate), "no finalized hash") @@ -129,13 +129,55 @@ func TestEmptyConcreteTargetIsNotAWildcard(t *testing.T) { func TestResolvedReleaseAllowsSharedLocationButRejectsConflictingMetadata(t *testing.T) { size := int64(12) base := Artifact{ - Target: Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, URL: "https://example.test/shared.tar.gz", + 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"}, URL: base.URL, Hash: base.Hash, Size: &size, + Target: Target{OS: "macos", Arch: "arm64"}, Location: base.Location, Hash: base.Hash, Size: &size, }}} assert.NoError(t, ValidateResolvedRelease(release)) 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/registry_artifact.go b/registry_artifact.go index 62eead79..12bd6250 100644 --- a/registry_artifact.go +++ b/registry_artifact.go @@ -89,7 +89,10 @@ func (p pkginfo) resolvedRelease(d Driver) (resolution.ResolvedRelease, error) { if err != nil { return resolution.ResolvedRelease{}, err } - artifact.URL = uri.String() + 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 @@ -125,7 +128,6 @@ func (p registryPackage) resolveArtifact() (resolution.Artifact, error) { } return resolution.Artifact{ Target: target, - URL: p.URL, Hash: hash, Size: size, }, nil diff --git a/registry_artifact_test.go b/registry_artifact_test.go index 95c83422..bfdba00b 100644 --- a/registry_artifact_test.go +++ b/registry_artifact_test.go @@ -165,7 +165,29 @@ func TestResolvedRegistryReleaseKeepsSourceAndArtifactHostsSeparate(t *testing.T require.NoError(t, err) assert.Equal(t, "https://registry.example.test", resolved.Source.Reference) require.Len(t, resolved.Artifacts, 1) - assert.Equal(t, "https://packages.example.test/driver.tar.gz", resolved.Artifacts[0].URL) + 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) { @@ -213,7 +235,7 @@ func TestResolvedRegistryReleaseMatchesGetPackageURLs(t *testing.T) { pkg, err := driver.GetPackage(version, rawPackage.PlatformTuple, false) require.NoError(t, err) require.NotNil(t, pkg.Path) - assert.Equal(t, pkg.Path.String(), artifact.URL) + assert.Equal(t, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: pkg.Path.String()}, artifact.Location) } } @@ -231,7 +253,7 @@ func TestRegistryTupleAliasesCanonicalizeWithoutChangingImplicitAssetURL(t *test 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, "https://registry.example.test/example-driver/1.2.3/example-driver_linux_x86_64-1.2.3.tar.gz", resolved.Artifacts[0].URL, + 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") } From 4729a0f06b0d58727b3d23541af74f0eb87f695a Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 15:24:15 +0000 Subject: [PATCH 035/115] refactor: generalize resolution evidence --- cmd/dbc/lockfile.go | 15 ++- cmd/dbc/lockfile_v2.go | 83 ++++++++----- cmd/dbc/lockfile_v2_test.go | 144 ++++++++++++++++++++-- internal/packslip/resolver_native.go | 26 ++-- internal/packslip/resolver_native_test.go | 54 +++++++- internal/resolution/types.go | 67 ++++++++-- internal/resolution/types_test.go | 57 ++++++++- 7 files changed, 370 insertions(+), 76 deletions(-) diff --git a/cmd/dbc/lockfile.go b/cmd/dbc/lockfile.go index 676f477b..c302dfa8 100644 --- a/cmd/dbc/lockfile.go +++ b/cmd/dbc/lockfile.go @@ -47,7 +47,7 @@ type lockInfo struct { Name string `toml:"name"` Version *semver.Version `toml:"version"` Source lockSource `toml:"source"` - Evidence lockEvidence `toml:"evidence,omitempty"` + 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 @@ -64,10 +64,15 @@ type lockSource struct { } type lockEvidence struct { - BundleURL string `toml:"bundle_url,omitempty"` - BundleHash string `toml:"bundle_hash,omitempty"` - ReleaseListURL string `toml:"release_list_url,omitempty"` - ReleaseListHash string `toml:"release_list_hash,omitempty"` + Kind resolution.EvidenceKind `toml:"kind"` + Location resolution.ArtifactLocation `toml:"location"` + Hash string `toml:"hash"` + // These fields detect the source-specific evidence shape used by the + // unreleased single-table lock representation. + LegacyBundleURL *string `toml:"bundle_url,omitempty"` + LegacyBundleHash *string `toml:"bundle_hash,omitempty"` + LegacyReleaseListURL *string `toml:"release_list_url,omitempty"` + LegacyReleaseListHash *string `toml:"release_list_hash,omitempty"` } type lockArtifact struct { diff --git a/cmd/dbc/lockfile_v2.go b/cmd/dbc/lockfile_v2.go index 2953ef15..99e35210 100644 --- a/cmd/dbc/lockfile_v2.go +++ b/cmd/dbc/lockfile_v2.go @@ -89,12 +89,7 @@ func lockInfoFromResolvedRelease(name string, release resolution.ResolvedRelease Source: lockSource{ Type: release.Source.Type, }, - Evidence: lockEvidence{ - BundleURL: release.Evidence.BundleURL, - BundleHash: release.Evidence.BundleHash, - ReleaseListURL: release.Evidence.ReleaseListURL, - ReleaseListHash: release.Evidence.ReleaseListHash, - }, + Evidence: lockEvidenceFromResolution(release.Evidence), } switch release.Source.Type { case "packslip": @@ -147,12 +142,7 @@ func (d lockInfo) resolvedRelease() resolution.ResolvedRelease { release := resolution.ResolvedRelease{ DriverID: d.Name, Source: resolution.SourceSpec{Type: d.Source.Type, Reference: sourceReference}, - Evidence: resolution.Evidence{ - BundleURL: d.Evidence.BundleURL, - BundleHash: d.Evidence.BundleHash, - ReleaseListURL: d.Evidence.ReleaseListURL, - ReleaseListHash: d.Evidence.ReleaseListHash, - }, + Evidence: resolutionEvidenceFromLock(d.Evidence), } if d.Version != nil { release.Version = d.Version.String() @@ -196,28 +186,14 @@ func validateLockInfo(entry lockInfo) error { if err := validateLockSource(entry.Source); err != nil { return fmt.Errorf("driver %q source: %w", entry.Name, err) } - if (entry.Evidence.BundleURL == "") != (entry.Evidence.BundleHash == "") { - return fmt.Errorf("driver %q evidence must include both bundle URL and hash", entry.Name) - } - if entry.Evidence.BundleURL != "" { - if err := validateHTTPURL("bundle URL", entry.Evidence.BundleURL); err != nil { - return fmt.Errorf("driver %q evidence: %w", entry.Name, err) + for i, evidence := range entry.Evidence { + if evidence.LegacyBundleURL != nil || evidence.LegacyBundleHash != nil || + evidence.LegacyReleaseListURL != nil || evidence.LegacyReleaseListHash != nil { + return fmt.Errorf("driver %q evidence %d uses obsolete source-specific fields", entry.Name, i) } } - if (entry.Evidence.ReleaseListURL == "") != (entry.Evidence.ReleaseListHash == "") { - return fmt.Errorf("driver %q evidence must include both release-list URL and hash", entry.Name) - } - if entry.Evidence.ReleaseListURL != "" { - if err := validateHTTPURL("release-list URL", entry.Evidence.ReleaseListURL); err != nil { - return fmt.Errorf("driver %q evidence: %w", entry.Name, err) - } - } - for _, hash := range []string{entry.Evidence.BundleHash, entry.Evidence.ReleaseListHash} { - if hash != "" { - if err := validateLockHash(hash); err != nil { - return fmt.Errorf("driver %q evidence: %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) @@ -233,6 +209,28 @@ func cloneInt64(value *int64) *int64 { 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. @@ -308,7 +306,7 @@ func refreshLockEntry(existing, refreshed lockInfo) (lockInfo, error) { merged.Artifacts = append(merged.Artifacts, candidate) } } - merged.Evidence = refreshed.Evidence + merged.Evidence = canonicalLockEvidence(refreshed.Evidence) if existing.Legacy != nil { proof := *existing.Legacy merged.Legacy = &proof @@ -408,6 +406,7 @@ func canonicalLockFile(lock LockFile) LockFile { 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]) @@ -427,3 +426,21 @@ func canonicalLockFile(lock LockFile) LockFile { } 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 index 35ffc5a2..f05b87a0 100644 --- a/cmd/dbc/lockfile_v2_test.go +++ b/cmd/dbc/lockfile_v2_test.go @@ -57,10 +57,11 @@ func testLockEntry() lockInfo { Name: "example", Version: version, Source: lockSource{Type: "packslip", Project: "github.com/example/driver"}, - Evidence: lockEvidence{ - BundleURL: "https://example.test/release.packslip", - BundleHash: "sha256:" + strings.Repeat("e", 64), - }, + 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", @@ -84,6 +85,12 @@ 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"}, @@ -97,12 +104,18 @@ func TestLockFileV2DeterministicRoundTripPreservesArtifactMetadata(t *testing.T) 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) @@ -117,6 +130,11 @@ func TestLockFileV2DeterministicRoundTripPreservesArtifactMetadata(t *testing.T) 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, @@ -206,12 +224,55 @@ func TestLockFileV2UsesTypedSourceFieldsAndRequiresEvidencePairs(t *testing.T) { 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.BundleHash = "" + entry.Evidence[0].Hash = "" err = validateLockInfo(entry) require.Error(t, err) - assert.Contains(t, err.Error(), "both bundle URL and hash") + assert.Contains(t, err.Error(), "no hash") +} + +func TestLockFileV2RejectsLegacyEvidenceWireForms(t *testing.T) { + base := "version = 2\nrevision = 0\n\n[[drivers]]\nname = 'example'\nversion = '1.2.3'\n\n[drivers.source]\ntype = 'registry'\nurl = 'https://registry.example.test'\n" + artifact := "\n[[drivers.artifacts]]\nformat = 'tar.gz'\nhash = 'sha256:" + strings.Repeat("a", 64) + "'\nsize = 1\n[drivers.artifacts.target]\nos = 'linux'\narch = 'amd64'\nlibc = 'gnu'\n[drivers.artifacts.location]\nkind = 'url'\nvalue = 'https://assets.example.test/archive.tar.gz'\n" + tests := []struct { + name string + evidence string + wantErr string + }{ + { + name: "old single table", + evidence: "\n[drivers.evidence]\nbundle_url = 'https://example.test/bundle'\nbundle_hash = 'sha256:" + strings.Repeat("e", 64) + "'\n", + wantErr: "obsolete source-specific fields", + }, + { + name: "old fields in new evidence array", + evidence: "\n[[drivers.evidence]]\nbundle_url = 'https://example.test/bundle'\nbundle_hash = 'sha256:" + strings.Repeat("e", 64) + "'\n", + wantErr: "obsolete source-specific fields", + }, + { + name: "old field alongside valid new evidence", + evidence: "\n[[drivers.evidence]]\nkind = 'release-metadata'\nhash = 'sha256:" + strings.Repeat("e", 64) + "'\nbundle_url = 'https://example.test/bundle'\n[drivers.evidence.location]\nkind = 'url'\nvalue = 'https://example.test/bundle'\n", + wantErr: "obsolete source-specific fields", + }, + { + name: "empty old field alongside valid new evidence", + evidence: "\n[[drivers.evidence]]\nkind = 'release-metadata'\nhash = 'sha256:" + strings.Repeat("e", 64) + "'\nbundle_url = ''\n[drivers.evidence.location]\nkind = 'url'\nvalue = 'https://example.test/bundle'\n", + wantErr: "obsolete source-specific fields", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "dbc.lock") + require.NoError(t, os.WriteFile(path, []byte(base+test.evidence+artifact), 0o600)) + _, err := loadLockFile(path) + assert.ErrorContains(t, err, test.wantErr) + }) + } } func TestLockFileV2RejectsObsoleteArtifactSelectorFields(t *testing.T) { @@ -325,6 +386,46 @@ func TestRefreshRejectsArtifactContradictionAndAllowsNewTarget(t *testing.T) { 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 @@ -720,6 +821,29 @@ func TestLockResolvedReleaseRoundTripPreservesEvidenceAndSelectors(t *testing.T) 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") +} + +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 { @@ -728,9 +852,11 @@ func testResolvedRelease() resolution.ResolvedRelease { DriverID: "example", Version: "1.2.3", Source: resolution.SourceSpec{Type: "packslip", Reference: "github.com/example/driver"}, - Evidence: resolution.Evidence{ - BundleURL: "https://example.test/bundle", BundleHash: "sha256:" + strings.Repeat("e", 64), - }, + 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, diff --git a/internal/packslip/resolver_native.go b/internal/packslip/resolver_native.go index c41b2683..206adb6e 100644 --- a/internal/packslip/resolver_native.go +++ b/internal/packslip/resolver_native.go @@ -330,6 +330,9 @@ func (r *nativeResolver) verifyReleaseBundle(ctx context.Context, bundleBytes [] } 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 := validateSupportedArtifactSet(verified.statement); err != nil { return resolution.ResolvedRelease{}, err } @@ -346,14 +349,23 @@ func (r *nativeResolver) finishResolved(ctx context.Context, project string, req 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: request.DriverID, - Version: verified.statement.predicate.Version, - Source: resolution.SourceSpec{Type: "packslip", Reference: project}, - Evidence: resolution.Evidence{ - BundleURL: bundleURL, BundleHash: checksum, - ReleaseListURL: listURL, ReleaseListHash: listHash, - }, + DriverID: request.DriverID, + Version: verified.statement.predicate.Version, + Source: resolution.SourceSpec{Type: "packslip", Reference: project}, + Evidence: evidence, Artifacts: []resolution.Artifact{artifact}, } if err := resolution.ValidateResolvedRelease(result); err != nil { diff --git a/internal/packslip/resolver_native_test.go b/internal/packslip/resolver_native_test.go index 5d79b20c..c5bdabd0 100644 --- a/internal/packslip/resolver_native_test.go +++ b/internal/packslip/resolver_native_test.go @@ -200,10 +200,18 @@ func TestResolverUsesSignedListDigestAndBuildsResolvedRelease(t *testing.T) { 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, "sha256:"+digestHex(releaseBytes), resolved.Evidence.BundleHash) - require.Equal(t, "sha256:"+digestHex(listBytes), resolved.Evidence.ReleaseListHash) - require.Equal(t, server.URL+"/acme/driver/HEAD/.well-known/packslip.json", resolved.Evidence.ReleaseListURL) - require.Equal(t, server.URL+"/assets/release.json", resolved.Evidence.BundleURL) + 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, 1, "a resolution contains the artifact selected for its requested target") 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) @@ -225,8 +233,42 @@ func TestResolverRetainsSignedListEvidenceWhenReleaseIsAbsentFromList(t *testing 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: "iceberg", Version: "1.0.0", Target: Target{OS: "linux", Arch: "x86_64", LibC: "gnu"}}) require.NoError(t, err) - require.NotEmpty(t, resolved.Evidence.ReleaseListURL) - require.Equal(t, "sha256:"+digestHex(fixture.list), resolved.Evidence.ReleaseListHash) + 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: "iceberg", Version: "1.0.0", Target: Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, + }) + 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) { diff --git a/internal/resolution/types.go b/internal/resolution/types.go index 82ccf4c7..aebf856a 100644 --- a/internal/resolution/types.go +++ b/internal/resolution/types.go @@ -206,26 +206,33 @@ type SourceSpec struct { 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 + Evidence []Evidence Artifacts []Artifact } -// Evidence records the immutable metadata used to resolve a source. It is -// separate from SourceSpec because evidence describes one resolution event, -// while source identity remains stable across resolutions. -type Evidence struct { - BundleURL string - BundleHash string - ReleaseListURL string - ReleaseListHash string -} - // Artifact describes one downloadable archive for one concrete target. Hash and // Size are optional so older registry entries remain representable. type Artifact struct { @@ -279,10 +286,48 @@ func ValidateArtifactMetadata(hash string, size *int64) error { 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 +} + // ValidateResolvedRelease checks the contract required before a release can // become a lockfile snapshot. Registry candidates may omit hashes or sizes; // only the snapshot boundary requires them to be finalized. func ValidateResolvedRelease(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") } diff --git a/internal/resolution/types_test.go b/internal/resolution/types_test.go index da445314..b545e932 100644 --- a/internal/resolution/types_test.go +++ b/internal/resolution/types_test.go @@ -31,10 +31,11 @@ func TestValidateResolvedRelease(t *testing.T) { Type: "packslip", Reference: "github.com/example/example-driver", }, - Evidence: Evidence{ - BundleURL: "https://example.test/release.sigstore.json", - BundleHash: "sha256:" + strings.Repeat("a", 64), - }, + 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", @@ -52,10 +53,56 @@ func TestValidateResolvedRelease(t *testing.T) { require.NoError(t, ValidateResolvedRelease(complete)) assert.Equal(t, "packslip", complete.Source.Type) - assert.Equal(t, "https://example.test/release.sigstore.json", complete.Evidence.BundleURL) + 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{{ From da5d8e0ec0533fa7f16dc75b7777bbb2b1af3bea Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 15:38:49 +0000 Subject: [PATCH 036/115] feat: snapshot all Packslip targets --- cmd/dbc/lockfile_v2_test.go | 16 +++ internal/packslip/model.go | 4 +- internal/packslip/resolver_js.go | 2 +- internal/packslip/resolver_native.go | 73 +++++++++-- internal/packslip/resolver_native_test.go | 152 ++++++++++++++++++---- internal/packslip/types.go | 9 +- 6 files changed, 217 insertions(+), 39 deletions(-) diff --git a/cmd/dbc/lockfile_v2_test.go b/cmd/dbc/lockfile_v2_test.go index f05b87a0..3a529dd1 100644 --- a/cmd/dbc/lockfile_v2_test.go +++ b/cmd/dbc/lockfile_v2_test.go @@ -484,6 +484,22 @@ func TestLockArtifactsAllowSharedLocationOnlyWithConsistentMetadata(t *testing.T assert.NotEqual(t, first.Location, second.Location, "same value with distinct kinds has a distinct identity") } +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) + require.NoError(t, resolution.ValidateResolvedRelease(release)) + + 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)} diff --git a/internal/packslip/model.go b/internal/packslip/model.go index 8fa170ef..a150dd1b 100644 --- a/internal/packslip/model.go +++ b/internal/packslip/model.go @@ -861,8 +861,8 @@ func supportedArchiveFormat(format string) bool { return format == "tar.gz" || format == "tgz" } -// validateSupportedArtifactSet checks ambiguity over the entire signed release, -// including platforms other than the one currently requested. +// validateSupportedArtifactSet checks ambiguity across the entire signed +// release before concrete targets are finalized. func validateSupportedArtifactSet(release *parsedRelease) error { count := 0 for i := range release.predicate.Artifacts { diff --git a/internal/packslip/resolver_js.go b/internal/packslip/resolver_js.go index 9d369a0b..17243b7e 100644 --- a/internal/packslip/resolver_js.go +++ b/internal/packslip/resolver_js.go @@ -20,7 +20,7 @@ import ( type unsupportedResolver struct{} -// NewResolver returns a resolver that rejects unsupported targets before +// 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 } diff --git a/internal/packslip/resolver_native.go b/internal/packslip/resolver_native.go index 206adb6e..86853b41 100644 --- a/internal/packslip/resolver_native.go +++ b/internal/packslip/resolver_native.go @@ -19,6 +19,7 @@ import ( "fmt" "net/http" "regexp" + "sort" "strings" "time" @@ -88,10 +89,6 @@ func (r *nativeResolver) Resolve(ctx context.Context, source PackslipSource, req 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) } - request.Target = resolution.CanonicalTarget(request.Target) - if err := resolution.ValidateConcreteTarget(request.Target); err != nil { - return resolution.ResolvedRelease{}, fmt.Errorf("invalid packslip target: %w", err) - } source.Project = project identityPolicy, err := githubIdentityPolicy(project) if err != nil { @@ -336,13 +333,21 @@ func (r *nativeResolver) finishResolved(ctx context.Context, project string, req if err := validateSupportedArtifactSet(verified.statement); err != nil { return resolution.ResolvedRelease{}, err } - selected, err := selectArtifact(verified.statement, request.Target) + targets, err := deriveConcreteTargets(verified.statement) if err != nil { return resolution.ResolvedRelease{}, err } - artifact, err := convertSelectedArtifact(verified.statement, selected, assets, request.Target) - 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 { @@ -366,7 +371,7 @@ func (r *nativeResolver) finishResolved(ctx context.Context, project string, req Version: verified.statement.predicate.Version, Source: resolution.SourceSpec{Type: "packslip", Reference: project}, Evidence: evidence, - Artifacts: []resolution.Artifact{artifact}, + Artifacts: artifacts, } if err := resolution.ValidateResolvedRelease(result); err != nil { return resolution.ResolvedRelease{}, fmt.Errorf("invalid resolved packslip release: %w", err) @@ -378,6 +383,56 @@ func (r *nativeResolver) finishResolved(ctx context.Context, project string, req 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 !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") diff --git a/internal/packslip/resolver_native_test.go b/internal/packslip/resolver_native_test.go index c5bdabd0..db76b9b2 100644 --- a/internal/packslip/resolver_native_test.go +++ b/internal/packslip/resolver_native_test.go @@ -186,15 +186,17 @@ func TestResolverUsesSignedListDigestAndBuildsResolvedRelease(t *testing.T) { 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-linux.tar.gz", "linux", "x86_64", "gnu", "tar.gz", "https://downloads.example/driver-linux.tar.gz"), - artifact("driver-macos.tgz", "darwin", "aarch64", "", "tgz", "https://downloads.example/driver-macos.tgz"), 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: "iceberg", Version: "1.0.0", Target: Target{OS: "linux", Arch: "amd64", LibC: "gnu"}}) + resolved, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "iceberg", Version: "1.0.0"}) require.NoError(t, err) require.Equal(t, "iceberg", resolved.DriverID) require.Equal(t, "1.0.0", resolved.Version) @@ -212,26 +214,130 @@ func TestResolverUsesSignedListDigestAndBuildsResolvedRelease(t *testing.T) { Hash: "sha256:" + digestHex(listBytes), }, }, resolved.Evidence) - require.Len(t, resolved.Artifacts, 1, "a resolution contains the artifact selected for its requested target") + 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) - macos, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{ - DriverID: "iceberg", Version: "1.0.0", Target: Target{OS: "macos", Arch: "arm64"}, - }) + 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 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 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.Len(t, macos.Artifacts, 1) - require.Equal(t, Target{OS: "macos", Arch: "arm64"}, macos.Artifacts[0].Target) - require.Equal(t, resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://downloads.example/driver-macos.tgz"}, macos.Artifacts[0].Location) - require.Equal(t, int32(2), fixture.apiHits.Load()) + 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: "iceberg", Version: "1.0.0", Target: Target{OS: "linux", Arch: "x86_64", LibC: "gnu"}}) + resolved, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "iceberg", Version: "1.0.0"}) require.NoError(t, err) require.Len(t, resolved.Evidence, 2) require.Equal(t, resolution.EvidenceKindReleaseIndex, resolved.Evidence[1].Kind) @@ -243,7 +349,7 @@ 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: "iceberg", Version: "1.0.0", Target: Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, + DriverID: "iceberg", Version: "1.0.0", }) require.NoError(t, err) require.Equal(t, []resolution.Evidence{{ @@ -278,7 +384,7 @@ func TestResolverPersistsSequenceBeforeYankAndRejectsRollback(t *testing.T) { 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", Target: Target{OS: "linux", Arch: "x86_64", LibC: "gnu"}}) + _, 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) @@ -298,7 +404,7 @@ func TestResolverPersistsSequenceBeforeYankAndRejectsRollback(t *testing.T) { }, 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", Target: Target{OS: "linux", Arch: "x86_64", LibC: "gnu"}}) + _, err = resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) require.ErrorContains(t, err, "sequence rollback") } @@ -308,7 +414,7 @@ func TestResolverRejectsExpiredListAndAcceptedListDisappearance(t *testing.T) { 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", Target: Target{OS: "linux", Arch: "x86_64"}}) + _, 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) @@ -322,7 +428,7 @@ func TestResolverRejectsExpiredListAndAcceptedListDisappearance(t *testing.T) { 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", Target: Target{OS: "linux", Arch: "x86_64"}}) + _, err = missingResolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) require.ErrorContains(t, err, "previously accepted signed release list") } @@ -332,7 +438,7 @@ func TestResolverRejectsBundleDigestMismatchWrongProjectAndInvalidSignature(t *t 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", Target: Target{OS: "linux", Arch: "x86_64", LibC: "gnu"}}) + _, 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") @@ -341,13 +447,13 @@ func TestResolverRejectsBundleDigestMismatchWrongProjectAndInvalidSignature(t *t 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", Target: Target{OS: "linux", Arch: "x86_64", LibC: "gnu"}}) + _, 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", Target: Target{OS: "linux", Arch: "x86_64", LibC: "gnu"}}) + _, err = resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) require.ErrorContains(t, err, "signature is invalid") } @@ -361,7 +467,7 @@ func TestResolverRejectsArtifactSelectionAmbiguityAndMalformedHashOrSize(t *test ) 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", Target: Target{OS: "linux", Arch: "x86_64"}}) + _, 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) @@ -373,7 +479,7 @@ func TestResolverRejectsArtifactSelectionAmbiguityAndMalformedHashOrSize(t *test 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", Target: Target{OS: "linux", Arch: "x86_64", LibC: "gnu"}}) + _, 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) @@ -386,7 +492,7 @@ func TestResolverRejectsArtifactSelectionAmbiguityAndMalformedHashOrSize(t *test 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", Target: Target{OS: "linux", Arch: "x86_64", LibC: "gnu"}}) + _, 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) diff --git a/internal/packslip/types.go b/internal/packslip/types.go index 5016eff0..7312abd0 100644 --- a/internal/packslip/types.go +++ b/internal/packslip/types.go @@ -47,14 +47,14 @@ type PackslipSource struct { Project string } -// Target is the canonical host tuple used to select one archive from a release. +// Target is the canonical platform tuple used in a resolution snapshot. type Target = resolution.Target -// Request identifies one driver release and the archive needed by this host. +// Request identifies one driver release whose installable artifacts are +// snapshotted for every concrete target advertised by the signed release. type Request struct { DriverID string Version string - Target Target } // TrustStore retains the identities and signed-list state accepted by dbc. @@ -87,7 +87,8 @@ type Config struct { Now func() time.Time } -// Resolver resolves authenticated release metadata for one target. +// 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) } From d261ade0d914e6927ca8479c3ea0a7fcf6b6db3b Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 16:07:27 +0000 Subject: [PATCH 037/115] fix: align Packslip selection rules --- internal/packslip/conformance_test.go | 160 ++++ internal/packslip/model.go | 52 +- internal/packslip/model_test.go | 38 + internal/packslip/resolver_native_test.go | 82 ++- .../packslip/testdata/conformance/LICENSE | 21 + .../packslip/testdata/conformance/README.md | 12 + .../conformance/artifact-selection.json | 281 +++++++ .../conformance/statement-validity.json | 688 ++++++++++++++++++ .../testdata/conformance/tag-versions.json | 19 + 9 files changed, 1326 insertions(+), 27 deletions(-) create mode 100644 internal/packslip/conformance_test.go create mode 100644 internal/packslip/testdata/conformance/LICENSE create mode 100644 internal/packslip/testdata/conformance/README.md create mode 100644 internal/packslip/testdata/conformance/artifact-selection.json create mode 100644 internal/packslip/testdata/conformance/statement-validity.json create mode 100644 internal/packslip/testdata/conformance/tag-versions.json 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/model.go b/internal/packslip/model.go index a150dd1b..703f2af2 100644 --- a/internal/packslip/model.go +++ b/internal/packslip/model.go @@ -241,6 +241,7 @@ func parseRelease(payload []byte, expectedProject string) (*parsedRelease, error 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 == ".." { @@ -260,6 +261,11 @@ func parseRelease(payload []byte, expectedProject string) (*parsedRelease, error 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, } { @@ -805,18 +811,21 @@ func normalizeLooseTagVersion(value string) (string, bool) { } func selectArtifact(release *parsedRelease, target Target) (*releaseArtifact, error) { - target = resolution.CanonicalTarget(target) + return selectArtifactByFormats(release, 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 } - formats := supportedArchiveFormats bestSpecificity, bestFormat := -1, len(formats) var chosen, tied *releaseArtifact for i := range release.predicate.Artifacts { artifact := &release.predicate.Artifacts[i] - if !supportedArchiveFormat(stringValue(artifact.Format)) { - continue - } if !fitsTarget(artifact.OS, target.OS) || !fitsTarget(artifact.Arch, target.Arch) || !fitsTarget(artifact.LibC, target.LibC) { continue } @@ -941,23 +950,32 @@ func artifactSelectorKey(artifact *releaseArtifact) string { } func canonicalizeArtifactAliases(artifact *releaseArtifact) { - if artifact.OS != nil && *artifact.OS == "darwin" { - value := "macos" - artifact.OS = &value + if artifact.OS != nil { + osName, _ := canonicalizeOSArchAliases(*artifact.OS, "") + artifact.OS = &osName } - if artifact.Arch == nil { - return + if artifact.Arch != nil { + _, arch := canonicalizeOSArchAliases("", *artifact.Arch) + artifact.Arch = &arch } - var value string - switch *artifact.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": - value = "amd64" + arch = "amd64" case "aarch64": - value = "arm64" - default: - return + arch = "arm64" } - artifact.Arch = &value + return osName, arch } func fitsTarget(value *string, target string) bool { diff --git a/internal/packslip/model_test.go b/internal/packslip/model_test.go index 836fd110..6182aca0 100644 --- a/internal/packslip/model_test.go +++ b/internal/packslip/model_test.go @@ -176,6 +176,44 @@ func TestSelectArtifactUsesPackslipOrderAndRejectsAmbiguity(t *testing.T) { 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 := parseRelease(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 := parseRelease(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 := parseRelease(validRelease("1.0.0", + artifact("first.tar.gz", "linux", "amd64"), + artifact("second.tar.gz", "linux", "amd64"), + ), testProject) + require.ErrorContains(t, err, "duplicate selectors") + + release, err := parseRelease(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") +} + func optionalToken(value string) *string { if value == "" { return nil diff --git a/internal/packslip/resolver_native_test.go b/internal/packslip/resolver_native_test.go index db76b9b2..f1665933 100644 --- a/internal/packslip/resolver_native_test.go +++ b/internal/packslip/resolver_native_test.go @@ -129,12 +129,16 @@ func makeSignedList(t *testing.T, project string, sequence uint64, expiry string } type resolverHTTPFixtures struct { - project string - tag string - list []byte - release []byte - artifactURL string - apiHits atomic.Int32 + project string + tag string + list []byte + release []byte + artifactURL string + releaseAssets []githubAsset + customReleaseAssets bool + apiHits atomic.Int32 + requestHits atomic.Int32 + archiveHits atomic.Int32 } func newResolverTestServer(t *testing.T, project, tag string, listBytes, releaseBytes []byte, artifactURL string) (*httptest.Server, *nativeResolver, *resolverHTTPFixtures) { @@ -142,6 +146,7 @@ func newResolverTestServer(t *testing.T, project, tag string, listBytes, release 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 { @@ -152,12 +157,19 @@ func newResolverTestServer(t *testing.T, project, tag string, listBytes, release _, _ = w.Write(fixture.list) case strings.HasPrefix(r.URL.Path, "/repos/") && strings.HasSuffix(r.URL.Path, "/releases"): fixture.apiHits.Add(1) - _, _ = w.Write(mustJSON([]githubRelease{{TagName: fixture.tag, Assets: []githubAsset{ - {Name: "packslip.sigstore.json", BrowserDownloadURL: serverURL(r, "/assets/release.json")}, - {Name: "driver-linux.tar.gz", BrowserDownloadURL: fixture.artifactURL}, - }}})) + 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": _, _ = 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) } @@ -308,6 +320,19 @@ func TestResolverPreservesExplicitMuslTargetAndIgnoresUnsupportedFormatSeeds(t * 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 := parseRelease(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")) @@ -457,6 +482,43 @@ func TestResolverRejectsBundleDigestMismatchWrongProjectAndInvalidSignature(t *t 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 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)} 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 } + ] +} From 336420db1d9891ddd05872ebbbf04b29d5d8ed1f Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 16:11:24 +0000 Subject: [PATCH 038/115] docs: note Packslip package contract --- internal/packslip/resolver_native.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/packslip/resolver_native.go b/internal/packslip/resolver_native.go index 86853b41..3a85a842 100644 --- a/internal/packslip/resolver_native.go +++ b/internal/packslip/resolver_native.go @@ -333,6 +333,14 @@ func (r *nativeResolver) finishResolved(ctx context.Context, project string, req if err := validateSupportedArtifactSet(verified.statement); err != nil { return resolution.ResolvedRelease{}, err } + // TODO(packslip): Before exposing Packslip as a public dbc source, require a + // signed dbc consumer declaration in Packslip predicate/artifact extensions. + // The prototype currently treats supported tar.gz/tgz artifacts as dbc + // package candidates. The declaration should identify the dbc ADBC driver + // and package contract, runtime identity, installable artifacts, and package + // format; release/artifact field ownership remains undecided. After download, + // verify it against dbc-package.toml and fail on mismatches. Keep the schema + // unspecified until that contract is designed. targets, err := deriveConcreteTargets(verified.statement) if err != nil { return resolution.ResolvedRelease{}, err From 5cf4a05dce602e4f758ab2ed319a8fa903c99e7f Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 16:24:27 +0000 Subject: [PATCH 039/115] feat: validate packages before installation --- config/package_archive.go | 111 +++++++++++++++++++++++--------- config/package_archive_test.go | 113 +++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 30 deletions(-) diff --git a/config/package_archive.go b/config/package_archive.go index 4cf09582..ce18f707 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -74,6 +74,13 @@ type InstallOptions struct { 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 +} + type packageManifest struct { manifest Manifest id string @@ -327,6 +334,42 @@ func InstallPackage(cfg Config, runtimeID string, downloaded *os.File, expected return installPackage(cfg, runtimeID, downloaded, expected, options, CreateManifest) } +// ValidatePackage verifies and stages an already-downloaded package in a +// private temporary directory without registering it or changing shared +// configuration. The temporary directory is removed before ValidatePackage +// returns. The archive remains open and can be passed to InstallPackage after +// validation. +func ValidatePackage(runtimeID string, downloaded *os.File, expected ExpectedPackageMetadata, options InstallOptions) (validation PackageValidation, err error) { + if downloaded == nil { + return PackageValidation{}, errors.New("package archive is nil") + } + expected, err = normalizePackageInstallMetadata(runtimeID, expected) + if err != nil { + return PackageValidation{}, err + } + workDir, err := os.MkdirTemp("", "dbc-package-validate-") + if err != nil { + return PackageValidation{}, fmt.Errorf("could not create private package validation directory: %w", err) + } + defer func() { + if cleanupErr := os.RemoveAll(workDir); cleanupErr != nil { + validation = PackageValidation{} + err = errors.Join(err, fmt.Errorf("could not remove package validation directory: %w", cleanupErr)) + } + }() + + finalDir := filepath.Join(workDir, "installed") + _, payloadDir, err := stagePackageArchive(workDir, runtimeID, finalDir, downloaded, expected, options.Verify, workDir) + if err != nil { + return PackageValidation{}, err + } + receipt, ok := readPackageReceipt(workDir, runtimeID, payloadDir) + if !ok { + return PackageValidation{}, errors.New("could not read validated package receipt") + } + return PackageValidation{VerifiedLibraryHash: receipt.InstalledLibraryHash}, nil +} + 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) } @@ -335,36 +378,9 @@ func installPackageWithCleanup(cfg Config, runtimeID string, downloaded *os.File if downloaded == nil { return Manifest{}, errors.New("package archive is nil") } - if err := validateFlatName(runtimeID); err != nil { - return Manifest{}, fmt.Errorf("invalid runtime driver id: %w", err) - } - if expected.ID == "" { - expected.ID = runtimeID - } - if expected.ID != runtimeID { - return Manifest{}, fmt.Errorf("expected package id %q does not match runtime driver id %q", expected.ID, runtimeID) - } - if expected.ArchiveHash != "" || expected.ArchiveSize != 0 { - if err := validateExpectedPackage(expected); err != nil { - return Manifest{}, err - } - } else { - if expected.Version != "" { - if _, err := semver.NewVersion(expected.Version); err != nil { - return Manifest{}, fmt.Errorf("invalid expected package version %q: %w", expected.Version, err) - } - } - if expected.Platform != "" { - if err := validatePlatformIdentifier(expected.Platform); err != nil { - return Manifest{}, fmt.Errorf("invalid expected package platform: %w", err) - } - } - } - if expected.SourceType == "" { - expected.SourceType = "local" - } - if expected.SourceIdentity == "" { - expected.SourceIdentity = "local" + expected, err := normalizePackageInstallMetadata(runtimeID, expected) + if err != nil { + return Manifest{}, err } loc, err := EnsureLocation(cfg) @@ -425,6 +441,41 @@ func installPackageWithCleanup(cfg Config, runtimeID string, downloaded *os.File return manifest, 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.ArchiveSize != 0 { + 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 diff --git a/config/package_archive_test.go b/config/package_archive_test.go index 714bcd42..793dc6fb 100644 --- a/config/package_archive_test.go +++ b/config/package_archive_test.go @@ -21,6 +21,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -430,6 +431,118 @@ func TestInstallPackageArchiveChecksMetadataAndDigests(t *testing.T) { } } +func TestValidatePackageIsNonMutatingAndArchiveCanBeInstalledAfterward(t *testing.T) { + root := t.TempDir() + cfg := config.Config{Level: config.ConfigEnv, Location: root} + oldArchive := validV2Archive(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 := validV2Archive(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.ValidatePackage("example", candidateFile, candidate, options) + require.NoError(t, err) + 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.ErrorIs(t, err, os.ErrNotExist, "validation staging directory must be removed before return") + + 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.Equal(t, entryNames(entriesBefore), entryNames(entriesAfter)) + + installed, err := config.InstallPackage(cfg, "example", candidateFile, candidate, options) + require.NoError(t, err, "the archive should remain reusable after validation") + assert.Equal(t, 2, verifyCalls, "install should perform its own verification") + installedLibrary, err := os.ReadFile(installed.Driver.Shared.Get(config.PlatformTuple())) + require.NoError(t, err) + assert.Equal(t, candidateLibrary, installedLibrary) +} + +func TestValidatePackageRejectsMetadataAndVerifierFailures(t *testing.T) { + archive := validV2Archive(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 = "macos_arm64" }}, + {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() + _, err := config.ValidatePackage("example", file, expected, config.InstallOptions{}) + 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() + _, err := config.ValidatePackage("example", file, + expectedPackage("example", "1.2.3", config.PlatformTuple(), "source", archive), + config.InstallOptions{Verify: func(dir string, _ config.Manifest) error { + stagingDir = dir + return verifyErr + }}) + 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 TestInstallPackageArchiveReceiptReplacementAndRuntimeManifest(t *testing.T) { root := t.TempDir() cfg := config.Config{Level: config.ConfigEnv, Location: root} From 707ae04f30e75a215a9e66925f2a04480823b986 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 22:33:29 +0000 Subject: [PATCH 040/115] feat: persist sync plan before installation --- cmd/dbc/sync.go | 353 +++++++++++++++++++++++++++---------------- cmd/dbc/sync_test.go | 300 ++++++++++++++++++++++++++++++++++++ 2 files changed, 521 insertions(+), 132 deletions(-) diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 0137e404..e291f948 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -109,8 +109,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 @@ -192,8 +193,19 @@ type installItem struct { ArchiveSize int64 InstalledLibraryHash string LockEntry *lockInfo + Archive *os.File + Expected config.ExpectedPackageMetadata + AlreadyInstalled *config.DriverInfo + RemovedDriver *config.DriverInfo } +type preparedSyncMsg struct { + items []installItem + lock LockFile +} + +type candidateLockSavedMsg struct{ lock LockFile } + func (s syncModel) createInstallList(list DriversList) ([]installItem, error) { // Load the lock file if it exists lf, err := loadLockFile(s.LockFilePath) @@ -365,101 +377,48 @@ type alreadyInstalledDrvMsg struct { func (s syncModel) installDriver(cfg config.Config, item installItem) tea.Cmd { return func() tea.Msg { - 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 != "" { - // A lockfile checksum proves only the installed library. - if chksum != item.Checksum { - return fmt.Errorf("checksum mismatch for driver %s: %s != %s", - item.Driver.Path, chksum, item.Checksum) - } - } else { - item.Checksum = chksum - } - item.InstalledLibraryHash = chksum - - if !canReuseLockedEntry(item) { - if err := ensureArchiveSnapshot(s, &item); err != nil { - return fmt.Errorf("failed to snapshot driver archive: %w", err) - } - } - - return alreadyInstalledDrvMsg{info: drv, item: item} - } else { - removedDriver = &drv - } - } + if item.Archive == nil { + return errors.New("prepared package archive is missing") } - - // 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 - } - defer output.Close() - if err := snapshotDownloadedArchive(&item, output); err != nil { - prog.Send(fmt.Errorf("failed to snapshot downloaded driver archive: %w", err)) - return - } - - expected, hasMetadata, err := expectedRegistryPackageMetadata(item.Package) - if err != nil { - prog.Send(err) - return - } - if !hasMetadata { - packageManifest, inspectErr := config.InspectPackageMetadata(output) - if inspectErr != nil { - prog.Send(inspectErr) - return - } - if packageManifest.PackageVersion == 2 { - prog.Send(errors.New("registry package v2 requires archive hash and size metadata")) - return - } - } - var verify func(string, config.Manifest) error - if !s.NoVerify { - verify = func(stagingDir string, manifest config.Manifest) error { - return dbc.VerifyPackageSignature(stagingDir, manifest) - } + defer item.Archive.Close() + if _, err := item.Archive.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("failed to rewind prepared driver archive: %w", err) + } + var verify func(string, config.Manifest) error + if !s.NoVerify { + verify = func(stagingDir string, manifest config.Manifest) error { + return dbc.VerifyPackageSignature(stagingDir, manifest) } - manifest, err := config.InstallPackage(cfg, item.Driver.Path, output, expected, config.InstallOptions{ - Verify: verify, - }) - if err != nil { - if isPackageVerificationFailure(err) { - prog.Send(fmt.Errorf("failed to verify signature: %w", packageVerificationError(err))) - } else { - prog.Send(fmt.Errorf("failed to install driver: %w", err)) - } - return + } + manifest, err := config.InstallPackage(cfg, item.Driver.Path, item.Archive, item.Expected, config.InstallOptions{ + Verify: verify, + }) + if err != nil { + if isPackageVerificationFailure(err) { + return fmt.Errorf("failed to verify signature: %w", packageVerificationError(err)) } - - prog.Send(installedDrvMsg{ - removed: removedDriver, - info: manifest.DriverInfo, - postInstall: manifest.PostInstall.Messages, - item: item, - }) - }() - return nil + return fmt.Errorf("failed to install driver: %w", err) + } + return installedDrvMsg{ + removed: item.RemovedDriver, + info: manifest.DriverInfo, + postInstall: manifest.PostInstall.Messages, + item: item, + } } } func (s syncModel) writeLockFile() error { s.locked.Version = lockFileVersion - return writeLockFileAtomic(s.LockFilePath, s.locked) + 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 { @@ -490,23 +449,6 @@ func packageLockSource(item installItem) (lockSource, error) { return lockSource{Type: "registry", URL: item.Driver.Registry.BaseURL.String()}, nil } -func ensureArchiveSnapshot(s syncModel, item *installItem) error { - if item.Package.ArtifactHash != "" && item.Package.ArtifactSize != nil { - if err := resolution.ValidateArtifactMetadata(item.Package.ArtifactHash, item.Package.ArtifactSize); err != nil { - return err - } - item.ArchiveHash = item.Package.ArtifactHash - item.ArchiveSize = *item.Package.ArtifactSize - return nil - } - output, err := s.downloadPkg(item.Package) - if err != nil { - return fmt.Errorf("failed to download archive for lock metadata: %w", err) - } - defer output.Close() - return snapshotDownloadedArchive(item, output) -} - func snapshotDownloadedArchive(item *installItem, archive *os.File) error { if archive == nil { return fmt.Errorf("download returned no archive") @@ -538,6 +480,148 @@ func snapshotDownloadedArchive(item *installItem, archive *os.File) error { return nil } +func (s syncModel) prepareInstallItems(items []installItem) (preparedSyncMsg, error) { + prepared := preparedSyncMsg{items: items, lock: LockFile{Version: lockFileVersion}} + closePrepared := func() { + for i := range prepared.items { + if prepared.items[i].Archive != nil { + _ = prepared.items[i].Archive.Close() + prepared.items[i].Archive = nil + } + } + } + for i := range prepared.items { + item := &prepared.items[i] + if s.cfg.Exists { + if installed, ok := s.cfg.Drivers[item.Driver.Path]; ok { + if item.Package.Version.Equal(installed.Version) { + libraryHash, err := checksum(installed.Driver.Shared.Get(config.PlatformTuple())) + if err != nil { + closePrepared() + return preparedSyncMsg{}, fmt.Errorf("failed to compute checksum: %w", err) + } + if item.Checksum != "" && libraryHash != item.Checksum { + closePrepared() + return preparedSyncMsg{}, fmt.Errorf("checksum mismatch for driver %s: %s != %s", item.Driver.Path, libraryHash, item.Checksum) + } + if item.Checksum == "" { + item.Checksum = libraryHash + } + item.InstalledLibraryHash = libraryHash + installedCopy := installed + item.AlreadyInstalled = &installedCopy + } else { + installedCopy := installed + item.RemovedDriver = &installedCopy + } + } + } + + needsArchive := item.AlreadyInstalled == nil || !canReuseLockedEntry(*item) + if needsArchive { + // Determine whether the registry supplied source metadata before + // measured values are copied into Package below. + expected, hasMetadata, err := expectedRegistryPackageMetadata(item.Package) + if err != nil { + closePrepared() + return preparedSyncMsg{}, err + } + archive, err := s.downloadPkg(item.Package) + if err != nil { + closePrepared() + return preparedSyncMsg{}, fmt.Errorf("failed to download driver: %w", err) + } + item.Archive = archive + if err := snapshotDownloadedArchive(item, archive); err != nil { + closePrepared() + return preparedSyncMsg{}, fmt.Errorf("failed to snapshot downloaded driver archive: %w", err) + } + if !hasMetadata { + if _, err := archive.Seek(0, io.SeekStart); err != nil { + closePrepared() + return preparedSyncMsg{}, fmt.Errorf("failed to rewind downloaded driver archive: %w", err) + } + packageManifest, inspectErr := config.InspectPackageMetadata(archive) + if inspectErr != nil { + closePrepared() + return preparedSyncMsg{}, inspectErr + } + if packageManifest.PackageVersion == 2 { + closePrepared() + return preparedSyncMsg{}, errors.New("registry package v2 requires archive hash and size metadata") + } + } + item.Package.ArtifactHash = item.ArchiveHash + item.Package.ArtifactSize = cloneInt64(&item.ArchiveSize) + expected.ArchiveHash = item.ArchiveHash + expected.ArchiveSize = item.ArchiveSize + item.Expected = expected + if _, err := archive.Seek(0, io.SeekStart); err != nil { + closePrepared() + return preparedSyncMsg{}, fmt.Errorf("failed to rewind downloaded driver archive: %w", err) + } + var verify func(string, config.Manifest) error + if !s.NoVerify { + verify = func(stagingDir string, manifest config.Manifest) error { + return dbc.VerifyPackageSignature(stagingDir, manifest) + } + } + validation, err := config.ValidatePackage(item.Driver.Path, archive, expected, config.InstallOptions{Verify: verify}) + if err != nil { + closePrepared() + if isPackageVerificationFailure(err) { + return preparedSyncMsg{}, fmt.Errorf("failed to verify signature: %w", packageVerificationError(err)) + } + return preparedSyncMsg{}, fmt.Errorf("failed to validate driver package: %w", err) + } + if item.AlreadyInstalled == nil { + item.InstalledLibraryHash = strings.TrimPrefix(validation.VerifiedLibraryHash, "sha256:") + } + if item.AlreadyInstalled != nil { + _ = archive.Close() + item.Archive = nil + } + } else { + // The exact locked artifact is already installed, so it can be reused + // without another download or package validation. + expected, _, err := expectedRegistryPackageMetadata(item.Package) + if err != nil { + closePrepared() + return preparedSyncMsg{}, err + } + item.Expected = expected + } + + entry, err := lockEntryForItem(*item) + if err != nil { + closePrepared() + return preparedSyncMsg{}, fmt.Errorf("failed to update lock entry: %w", err) + } + prepared.lock.Drivers = append(prepared.lock.Drivers, entry) + } + return prepared, nil +} + +func (s syncModel) dispatchInstallItem() tea.Cmd { + if s.index >= len(s.installItems) { + return tea.Quit + } + item := s.installItems[s.index] + if item.AlreadyInstalled != nil { + return func() tea.Msg { return alreadyInstalledDrvMsg{info: *item.AlreadyInstalled, item: item} } + } + return s.installDriver(s.cfg, item) +} + +func closePreparedArchives(items []installItem) { + for i := range items { + if items[i].Archive != nil { + _ = items[i].Archive.Close() + items[i].Archive = nil + } + } +} + func lockEntryForItem(item installItem) (lockInfo, error) { if item.LockEntry != nil && item.LockEntry.Legacy != nil && samePlatformTarget(item.LockEntry.Legacy.Platform, config.PlatformTuple()) { if err := verifyLegacyLibraryProof(*item.LockEntry, config.PlatformTuple(), item.InstalledLibraryHash); err != nil { @@ -687,13 +771,26 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - return s, tea.Batch(s.installDriver(s.cfg, s.installItems[s.index]), s.spinner.Tick) - case alreadyInstalledDrvMsg: - entry, err := lockEntryForItem(msg.item) - if err != nil { - return s, errCmd("failed to update lock entry: %w", err) + return s, tea.Batch(func() tea.Msg { + prepared, err := s.prepareInstallItems(msg) + if err != nil { + return err + } + return prepared + }, s.spinner.Tick) + case preparedSyncMsg: + s.installItems = msg.items + return s, func() tea.Msg { + if err := s.persistCandidateLock(msg.lock); err != nil { + closePreparedArchives(msg.items) + return fmt.Errorf("failed to write candidate lock file: %w", err) + } + return candidateLockSavedMsg{lock: msg.lock} } - s.locked.Drivers = append(s.locked.Drivers, entry) + case candidateLockSavedMsg: + s.locked = msg.lock + return s, tea.Batch(s.dispatchInstallItem(), s.spinner.Tick) + case alreadyInstalledDrvMsg: s.skippedDrivers = append(s.skippedDrivers, jsonschema.SyncedDriver{ Name: msg.info.ID, Version: msg.info.Version.String(), @@ -710,14 +807,9 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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.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) + return s, tea.Sequence(tea.Printf("%s %s-%s already installed", checkMark, msg.info.ID, msg.info.Version), tea.Quit) } s.index++ @@ -725,17 +817,18 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if s.jsonOutput { return s, tea.Batch( progressCmd, - s.installDriver(s.cfg, s.installItems[s.index]), + s.dispatchInstallItem(), ) } 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]), + s.dispatchInstallItem(), ) case installedDrvMsg: chksum, err := checksum(msg.info.Driver.Shared.Get(config.PlatformTuple())) if err != nil { + closePreparedArchives(s.installItems) s.status = 1 if s.jsonOutput { return s, tea.Sequence(tea.Println(marshalEnvelope("error", jsonschema.ErrorResponse{ @@ -745,13 +838,11 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return s, tea.Sequence(tea.Println("Error: ", err), tea.Quit) } - msg.item.InstalledLibraryHash = chksum - entry, err := lockEntryForItem(msg.item) - if err != nil { + if msg.item.InstalledLibraryHash != "" && msg.item.InstalledLibraryHash != chksum { + closePreparedArchives(s.installItems) s.status = 1 - return s, tea.Sequence(tea.Println("Error: ", err), tea.Quit) + return s, tea.Sequence(tea.Println("Error: installed library checksum does not match validated package"), tea.Quit) } - s.locked.Drivers = append(s.locked.Drivers, entry) s.newlyInstalled = append(s.newlyInstalled, jsonschema.SyncedDriver{ Name: msg.info.ID, Version: msg.info.Version.String(), @@ -788,13 +879,10 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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.Quit } return s, tea.Sequence( printCmd, - func() tea.Msg { return s.writeLockFile() }, tea.Quit) } @@ -803,15 +891,16 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if s.jsonOutput { return s, tea.Batch( progressCmd, - s.installDriver(s.cfg, s.installItems[s.index]), + s.dispatchInstallItem(), ) } return s, tea.Batch( progressCmd, printCmd, - s.installDriver(s.cfg, s.installItems[s.index]), + s.dispatchInstallItem(), ) case error: + closePreparedArchives(s.installItems) s.status = 1 s.err = msg if s.jsonOutput { diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index 749194a5..d48910dc 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -15,8 +15,11 @@ package main import ( + "archive/tar" + "compress/gzip" "encoding/json" "fmt" + "io" "os" "path/filepath" "strings" @@ -390,3 +393,300 @@ func (suite *SubcommandTestSuite) TestSync_JSONProgressStream() { suite.Contains(kinds, "sync.progress") suite.Equal("sync.status", kinds[len(kinds)-1]) } + +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) + + badArchivePath := filepath.Join(suite.tempdir, "bad-package.tar.gz") + suite.Require().NoError(os.WriteFile(badArchivePath, []byte("not a package archive"), 0600)) + downloaded := map[string]int{} + downloadCalls := 0 + var firstArchive *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++ + if downloadCalls == 2 { + return os.Open(badArchivePath) + } + archive, err := downloadTestPkg(pkg) + firstArchive = archive + return archive, err + }, + }) + suite.runCmdErr(model) + + 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) + _, err = firstArchive.Stat() + suite.ErrorIs(err, os.ErrClosed) +} + +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) + _, err = downloadedArchive.Stat() + suite.ErrorIs(err, os.ErrClosed) +} + +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 downloadedPaths []string + var downloadedDrivers []string + model := SyncCmd{Path: path, 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) + downloadedPaths = append(downloadedPaths, copyPath) + downloadedDrivers = append(downloadedDrivers, pkg.Driver.Path) + return os.Open(copyPath) + }, + }).(syncModel) + model.writeCandidateLock = func(path string, lock LockFile) error { + if err := writeLockFileAtomic(path, lock); err != nil { + return err + } + if len(downloadedPaths) != 2 { + return fmt.Errorf("expected two prepared archives, got %d", len(downloadedPaths)) + } + // Corrupt the final item only after the complete candidate is durable, + // forcing execution to fail after one install while preserving the lock. + return os.WriteFile(downloadedPaths[len(downloadedPaths)-1], []byte("broken after prepare"), 0600) + } + 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.Config{Level: config.ConfigEnv, Location: suite.Dir()}, name); err == nil { + installedCount++ + } + } + suite.Equal(1, installedCount) + suite.Require().Len(downloadedDrivers, 2) + 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, 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.Require().NotNil(observedPackage.ArtifactSize) + suite.Equal(*lockedArtifact.Size, *observedPackage.ArtifactSize) + for _, name := range []string{"test-driver-1", "test-driver-no-sig"} { + _, err := config.GetDriver(config.Config{Level: config.ConfigEnv, Location: suite.Dir()}, name) + suite.NoError(err) + } + convergedLock, err := os.ReadFile(lockPath) + suite.Require().NoError(err) + suite.Equal(lockedCandidate, convergedLock) +} + +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.ValidatePackage("test-driver-1", archive, expected, config.InstallOptions{}) + suite.Require().NoError(err) + 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 (suite *SubcommandTestSuite) TestSyncPartialRegistryDownloadsEachArchiveOnceAndRejectsV2WithoutMetadata() { + 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, 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) + + // The registry fixture omits archive metadata. A v2 archive must therefore + // be rejected after its one download rather than treating measured values as + // source-provided metadata. + v2Path := filepath.Join(suite.tempdir, "v2-without-registry-metadata.tar.gz") + suite.writeV2MetadataArchive(v2Path) + v2List := filepath.Join(suite.tempdir, "v2.toml") + suite.Require().NoError(os.WriteFile(v2List, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0644)) + v2Downloads := 0 + v2Model := SyncCmd{Path: v2List, NoVerify: true}.GetModelCustom(baseModel{ + getDriverRegistry: getTestDriverRegistry, + downloadPkg: func(dbc.PkgInfo) (*os.File, error) { + v2Downloads++ + return os.Open(v2Path) + }, + }) + suite.Contains(suite.runCmdErr(v2Model), "registry package v2 requires archive hash and size metadata") + suite.Equal(1, v2Downloads) +} + +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 (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() +} + +func (suite *SubcommandTestSuite) writeV2MetadataArchive(path string) { + suite.T().Helper() + metadata := []byte(fmt.Sprintf(`package_version = 2 +id = "test-driver-1" +name = "Test Driver 1" +version = "1.1.0" +platform = %q + +[Driver] +entrypoint = "AdbcDriverTestInit" + +[Files] +driver = "driver.so" +`, config.PlatformTuple())) + file, err := os.Create(path) + suite.Require().NoError(err) + gzipWriter := gzip.NewWriter(file) + tarWriter := tar.NewWriter(gzipWriter) + suite.Require().NoError(tarWriter.WriteHeader(&tar.Header{Name: "dbc-package.toml", Mode: 0600, Size: int64(len(metadata)), Typeflag: tar.TypeReg})) + _, err = tarWriter.Write(metadata) + suite.Require().NoError(err) + suite.Require().NoError(tarWriter.Close()) + suite.Require().NoError(gzipWriter.Close()) + suite.Require().NoError(file.Close()) +} From e70db9869bde85f32eb6e91eb294e670c9767535 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 22:44:03 +0000 Subject: [PATCH 041/115] fix: preserve structured sync checksum errors --- cmd/dbc/sync.go | 28 ++++++++++++++----------- cmd/dbc/sync_test.go | 50 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index e291f948..a911c95b 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -622,6 +622,20 @@ func closePreparedArchives(items []installItem) { } } +func (s syncModel) checksumFailure(err error) (syncModel, tea.Cmd) { + closePreparedArchives(s.installItems) + s.status = 1 + s.err = err + if s.jsonOutput { + s.emitJSON("error", jsonschema.ErrorResponse{ + Code: "checksum_failed", + Message: err.Error(), + }) + return s, tea.Quit + } + return s, tea.Sequence(tea.Println("Error: ", err), tea.Quit) +} + func lockEntryForItem(item installItem) (lockInfo, error) { if item.LockEntry != nil && item.LockEntry.Legacy != nil && samePlatformTarget(item.LockEntry.Legacy.Platform, config.PlatformTuple()) { if err := verifyLegacyLibraryProof(*item.LockEntry, config.PlatformTuple(), item.InstalledLibraryHash); err != nil { @@ -828,20 +842,10 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case installedDrvMsg: chksum, err := checksum(msg.info.Driver.Shared.Get(config.PlatformTuple())) if err != nil { - closePreparedArchives(s.installItems) - 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) + return s.checksumFailure(err) } if msg.item.InstalledLibraryHash != "" && msg.item.InstalledLibraryHash != chksum { - closePreparedArchives(s.installItems) - s.status = 1 - return s, tea.Sequence(tea.Println("Error: installed library checksum does not match validated package"), tea.Quit) + return s.checksumFailure(errors.New("installed library checksum does not match validated package")) } s.newlyInstalled = append(s.newlyInstalled, jsonschema.SyncedDriver{ Name: msg.info.ID, diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index d48910dc..23e0b07d 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -16,6 +16,7 @@ package main import ( "archive/tar" + "bytes" "compress/gzip" "encoding/json" "fmt" @@ -23,6 +24,7 @@ import ( "os" "path/filepath" "strings" + "testing" "github.com/Masterminds/semver/v3" "github.com/columnar-tech/dbc" @@ -690,3 +692,51 @@ driver = "driver.so" suite.Require().NoError(gzipWriter.Close()) suite.Require().NoError(file.Close()) } + +func TestSyncJSONPostInstallChecksumMismatchReportsStructuredError(t *testing.T) { + libraryPath := filepath.Join(t.TempDir(), "driver.so") + if err := os.WriteFile(libraryPath, []byte("installed library"), 0600); err != nil { + t.Fatal(err) + } + info := config.DriverInfo{ID: "example", Version: semver.MustParse("1.0.0")} + info.Driver.Shared.Set(config.PlatformTuple(), libraryPath) + const message = "installed library checksum does not match validated package" + var output bytes.Buffer + model := syncModel{ + jsonOutput: true, + jsonOut: &output, + installItems: []installItem{{ + Driver: dbc.Driver{Path: "example"}, + InstalledLibraryHash: strings.Repeat("0", 64), + }}, + } + updated, _ := model.Update(installedDrvMsg{ + info: info, + item: model.installItems[0], + }) + + status := updated.(HasStatus) + if status.Status() != 1 { + t.Fatalf("status = %d, want 1", status.Status()) + } + if status.Err() == nil || status.Err().Error() != message { + t.Fatalf("Err() = %v, want %q", status.Err(), message) + } + var envelope jsonschema.Envelope + if err := json.Unmarshal(output.Bytes(), &envelope); err != nil { + t.Fatalf("output is not a valid JSON envelope: %v; output %q", err, output.String()) + } + if envelope.Kind != "error" { + t.Fatalf("envelope kind = %q, want error", envelope.Kind) + } + var response jsonschema.ErrorResponse + if err := json.Unmarshal(envelope.Payload, &response); err != nil { + t.Fatalf("could not decode error payload: %v", err) + } + if response.Code != "checksum_failed" { + t.Fatalf("error code = %q, want checksum_failed", response.Code) + } + if response.Message != message { + t.Fatalf("error message = %q, want %q", response.Message, message) + } +} From c8d199437918c3e0358540f0668242d827c75eed Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 22:48:18 +0000 Subject: [PATCH 042/115] fix: avoid duplicate sync checksum errors --- cmd/dbc/sync.go | 2 +- cmd/dbc/sync_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index a911c95b..0af62bb1 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -633,7 +633,7 @@ func (s syncModel) checksumFailure(err error) (syncModel, tea.Cmd) { }) return s, tea.Quit } - return s, tea.Sequence(tea.Println("Error: ", err), tea.Quit) + return s, tea.Quit } func lockEntryForItem(item installItem) (lockInfo, error) { diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index 23e0b07d..e24d2179 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -26,6 +26,7 @@ import ( "strings" "testing" + tea "charm.land/bubbletea/v2" "github.com/Masterminds/semver/v3" "github.com/columnar-tech/dbc" "github.com/columnar-tech/dbc/config" @@ -740,3 +741,38 @@ func TestSyncJSONPostInstallChecksumMismatchReportsStructuredError(t *testing.T) t.Fatalf("error message = %q, want %q", response.Message, message) } } + +type syncInjectedMessageModel struct { + model syncModel + message tea.Msg +} + +func (m syncInjectedMessageModel) Init() tea.Cmd { + return func() tea.Msg { return m.message } +} + +func (m syncInjectedMessageModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m.model.Update(msg) +} + +func (m syncInjectedMessageModel) View() tea.View { + return m.model.View() +} + +func (suite *SubcommandTestSuite) TestSyncPlainPostInstallChecksumMismatchUsesSingleStandardError() { + libraryPath := filepath.Join(suite.tempdir, "driver.so") + suite.Require().NoError(os.WriteFile(libraryPath, []byte("installed library"), 0600)) + info := config.DriverInfo{ID: "example", Version: semver.MustParse("1.0.0")} + info.Driver.Shared.Set(config.PlatformTuple(), libraryPath) + const message = "installed library checksum does not match validated package" + model := syncInjectedMessageModel{ + model: syncModel{installItems: []installItem{{ + Driver: dbc.Driver{Path: "example"}, + InstalledLibraryHash: strings.Repeat("0", 64), + }}}, + message: installedDrvMsg{info: info, item: installItem{InstalledLibraryHash: strings.Repeat("0", 64)}}, + } + output := suite.runCmdErr(model) + suite.Equal("\nError: "+message, output) + suite.Equal(1, strings.Count(output, message)) +} From e209d33daeb2afae8b2fb5187b7293ae98774b52 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 22:56:17 +0000 Subject: [PATCH 043/115] refactor: centralize sync terminal errors --- cmd/dbc/sync.go | 18 ++--- cmd/dbc/sync_test.go | 154 ++++++++++++++++++++++++------------------- 2 files changed, 93 insertions(+), 79 deletions(-) diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 0af62bb1..0ee29c6c 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -622,13 +622,13 @@ func closePreparedArchives(items []installItem) { } } -func (s syncModel) checksumFailure(err error) (syncModel, tea.Cmd) { +func (s syncModel) fail(code string, err error) (syncModel, tea.Cmd) { closePreparedArchives(s.installItems) s.status = 1 s.err = err if s.jsonOutput { s.emitJSON("error", jsonschema.ErrorResponse{ - Code: "checksum_failed", + Code: code, Message: err.Error(), }) return s, tea.Quit @@ -842,10 +842,10 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case installedDrvMsg: chksum, err := checksum(msg.info.Driver.Shared.Get(config.PlatformTuple())) if err != nil { - return s.checksumFailure(err) + return s.fail("checksum_failed", err) } if msg.item.InstalledLibraryHash != "" && msg.item.InstalledLibraryHash != chksum { - return s.checksumFailure(errors.New("installed library checksum does not match validated package")) + return s.fail("checksum_failed", errors.New("installed library checksum does not match validated package")) } s.newlyInstalled = append(s.newlyInstalled, jsonschema.SyncedDriver{ Name: msg.info.ID, @@ -904,15 +904,7 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.dispatchInstallItem(), ) case error: - closePreparedArchives(s.installItems) - 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) - } + return s.fail("sync_failed", msg) } bm, cmd := s.baseModel.Update(msg) diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index e24d2179..4c55ff96 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -16,15 +16,14 @@ package main import ( "archive/tar" - "bytes" "compress/gzip" "encoding/json" + "errors" "fmt" "io" "os" "path/filepath" "strings" - "testing" tea "charm.land/bubbletea/v2" "github.com/Masterminds/semver/v3" @@ -694,57 +693,10 @@ driver = "driver.so" suite.Require().NoError(file.Close()) } -func TestSyncJSONPostInstallChecksumMismatchReportsStructuredError(t *testing.T) { - libraryPath := filepath.Join(t.TempDir(), "driver.so") - if err := os.WriteFile(libraryPath, []byte("installed library"), 0600); err != nil { - t.Fatal(err) - } - info := config.DriverInfo{ID: "example", Version: semver.MustParse("1.0.0")} - info.Driver.Shared.Set(config.PlatformTuple(), libraryPath) - const message = "installed library checksum does not match validated package" - var output bytes.Buffer - model := syncModel{ - jsonOutput: true, - jsonOut: &output, - installItems: []installItem{{ - Driver: dbc.Driver{Path: "example"}, - InstalledLibraryHash: strings.Repeat("0", 64), - }}, - } - updated, _ := model.Update(installedDrvMsg{ - info: info, - item: model.installItems[0], - }) - - status := updated.(HasStatus) - if status.Status() != 1 { - t.Fatalf("status = %d, want 1", status.Status()) - } - if status.Err() == nil || status.Err().Error() != message { - t.Fatalf("Err() = %v, want %q", status.Err(), message) - } - var envelope jsonschema.Envelope - if err := json.Unmarshal(output.Bytes(), &envelope); err != nil { - t.Fatalf("output is not a valid JSON envelope: %v; output %q", err, output.String()) - } - if envelope.Kind != "error" { - t.Fatalf("envelope kind = %q, want error", envelope.Kind) - } - var response jsonschema.ErrorResponse - if err := json.Unmarshal(envelope.Payload, &response); err != nil { - t.Fatalf("could not decode error payload: %v", err) - } - if response.Code != "checksum_failed" { - t.Fatalf("error code = %q, want checksum_failed", response.Code) - } - if response.Message != message { - t.Fatalf("error message = %q, want %q", response.Message, message) - } -} - type syncInjectedMessageModel struct { model syncModel message tea.Msg + result *syncModel } func (m syncInjectedMessageModel) Init() tea.Cmd { @@ -752,27 +704,97 @@ func (m syncInjectedMessageModel) Init() tea.Cmd { } func (m syncInjectedMessageModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - return m.model.Update(msg) + 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 (suite *SubcommandTestSuite) TestSyncPlainPostInstallChecksumMismatchUsesSingleStandardError() { - libraryPath := filepath.Join(suite.tempdir, "driver.so") - suite.Require().NoError(os.WriteFile(libraryPath, []byte("installed library"), 0600)) - info := config.DriverInfo{ID: "example", Version: semver.MustParse("1.0.0")} - info.Driver.Shared.Set(config.PlatformTuple(), libraryPath) - const message = "installed library checksum does not match validated package" - model := syncInjectedMessageModel{ - model: syncModel{installItems: []installItem{{ - Driver: dbc.Driver{Path: "example"}, - InstalledLibraryHash: strings.Repeat("0", 64), - }}}, - message: installedDrvMsg{info: info, item: installItem{InstalledLibraryHash: strings.Repeat("0", 64)}}, +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 _, failure := range []string{"generic", "checksum-read", "checksum-mismatch"} { + for _, mode := range []string{"plain", "json", "json-stream"} { + suite.Run(failure+"/"+mode, func() { + var ( + message tea.Msg + wantErr error + code string + ) + switch failure { + case "generic": + wantErr = errors.New("injected sync failure") + message = wantErr + code = "sync_failed" + case "checksum-read": + missingPath := filepath.Join(suite.T().TempDir(), "missing-driver.so") + _, wantErr = checksum(missingPath) + info := config.DriverInfo{ID: "example", Version: semver.MustParse("1.0.0")} + info.Driver.Shared.Set(config.PlatformTuple(), missingPath) + message = installedDrvMsg{info: info} + code = "checksum_failed" + case "checksum-mismatch": + libraryPath := filepath.Join(suite.T().TempDir(), "driver.so") + suite.Require().NoError(os.WriteFile(libraryPath, []byte("installed library"), 0600)) + info := config.DriverInfo{ID: "example", Version: semver.MustParse("1.0.0")} + info.Driver.Shared.Set(config.PlatformTuple(), libraryPath) + item := installItem{InstalledLibraryHash: strings.Repeat("0", 64)} + message = installedDrvMsg{info: info, item: item} + wantErr = errors.New("installed library checksum does not match validated package") + code = "checksum_failed" + } + + 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) + }) + } } - output := suite.runCmdErr(model) - suite.Equal("\nError: "+message, output) - suite.Equal(1, strings.Count(output, message)) } From 91525f5ce74fdea7354e205605698acb5eb6359b Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 23:07:58 +0000 Subject: [PATCH 044/115] fix: defer packslip ambiguity to target selection --- internal/packslip/model.go | 46 +++-------------------- internal/packslip/model_test.go | 38 +++++++++++++++---- internal/packslip/resolver_native_test.go | 23 +++++++++++- 3 files changed, 57 insertions(+), 50 deletions(-) diff --git a/internal/packslip/model.go b/internal/packslip/model.go index 703f2af2..057bec2a 100644 --- a/internal/packslip/model.go +++ b/internal/packslip/model.go @@ -870,24 +870,14 @@ func supportedArchiveFormat(format string) bool { return format == "tar.gz" || format == "tgz" } -// validateSupportedArtifactSet checks ambiguity across the entire signed -// release before concrete targets are finalized. +// validateSupportedArtifactSet ensures the release inventory contains at +// least one archive format supported by dbc. Selection ambiguity is checked +// only after concrete targets are derived. func validateSupportedArtifactSet(release *parsedRelease) error { count := 0 for i := range release.predicate.Artifacts { - left := &release.predicate.Artifacts[i] - if !supportedArchiveFormat(stringValue(left.Format)) { - continue - } - count++ - for j := i + 1; j < len(release.predicate.Artifacts); j++ { - right := &release.predicate.Artifacts[j] - if !supportedArchiveFormat(stringValue(right.Format)) || stringValue(left.Format) != stringValue(right.Format) { - continue - } - if artifactSpecificity(left) == artifactSpecificity(right) && selectorVariantsOverlap(left, right) && selectorsOverlap(left, right) { - return fmt.Errorf("%w: %q and %q", ErrAmbiguousArtifact, left.Name, right.Name) - } + if supportedArchiveFormat(stringValue(release.predicate.Artifacts[i].Format)) { + count++ } } if count == 0 { @@ -896,32 +886,6 @@ func validateSupportedArtifactSet(release *parsedRelease) error { return nil } -func artifactSpecificity(artifact *releaseArtifact) int { - specificity := 0 - for _, selector := range []*string{artifact.OS, artifact.Arch, artifact.LibC} { - if selector != nil { - specificity++ - } - } - return specificity -} - -func selectorVariantsOverlap(left, right *releaseArtifact) bool { - if left.Variant == nil || right.Variant == nil { - return left.Variant == nil && right.Variant == nil - } - return *left.Variant == *right.Variant -} - -func selectorsOverlap(left, right *releaseArtifact) bool { - for _, pair := range [][2]*string{{left.OS, right.OS}, {left.Arch, right.Arch}, {left.LibC, right.LibC}} { - if pair[0] != nil && pair[1] != nil && *pair[0] != *pair[1] { - return false - } - } - return true -} - func convertSelectedArtifact(release *parsedRelease, artifact *releaseArtifact, assets []githubAsset, target Target) (resolution.Artifact, error) { artifactURL := stringValue(artifact.URL) if artifactURL == "" { diff --git a/internal/packslip/model_test.go b/internal/packslip/model_test.go index 6182aca0..0b4c25a1 100644 --- a/internal/packslip/model_test.go +++ b/internal/packslip/model_test.go @@ -200,10 +200,10 @@ func TestParseReleaseRejectsExactSelectorDuplicatesButDefersOverlappingScopeTies 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 := parseRelease(validRelease("1.0.0", - artifact("first.tar.gz", "linux", "amd64"), + artifact("first.tar.gz", "linux", "x86_64"), artifact("second.tar.gz", "linux", "amd64"), ), testProject) - require.ErrorContains(t, err, "duplicate selectors") + require.ErrorContains(t, err, "duplicate selectors", "selector aliases are canonicalized before structural duplicate detection") release, err := parseRelease(validRelease("1.0.0", artifact("linux.tar.gz", "linux", ""), @@ -212,6 +212,17 @@ func TestParseReleaseRejectsExactSelectorDuplicatesButDefersOverlappingScopeTies 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 := parseRelease(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 { @@ -265,17 +276,30 @@ func TestWireDecoderIgnoresUnknownOptionalFieldsButRejectsMalformedJSON(t *testi require.ErrorContains(t, err, "duplicate JSON field") } -func TestSupportedArtifactAmbiguityIsRejectedForOtherPlatforms(t *testing.T) { +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 := parseRelease(validRelease("1.0.0", - artifact("windows.tar.gz", "windows", ""), - artifact("arm64.tar.gz", "", "aarch64"), - artifact("linux-x64.tar.gz", "linux", "x86_64"), + 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.ErrorIs(t, validateSupportedArtifactSet(release), ErrAmbiguousArtifact) + 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 := parseRelease(validRelease("1.0.0", unsupported), testProject) + require.NoError(t, err) + require.ErrorContains(t, validateSupportedArtifactSet(release), "no supported tar.gz or tgz artifacts") } func TestParseReleaseHasNoUnsignedFallback(t *testing.T) { diff --git a/internal/packslip/resolver_native_test.go b/internal/packslip/resolver_native_test.go index f1665933..7dc9bdc7 100644 --- a/internal/packslip/resolver_native_test.go +++ b/internal/packslip/resolver_native_test.go @@ -267,6 +267,25 @@ func TestResolverSelectsOneCanonicalWinnerPerTarget(t *testing.T) { 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)} @@ -524,8 +543,8 @@ func TestResolverRejectsArtifactSelectionAmbiguityAndMalformedHashOrSize(t *test 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("os.tar.gz", "linux", "", "tar.gz", ptr(uint64(1))), - artifact("arch.tar.gz", "", "x86_64", "tar.gz", ptr(uint64(1))), + 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") From 1a37d4d2a9c59daaf98f7338a7151337df5b2aea Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 23:42:30 +0000 Subject: [PATCH 045/115] feat: hold project lock through sync lifecycle --- cmd/dbc/main.go | 25 +- cmd/dbc/subcommand_test.go | 8 +- cmd/dbc/sync.go | 645 ++++++++++++++++++++++++++----------- cmd/dbc/sync_test.go | 373 +++++++++++++++++++++ 4 files changed, 851 insertions(+), 200 deletions(-) diff --git a/cmd/dbc/main.go b/cmd/dbc/main.go index d9409162..e0b1973f 100644 --- a/cmd/dbc/main.go +++ b/cmd/dbc/main.go @@ -76,6 +76,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 +180,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 @@ -467,12 +479,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 +496,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/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 0ee29c6c..97ad2ef1 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -15,6 +15,7 @@ package main import ( + "context" "errors" "fmt" "io" @@ -23,6 +24,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" "charm.land/bubbles/v2/progress" @@ -31,6 +33,7 @@ import ( "charm.land/lipgloss/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" "github.com/columnar-tech/dbc/internal/resolution" ) @@ -51,6 +54,7 @@ func (c SyncCmd) GetModelCustom(baseModel baseModel) tea.Model { NoVerify: c.NoVerify, jsonOutput: c.Json || c.JsonStreamProgress, jsonStreamProgress: c.JsonStreamProgress, + worker: newSyncWorker(), } } @@ -62,6 +66,7 @@ func (c SyncCmd) GetModel() tea.Model { jsonOutput: c.Json || c.JsonStreamProgress, jsonStreamProgress: c.JsonStreamProgress, baseModel: defaultBaseModel(), + worker: newSyncWorker(), } } @@ -69,6 +74,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 @@ -137,9 +174,50 @@ type syncModel struct { // newlyInstalled tracks freshly installed drivers for JSON output newlyInstalled []jsonschema.SyncedDriver - jsonOut io.Writer + jsonOut io.Writer + worker *syncWorker + terminal bool +} + +type syncWorkerHooks struct { + duringPrepare func(context.Context, int, installItem) error + beforeCandidateSave func(context.Context) error + installPackage func(context.Context, config.Config, string, *os.File, config.ExpectedPackageMetadata, config.InstallOptions) (config.Manifest, 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 +} + +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 syncResolvingMsg struct{ driver string } + +type syncItemsMsg struct{ items []installItem } + +// driversListMsg is retained for focused lock-resolution tests. The production +// sync path reads the driver list inside the project-lock-owning worker. type driversListMsg struct { path string list DriversList @@ -147,30 +225,86 @@ type driversListMsg struct { func (s syncModel) Init() tea.Cmd { return func() tea.Msg { - p, err := filepath.Abs(s.Path) - if err != nil { - return err + if s.worker == nil { + s.worker = newSyncWorker() } + return s.worker.start(s) + } +} - if filepath.Ext(p) == "" { - p = filepath.Join(p, "dbc.toml") - } +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() +} - lockPath := filepath.Join(filepath.Dir(p), ".dbc.project.lock") - lock, err := acquireLock(lockPath, 10*time.Second) - if err != nil { - return err - } - defer lock.Release() +func (w *syncWorker) hasStarted() bool { + w.stateMu.Lock() + defer w.stateMu.Unlock() + return w.started +} - drivers, err := loadDriverList(p) - if err != nil { - return err - } - return driversListMsg{ - path: p, - list: drivers, - } +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 } } @@ -185,6 +319,18 @@ func loadDriverList(path string) (DriversList, error) { 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 +} + type installItem struct { Driver dbc.Driver Package dbc.PkgInfo @@ -375,37 +521,56 @@ type alreadyInstalledDrvMsg struct { item installItem } -func (s syncModel) installDriver(cfg config.Config, item installItem) tea.Cmd { - return func() tea.Msg { - if item.Archive == nil { - return errors.New("prepared package archive is missing") - } - defer item.Archive.Close() - if _, err := item.Archive.Seek(0, io.SeekStart); err != nil { - return fmt.Errorf("failed to rewind prepared driver archive: %w", err) - } - var verify func(string, config.Manifest) error - if !s.NoVerify { - verify = func(stagingDir string, manifest config.Manifest) error { - return dbc.VerifyPackageSignature(stagingDir, manifest) - } +func (s syncModel) installPreparedPackage(ctx context.Context, item installItem) (installedDrvMsg, error) { + if err := ctx.Err(); err != nil { + return installedDrvMsg{}, err + } + if item.Archive == nil { + return installedDrvMsg{}, errors.New("prepared package archive is missing") + } + if _, err := item.Archive.Seek(0, io.SeekStart); err != nil { + return installedDrvMsg{}, fmt.Errorf("failed to rewind prepared driver archive: %w", err) + } + var verify func(string, config.Manifest) error + if !s.NoVerify { + verify = func(stagingDir string, manifest config.Manifest) error { + return dbc.VerifyPackageSignature(stagingDir, manifest) } - manifest, err := config.InstallPackage(cfg, item.Driver.Path, item.Archive, item.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 install driver: %w", err) + } + install := s.worker.hooks.installPackage + if install == nil { + // config.InstallPackage is contextless. Keep this call synchronous so + // cancellation cannot release the project lock or archive prematurely. + install = func(_ context.Context, cfg config.Config, driver string, archive *os.File, expected config.ExpectedPackageMetadata, options config.InstallOptions) (config.Manifest, error) { + return config.InstallPackage(cfg, driver, archive, expected, options) } - return installedDrvMsg{ - removed: item.RemovedDriver, - info: manifest.DriverInfo, - postInstall: manifest.PostInstall.Messages, - item: item, + } + manifest, err := install(ctx, s.cfg, item.Driver.Path, item.Archive, item.Expected, config.InstallOptions{Verify: verify}) + if err := ctx.Err(); err != nil { + return installedDrvMsg{}, err + } + if err != nil { + if isPackageVerificationFailure(err) { + return installedDrvMsg{}, fmt.Errorf("failed to verify signature: %w", packageVerificationError(err)) } + return installedDrvMsg{}, fmt.Errorf("failed to install driver: %w", err) } + if err := ctx.Err(); err != nil { + return installedDrvMsg{}, err + } + checksumHash, err := checksum(manifest.DriverInfo.Driver.Shared.Get(config.PlatformTuple())) + if err != nil { + return installedDrvMsg{}, syncChecksumError{err: err} + } + if item.InstalledLibraryHash != "" && item.InstalledLibraryHash != checksumHash { + return installedDrvMsg{}, syncChecksumError{err: errors.New("installed library checksum does not match validated package")} + } + return installedDrvMsg{ + removed: item.RemovedDriver, + info: manifest.DriverInfo, + postInstall: manifest.PostInstall.Messages, + item: item, + }, nil } func (s syncModel) writeLockFile() error { @@ -480,29 +645,22 @@ func snapshotDownloadedArchive(item *installItem, archive *os.File) error { return nil } -func (s syncModel) prepareInstallItems(items []installItem) (preparedSyncMsg, error) { +func (s syncModel) prepareInstallItems(ctx context.Context, items []installItem) (preparedSyncMsg, error) { prepared := preparedSyncMsg{items: items, lock: LockFile{Version: lockFileVersion}} - closePrepared := func() { - for i := range prepared.items { - if prepared.items[i].Archive != nil { - _ = prepared.items[i].Archive.Close() - prepared.items[i].Archive = nil - } - } - } for i := range prepared.items { + if err := ctx.Err(); err != nil { + return prepared, err + } item := &prepared.items[i] if s.cfg.Exists { if installed, ok := s.cfg.Drivers[item.Driver.Path]; ok { if item.Package.Version.Equal(installed.Version) { libraryHash, err := checksum(installed.Driver.Shared.Get(config.PlatformTuple())) if err != nil { - closePrepared() - return preparedSyncMsg{}, fmt.Errorf("failed to compute checksum: %w", err) + return prepared, fmt.Errorf("failed to compute checksum: %w", err) } if item.Checksum != "" && libraryHash != item.Checksum { - closePrepared() - return preparedSyncMsg{}, fmt.Errorf("checksum mismatch for driver %s: %s != %s", item.Driver.Path, libraryHash, item.Checksum) + return prepared, fmt.Errorf("checksum mismatch for driver %s: %s != %s", item.Driver.Path, libraryHash, item.Checksum) } if item.Checksum == "" { item.Checksum = libraryHash @@ -523,32 +681,33 @@ func (s syncModel) prepareInstallItems(items []installItem) (preparedSyncMsg, er // measured values are copied into Package below. expected, hasMetadata, err := expectedRegistryPackageMetadata(item.Package) if err != nil { - closePrepared() - return preparedSyncMsg{}, err + return prepared, err } + // downloadPkg is contextless; wait for it to return while retaining + // the project lock, then observe cancellation and clean up its archive. archive, err := s.downloadPkg(item.Package) + if archive != nil { + item.Archive = archive + } + if ctx.Err() != nil { + return prepared, ctx.Err() + } if err != nil { - closePrepared() - return preparedSyncMsg{}, fmt.Errorf("failed to download driver: %w", err) + return prepared, fmt.Errorf("failed to download driver: %w", err) } - item.Archive = archive if err := snapshotDownloadedArchive(item, archive); err != nil { - closePrepared() - return preparedSyncMsg{}, fmt.Errorf("failed to snapshot downloaded driver archive: %w", err) + return prepared, fmt.Errorf("failed to snapshot downloaded driver archive: %w", err) } if !hasMetadata { if _, err := archive.Seek(0, io.SeekStart); err != nil { - closePrepared() - return preparedSyncMsg{}, fmt.Errorf("failed to rewind downloaded driver archive: %w", err) + return prepared, fmt.Errorf("failed to rewind downloaded driver archive: %w", err) } packageManifest, inspectErr := config.InspectPackageMetadata(archive) if inspectErr != nil { - closePrepared() - return preparedSyncMsg{}, inspectErr + return prepared, inspectErr } if packageManifest.PackageVersion == 2 { - closePrepared() - return preparedSyncMsg{}, errors.New("registry package v2 requires archive hash and size metadata") + return prepared, errors.New("registry package v2 requires archive hash and size metadata") } } item.Package.ArtifactHash = item.ArchiveHash @@ -557,8 +716,7 @@ func (s syncModel) prepareInstallItems(items []installItem) (preparedSyncMsg, er expected.ArchiveSize = item.ArchiveSize item.Expected = expected if _, err := archive.Seek(0, io.SeekStart); err != nil { - closePrepared() - return preparedSyncMsg{}, fmt.Errorf("failed to rewind downloaded driver archive: %w", err) + return prepared, fmt.Errorf("failed to rewind downloaded driver archive: %w", err) } var verify func(string, config.Manifest) error if !s.NoVerify { @@ -568,49 +726,193 @@ func (s syncModel) prepareInstallItems(items []installItem) (preparedSyncMsg, er } validation, err := config.ValidatePackage(item.Driver.Path, archive, expected, config.InstallOptions{Verify: verify}) if err != nil { - closePrepared() if isPackageVerificationFailure(err) { - return preparedSyncMsg{}, fmt.Errorf("failed to verify signature: %w", packageVerificationError(err)) + return prepared, fmt.Errorf("failed to verify signature: %w", packageVerificationError(err)) } - return preparedSyncMsg{}, fmt.Errorf("failed to validate driver package: %w", err) + return prepared, fmt.Errorf("failed to validate driver package: %w", err) } if item.AlreadyInstalled == nil { item.InstalledLibraryHash = strings.TrimPrefix(validation.VerifiedLibraryHash, "sha256:") } - if item.AlreadyInstalled != nil { - _ = archive.Close() - item.Archive = nil - } } else { // The exact locked artifact is already installed, so it can be reused // without another download or package validation. expected, _, err := expectedRegistryPackageMetadata(item.Package) if err != nil { - closePrepared() - return preparedSyncMsg{}, err + return prepared, err } item.Expected = expected } entry, err := lockEntryForItem(*item) if err != nil { - closePrepared() - return preparedSyncMsg{}, fmt.Errorf("failed to update lock entry: %w", err) + 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 } -func (s syncModel) dispatchInstallItem() tea.Cmd { - if s.index >= len(s.installItems) { - return tea.Quit +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(err, fslock.ErrLockContended) { + return fslock.Lock{}, fmt.Errorf("another dbc operation is in progress: %w", 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()) + } + return fslock.Lock{}, fmt.Errorf("could not acquire lock in %s: %w", filepath.Dir(lockPath), err) +} + +func (s syncModel) runSyncWorker(worker *syncWorker) syncWorkerResultMsg { + result := syncWorkerResultMsg{code: "sync_failed"} + fail := func(code string, err error) syncWorkerResultMsg { + result.code = code + result.err = err + return result } - item := s.installItems[s.index] - if item.AlreadyInstalled != nil { - return func() tea.Msg { return alreadyInstalledDrvMsg{info: *item.AlreadyInstalled, item: item} } + 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) + } + + needsRegistry, err := s.registryDiscoveryNeeded(s.list) + if err != nil { + return fail("sync_failed", fmt.Errorf("failed to inspect lock file: %w", err)) + } + 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.createInstallList(s.list) + 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.Driver.Path}) { + return fail("sync_failed", ctx.Err()) + } + } + + prepared, err := s.prepareInstallItems(ctx, items) + defer closePreparedArchives(prepared.items) + 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)) + } + result.lock = prepared.lock + + for _, item := range prepared.items { + if err := ctx.Err(); err != nil { + return fail("sync_failed", err) + } + if item.AlreadyInstalled != nil { + progress := alreadyInstalledDrvMsg{info: *item.AlreadyInstalled, item: item} + progress.item.Archive = nil + if !worker.send(ctx, progress) { + return fail("sync_failed", ctx.Err()) + } + continue + } + installed, err := s.installPreparedPackage(ctx, item) + if err != nil { + var checksumErr syncChecksumError + if errors.As(err, &checksumErr) { + return fail("checksum_failed", checksumErr) + } + return fail("sync_failed", err) + } + installed.item.Archive = nil + if !worker.send(ctx, installed) { + return fail("sync_failed", ctx.Err()) + } } - return s.installDriver(s.cfg, item) + if err := ctx.Err(); err != nil { + return fail("sync_failed", err) + } + result.err = nil + result.code = "" + return result } func closePreparedArchives(items []installItem) { @@ -622,10 +924,18 @@ func closePreparedArchives(items []installItem) { } } +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) { - closePreparedArchives(s.installItems) s.status = 1 s.err = err + s.terminal = true if s.jsonOutput { s.emitJSON("error", jsonschema.ErrorResponse{ Code: code, @@ -715,6 +1025,8 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.progress, cmd = s.progress.Update(msg) return s, cmd case driversListMsg: + // This adapter exists for focused lock-resolution tests. Runtime syncs + // keep this entire sequence in runSyncWorker under the project lock. s.Path = msg.path s.LockFilePath = strings.TrimSuffix(s.Path, filepath.Ext(s.Path)) + ".lock" s.list = msg.list @@ -727,83 +1039,34 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if !needsRegistry { return s, func() tea.Msg { - items, err := s.createInstallList(s.list) + returnItems, err := s.createInstallList(s.list) if err != nil { return err } - return items + return returnItems } } 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 + return driversWithRegistryError{drivers: drivers, err: err} } - case []installItem: + case syncItemsMsg: 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(func() tea.Msg { - prepared, err := s.prepareInstallItems(msg) - if err != nil { - return err - } - return prepared - }, s.spinner.Tick) - case preparedSyncMsg: s.installItems = msg.items - return s, func() tea.Msg { - if err := s.persistCandidateLock(msg.lock); err != nil { - closePreparedArchives(msg.items) - return fmt.Errorf("failed to write candidate lock file: %w", err) - } - return candidateLockSavedMsg{lock: msg.lock} + 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, + }) } - case candidateLockSavedMsg: - s.locked = msg.lock - return s, tea.Batch(s.dispatchInstallItem(), s.spinner.Tick) + return s, s.worker.nextEvent() case alreadyInstalledDrvMsg: s.skippedDrivers = append(s.skippedDrivers, jsonschema.SyncedDriver{ Name: msg.info.ID, @@ -817,35 +1080,30 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Version: msg.info.Version.String(), }) } - - if s.index >= len(s.installItems)-1 { - s.done = true - if s.jsonOutput { - return s, tea.Quit - } - return s, tea.Sequence(tea.Printf("%s %s-%s already installed", checkMark, msg.info.ID, msg.info.Version), tea.Quit) + percent := syncProgressPercent(s.index, len(s.installItems)) + if s.index < len(s.installItems)-1 { + s.index++ } - - s.index++ - progressCmd := s.progress.SetPercent(float64(s.index) / float64(len(s.installItems))) + progressCmd := s.progress.SetPercent(percent) if s.jsonOutput { - return s, tea.Batch( - progressCmd, - s.dispatchInstallItem(), - ) + return s, tea.Batch(progressCmd, s.worker.nextEvent()) } return s, tea.Batch( progressCmd, - tea.Printf("%s %s-%s already installed", checkMark, msg.info.ID, msg.info.Version), - s.dispatchInstallItem(), + tea.Sequence(tea.Printf("%s %s-%s already installed", checkMark, msg.info.ID, msg.info.Version), s.worker.nextEvent()), ) case installedDrvMsg: - chksum, err := checksum(msg.info.Driver.Shared.Get(config.PlatformTuple())) - if err != nil { - return s.fail("checksum_failed", err) - } - if msg.item.InstalledLibraryHash != "" && msg.item.InstalledLibraryHash != chksum { - return s.fail("checksum_failed", errors.New("installed library checksum does not match validated package")) + // Production workers verify the post-install checksum before publishing + // this progress event. Retain the direct-update path for isolated model + // tests and embedders that inject the event without a worker. + if s.worker == nil { + chksum, err := checksum(msg.info.Driver.Shared.Get(config.PlatformTuple())) + if err != nil { + return s.fail("checksum_failed", err) + } + if msg.item.InstalledLibraryHash != "" && msg.item.InstalledLibraryHash != chksum { + return s.fail("checksum_failed", errors.New("installed library checksum does not match validated package")) + } } s.newlyInstalled = append(s.newlyInstalled, jsonschema.SyncedDriver{ Name: msg.info.ID, @@ -880,29 +1138,29 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - if s.index >= len(s.installItems)-1 { - s.done = true - if s.jsonOutput { - return s, tea.Quit - } - return s, tea.Sequence( - printCmd, - tea.Quit) + percent := syncProgressPercent(s.index, len(s.installItems)) + if s.index < len(s.installItems)-1 { + s.index++ } - - s.index++ - progressCmd := s.progress.SetPercent(float64(s.index) / float64(len(s.installItems))) + progressCmd := s.progress.SetPercent(percent) if s.jsonOutput { return s, tea.Batch( progressCmd, - s.dispatchInstallItem(), + s.worker.nextEvent(), ) } return s, tea.Batch( progressCmd, - printCmd, - s.dispatchInstallItem(), + 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) } @@ -934,7 +1192,8 @@ func (s syncModel) View() tea.View { prog := s.progress.View() cellsAvail := max(0, s.width-lipgloss.Width(spin+prog+driverCount)) - driverName := s.installItems[s.index].Driver.Path + driverIndex := min(s.index, len(s.installItems)-1) + driverName := s.installItems[driverIndex].Driver.Path info := lipgloss.NewStyle().MaxWidth(cellsAvail).Render("Installing " + driverName) cellsRemaining := max(0, s.width-lipgloss.Width(spin+info+prog+driverCount)) diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index 4c55ff96..8445b524 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -16,7 +16,9 @@ package main import ( "archive/tar" + "bytes" "compress/gzip" + "context" "encoding/json" "errors" "fmt" @@ -24,14 +26,28 @@ import ( "os" "path/filepath" "strings" + "sync" + "sync/atomic" + "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/fslock" "github.com/columnar-tech/dbc/internal/jsonschema" ) +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 (suite *SubcommandTestSuite) TestSync() { m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() suite.runCmd(m) @@ -654,6 +670,363 @@ func (suite *SubcommandTestSuite) TestSyncExactLockedArtifactConvergesWithoutReg suite.Equal(0, downloadCalls) } +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 archive *os.File + 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) { + archive, err = downloadTestPkg(pkg) + if stage == "download" { + close(entered) + <-release + } + return archive, err + }, + }).(syncModel) + 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 { + archive = item.Archive + return block(ctx) + } + case "candidate-save": + model.worker.hooks.beforeCandidateSave = block + case "install": + model.worker.hooks.installPackage = func(ctx context.Context, _ config.Config, _ string, file *os.File, _ config.ExpectedPackageMetadata, _ config.InstallOptions) (config.Manifest, error) { + archive = file + close(entered) + <-release + return config.Manifest{}, 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 + if archive != nil { + _, statErr = archive.Stat() + suite.NoError(statErr, "prepared archive must stay open until the worker exits") + } else { + suite.Equal("registry", stage, "only registry discovery runs before an archive is opened") + } + 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 archive != nil { + _, statErr = archive.Stat() + suite.ErrorIs(statErr, os.ErrClosed) + } + 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) + _, err = archive.Stat() + suite.ErrorIs(err, os.ErrClosed) + 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.installPackage = func(_ context.Context, cfg config.Config, driver string, archive *os.File, expected config.ExpectedPackageMetadata, options config.InstallOptions) (config.Manifest, error) { + secondInstalls.Add(1) + return config.InstallPackage(cfg, driver, archive, expected, options) + } + 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) From d4af9b806e1dfabc9ededc7d01e73cbd28dcc578 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sun, 20 Sep 2026 23:53:40 +0000 Subject: [PATCH 046/115] fix: preserve sync lock contention errors --- cmd/dbc/sync.go | 7 +++++-- cmd/dbc/sync_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 97ad2ef1..c86d6c7e 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -768,14 +768,17 @@ func acquireSyncProjectLock(ctx context.Context, lockPath string) (fslock.Lock, if err == nil { return lock, nil } - if errors.Is(err, fslock.ErrLockContended) { - return fslock.Lock{}, fmt.Errorf("another dbc operation is in progress: %w", err) + 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) } diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index 8445b524..73714256 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -48,6 +48,35 @@ func TestSyncProgressPercentTracksCompletedItems(t *testing.T) { } } +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) From c0a48154d5fdc1fcadb1744649253045abbacf90 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 00:24:37 +0000 Subject: [PATCH 047/115] fix: verify same-version sync receipts --- cmd/dbc/sync.go | 117 ++++++++- cmd/dbc/sync_test.go | 374 +++++++++++++++++++++++++++++ config/install_transaction_test.go | 104 ++++++++ config/package_archive.go | 123 +++++++++- 4 files changed, 696 insertions(+), 22 deletions(-) diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index c86d6c7e..88d9dd1c 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -338,6 +338,7 @@ type installItem struct { ArchiveHash string ArchiveSize int64 InstalledLibraryHash string + ValidatedLibraryHash string LockEntry *lockInfo Archive *os.File Expected config.ExpectedPackageMetadata @@ -652,22 +653,44 @@ func (s syncModel) prepareInstallItems(ctx context.Context, items []installItem) return prepared, err } item := &prepared.items[i] + var sameVersionInstalled *config.DriverInfo if s.cfg.Exists { if installed, ok := s.cfg.Drivers[item.Driver.Path]; ok { if item.Package.Version.Equal(installed.Version) { - libraryHash, err := checksum(installed.Driver.Shared.Get(config.PlatformTuple())) + installedCopy := installed + sameVersionInstalled = &installedCopy + expected, hasMetadata, err := expectedRegistryPackageMetadata(item.Package) if err != nil { - return prepared, fmt.Errorf("failed to compute checksum: %w", err) + return prepared, err } - if item.Checksum != "" && libraryHash != item.Checksum { - return prepared, fmt.Errorf("checksum mismatch for driver %s: %s != %s", item.Driver.Path, libraryHash, item.Checksum) + receipt, managed, receiptPresent, valid, err := config.InspectDriverInstallReceipt(s.cfg, installed) + if err != nil { + return prepared, fmt.Errorf("failed to resolve installed driver receipt location: %w", err) } - if item.Checksum == "" { - item.Checksum = libraryHash + libraryPath := installed.Driver.Shared.Get(config.PlatformTuple()) + if managed { + if !receiptPresent && item.LockEntry != nil && item.LockEntry.Legacy != nil { + // A v1 lock's library digest is its proof. Preserve that + // compatibility only for genuinely receipt-less generations. + libraryHash, checksumErr := checksum(libraryPath) + if checksumErr == nil && item.Checksum != "" && libraryHash == item.Checksum { + item.InstalledLibraryHash = libraryHash + item.AlreadyInstalled = &installedCopy + } + } else if hasMetadata && valid && installReceiptMatchesExpected(receipt, expected) && config.VerifyInstallReceiptLibraryIntegrity(libraryPath, receipt) { + markAlreadyInstalled(item, installed, receipt.InstalledLibraryHash) + } + } else { + // Manifest-only packages may reference an external library and + // therefore have no managed receipt beside that path. A v1 + // library checksum may tentatively prove continuity, but a v2 + // lock without runtime proof cannot authorize a same-version skip. + libraryHash, checksumErr := checksum(libraryPath) + if checksumErr == nil && item.Checksum != "" && libraryHash == item.Checksum { + item.InstalledLibraryHash = libraryHash + item.AlreadyInstalled = &installedCopy + } } - item.InstalledLibraryHash = libraryHash - installedCopy := installed - item.AlreadyInstalled = &installedCopy } else { installedCopy := installed item.RemovedDriver = &installedCopy @@ -731,8 +754,27 @@ func (s syncModel) prepareInstallItems(ctx context.Context, items []installItem) } return prepared, fmt.Errorf("failed to validate driver package: %w", err) } + verifiedLibraryHash := strings.TrimPrefix(validation.VerifiedLibraryHash, "sha256:") + item.ValidatedLibraryHash = verifiedLibraryHash + if item.AlreadyInstalled == nil && sameVersionInstalled != nil { + receipt, managed, _, valid, err := config.InspectDriverInstallReceipt(s.cfg, *sameVersionInstalled) + if err != nil { + return prepared, fmt.Errorf("failed to resolve installed driver receipt location: %w", err) + } + libraryPath := sameVersionInstalled.Driver.Shared.Get(config.PlatformTuple()) + if managed && valid && installReceiptMatchesExpected(receipt, expected) && config.VerifyInstallReceiptLibraryIntegrity(libraryPath, receipt) { + markAlreadyInstalled(item, *sameVersionInstalled, receipt.InstalledLibraryHash) + } + } + if item.AlreadyInstalled != nil && sameVersionInstalled != nil && + (verifiedLibraryHash == "" || item.InstalledLibraryHash != verifiedLibraryHash) { + // A receipt or v1 library proof can tentatively identify the + // installed artifact. Once the candidate archive is validated, + // require its measured library bytes to preserve that identity. + item.AlreadyInstalled = nil + } if item.AlreadyInstalled == nil { - item.InstalledLibraryHash = strings.TrimPrefix(validation.VerifiedLibraryHash, "sha256:") + item.InstalledLibraryHash = verifiedLibraryHash } } else { // The exact locked artifact is already installed, so it can be reused @@ -758,6 +800,21 @@ func (s syncModel) prepareInstallItems(ctx context.Context, items []installItem) return prepared, nil } +func installReceiptMatchesExpected(receipt config.InstallReceipt, expected config.ExpectedPackageMetadata) bool { + return expected.ID != "" && expected.Version != "" && expected.Platform != "" && + expected.SourceType != "" && expected.SourceIdentity != "" && expected.ArchiveHash != "" && expected.ArchiveSize > 0 && + receipt.DriverID == expected.ID && receipt.DriverVersion == expected.Version && + receipt.Platform == expected.Platform && receipt.SourceType == expected.SourceType && + receipt.SourceIdentity == expected.SourceIdentity && receipt.ArchiveHash == expected.ArchiveHash && + receipt.ArchiveSize == expected.ArchiveSize +} + +func markAlreadyInstalled(item *installItem, installed config.DriverInfo, libraryHash string) { + item.InstalledLibraryHash = strings.TrimPrefix(libraryHash, "sha256:") + installedCopy := installed + item.AlreadyInstalled = &installedCopy +} + type syncChecksumError struct{ err error } func (e syncChecksumError) Error() string { return e.err.Error() } @@ -950,12 +1007,19 @@ func (s syncModel) fail(code string, err error) (syncModel, tea.Cmd) { } func lockEntryForItem(item installItem) (lockInfo, error) { + legacyProofMatches := true if item.LockEntry != nil && item.LockEntry.Legacy != nil && samePlatformTarget(item.LockEntry.Legacy.Platform, config.PlatformTuple()) { if err := verifyLegacyLibraryProof(*item.LockEntry, config.PlatformTuple(), item.InstalledLibraryHash); err != nil { - return lockInfo{}, err + 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) { + if canReuseLockedEntry(item) && legacyProofMatches { return *item.LockEntry, nil } if item.Package.Version == nil || item.Package.Path == nil { @@ -1004,6 +1068,9 @@ func lockEntryForItem(item installItem) (lockInfo, error) { return candidate, nil } if item.LockEntry.Legacy != nil { + if !legacyProofMatches { + return candidate, nil + } var verified *VerifiedLegacyLibrary if samePlatformTarget(item.LockEntry.Legacy.Platform, config.PlatformTuple()) { verified = &VerifiedLegacyLibrary{Platform: config.PlatformTuple(), LibraryHash: item.InstalledLibraryHash} @@ -1012,7 +1079,31 @@ func lockEntryForItem(item installItem) (lockInfo, error) { } return candidate, nil } - return refreshLockEntry(*item.LockEntry, candidate) + existing := *item.LockEntry + if !legacyProofMatches { + existing.Legacy = nil + } + return refreshLockEntry(existing, candidate) +} + +func hasValidatedReplacementEvidence(item installItem) bool { + if item.AlreadyInstalled != nil || item.Archive == nil || item.ArchiveHash == "" || item.ArchiveSize <= 0 || item.ValidatedLibraryHash == "" { + return false + } + if validateLegacyLibraryHash(item.ValidatedLibraryHash) != nil || + item.Expected.ID != item.Driver.Path || item.Package.Version == nil || item.Expected.Version != item.Package.Version.String() || + item.Expected.Platform == "" || item.Expected.Platform != item.Package.PlatformTuple || + item.Expected.SourceType == "" || item.Expected.SourceIdentity == "" || + item.Expected.ArchiveHash != item.ArchiveHash || item.Expected.ArchiveSize != item.ArchiveSize || + item.Package.ArtifactHash != item.ArchiveHash || item.Package.ArtifactSize == nil || *item.Package.ArtifactSize != item.ArchiveSize { + return false + } + if item.Driver.Registry == nil || item.Driver.Registry.BaseURL == nil || + item.Expected.SourceType != "registry" || item.Expected.SourceIdentity != item.Driver.Registry.BaseURL.String() { + return false + } + archiveInfo, err := item.Archive.Stat() + return err == nil && archiveInfo.Size() == item.ArchiveSize } func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index 73714256..ec3bbdff 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -646,6 +646,50 @@ func (suite *SubcommandTestSuite) TestSyncLegacyLibraryProofUsesValidatedArchive suite.Equal(libraryHash, entry.Legacy.LibraryHash) } +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) TestSyncPartialRegistryDownloadsEachArchiveOnceAndRejectsV2WithoutMetadata() { 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)) @@ -699,6 +743,336 @@ func (suite *SubcommandTestSuite) TestSyncExactLockedArtifactConvergesWithoutReg suite.Equal(0, downloadCalls) } +func (suite *SubcommandTestSuite) TestSyncSameVersionRequiresMatchingManagedReceipt() { + tests := []struct { + name string + mutate func(*testing.T, string, string, config.InstallReceipt) + }{ + {name: "healthy receipt skips", mutate: func(*testing.T, string, string, config.InstallReceipt) {}}, + {name: "missing receipt repairs", mutate: func(t *testing.T, _ string, receiptPath string, _ config.InstallReceipt) { + if err := os.Remove(receiptPath); err != nil { + t.Fatal(err) + } + }}, + {name: "invalid receipt repairs", mutate: func(t *testing.T, _ string, receiptPath string, _ config.InstallReceipt) { + if err := os.WriteFile(receiptPath, []byte("not json"), 0o600); err != nil { + t.Fatal(err) + } + }}, + {name: "driver id mismatch repairs", mutate: func(t *testing.T, _ string, receiptPath string, receipt config.InstallReceipt) { + receipt.DriverID = "another-driver" + writeSyncReceipt(t, receiptPath, receipt) + }}, + {name: "version mismatch repairs", mutate: func(t *testing.T, _ string, receiptPath string, receipt config.InstallReceipt) { + receipt.DriverVersion = "9.9.9" + writeSyncReceipt(t, receiptPath, receipt) + }}, + {name: "source type mismatch repairs", mutate: func(t *testing.T, _ string, receiptPath string, receipt config.InstallReceipt) { + receipt.SourceType = "local" + writeSyncReceipt(t, receiptPath, receipt) + }}, + {name: "source identity mismatch repairs", mutate: func(t *testing.T, _ string, receiptPath string, receipt config.InstallReceipt) { + receipt.SourceIdentity = "https://other.example" + writeSyncReceipt(t, receiptPath, receipt) + }}, + {name: "target mismatch repairs", mutate: func(t *testing.T, _ string, receiptPath string, receipt config.InstallReceipt) { + if receipt.Platform == "windows_amd64" { + receipt.Platform = "linux_amd64" + } else { + receipt.Platform = "windows_amd64" + } + writeSyncReceipt(t, receiptPath, receipt) + }}, + {name: "archive hash mismatch repairs", mutate: func(t *testing.T, _ string, receiptPath string, receipt config.InstallReceipt) { + receipt.ArchiveHash = "sha256:" + strings.Repeat("0", 64) + writeSyncReceipt(t, receiptPath, receipt) + }}, + {name: "archive size mismatch repairs", mutate: func(t *testing.T, _ string, receiptPath string, receipt config.InstallReceipt) { + receipt.ArchiveSize++ + writeSyncReceipt(t, receiptPath, receipt) + }}, + {name: "tampered library repairs", mutate: func(t *testing.T, libraryPath, _ string, _ config.InstallReceipt) { + if err := os.WriteFile(libraryPath, []byte("tampered"), 0o644); err != nil { + t.Fatal(err) + } + }}, + {name: "missing library repairs", mutate: func(t *testing.T, libraryPath, _ string, _ config.InstallReceipt) { + if err := os.Remove(libraryPath); err != nil { + t.Fatal(err) + } + }}, + } + + for _, test := range tests { + suite.Run(test.name, func() { + t := suite.T() + root := t.TempDir() + t.Setenv("ADBC_DRIVER_PATH", root) + driverListPath := filepath.Join(root, "dbc.toml") + if err := os.WriteFile(driverListPath, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0o644); err != nil { + t.Fatal(err) + } + suite.runCmd(SyncCmd{Path: driverListPath, NoVerify: true}.GetModelCustom(testBaseModel())) + + cfg := config.Config{Level: config.ConfigEnv, Location: root} + installed, err := config.GetDriver(cfg, "test-driver-1") + if err != nil { + t.Fatal(err) + } + libraryPath := installed.Driver.Shared.Get(config.PlatformTuple()) + receiptPath := filepath.Join(filepath.Dir(libraryPath), "dbc-install-receipt.json") + receiptBytes, err := os.ReadFile(receiptPath) + if err != nil { + t.Fatal(err) + } + var receipt config.InstallReceipt + if err := json.Unmarshal(receiptBytes, &receipt); err != nil { + t.Fatal(err) + } + test.mutate(t, libraryPath, receiptPath, receipt) + + 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) + if strings.Contains(test.name, "healthy") { + suite.Equal(0, downloadCalls) + } else { + suite.Equal(1, downloadCalls) + } + + installed, err = config.GetDriver(cfg, "test-driver-1") + if err != nil { + t.Fatal(err) + } + libraryPath = installed.Driver.Shared.Get(config.PlatformTuple()) + receipt, managed, present, valid := config.InspectInstallReceipt(root, "test-driver-1", libraryPath) + if !managed || !present || !valid || !config.VerifyInstallReceiptLibraryIntegrity(libraryPath, receipt) { + t.Fatalf("repaired installation has invalid receipt/library: managed %v present %v valid %v", managed, present, valid) + } + }) + } +} + +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) TestSyncReceiptRepairUsesSelectedMultiPathRegistrationRoot() { + tests := []struct { + name string + mutate func(*testing.T, string, string, config.InstallReceipt) + }{ + {name: "receipt mismatch", mutate: func(t *testing.T, _ string, receiptPath string, receipt config.InstallReceipt) { + receipt.SourceIdentity = "https://different.example" + writeSyncReceipt(t, receiptPath, receipt) + }}, + {name: "library tampering", mutate: func(t *testing.T, libraryPath, _ string, _ config.InstallReceipt) { + if err := os.WriteFile(libraryPath, []byte("tampered"), 0o644); err != nil { + t.Fatal(err) + } + }}, + } + for _, test := range tests { + suite.Run(test.name, func() { + t := suite.T() + root := t.TempDir() + first := filepath.Join(root, "first") + second := filepath.Join(root, "second") + suite.Require().NoError(os.MkdirAll(first, 0o755)) + suite.Require().NoError(os.MkdirAll(second, 0o755)) + t.Setenv("ADBC_DRIVER_PATH", first+string(filepath.ListSeparator)+second) + 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())) + + firstManifest := filepath.Join(first, "test-driver-1.toml") + suite.Require().NoError(os.Remove(firstManifest)) + archive, err := os.Open(filepath.Join("testdata", "test-driver-1.1.tar.gz")) + suite.Require().NoError(err) + _, err = config.InstallPackage(config.Config{Level: config.ConfigEnv, Location: second}, "test-driver-1", archive, config.ExpectedPackageMetadata{ + ID: "test-driver-1", Version: "1.1.0", Platform: config.PlatformTuple(), + SourceType: "registry", SourceIdentity: testRegistry.BaseURL.String(), + }, config.InstallOptions{}) + suite.Require().NoError(err) + suite.NoError(archive.Close()) + + combined := config.Config{Level: config.ConfigEnv, Location: first + string(filepath.ListSeparator) + second} + installed, err := config.GetDriver(combined, "test-driver-1") + suite.Require().NoError(err) + suite.Equal(second, installed.FilePath) + libraryPath := installed.Driver.Shared.Get(config.PlatformTuple()) + receiptPath := filepath.Join(filepath.Dir(libraryPath), "dbc-install-receipt.json") + receiptBytes, err := os.ReadFile(receiptPath) + suite.Require().NoError(err) + var receipt config.InstallReceipt + suite.Require().NoError(json.Unmarshal(receiptBytes, &receipt)) + test.mutate(t, libraryPath, receiptPath, receipt) + + downloadCalls, registryCalls := 0, 0 + model := SyncCmd{Path: driverListPath, NoVerify: true}.GetModelCustom(baseModel{ + getDriverRegistry: func() ([]dbc.Driver, error) { + registryCalls++ + return nil, errors.New("exact lock 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) + + installed, err = config.GetDriver(config.Config{Level: config.ConfigEnv, Location: first + string(filepath.ListSeparator) + second}, "test-driver-1") + suite.Require().NoError(err) + suite.Equal(first, installed.FilePath, "repair should publish to the configured primary environment path") + finalLibrary := installed.Driver.Shared.Get(config.PlatformTuple()) + finalReceipt, managed, present, valid, err := config.InspectDriverInstallReceipt(config.Config{Level: config.ConfigEnv, Location: first + string(filepath.ListSeparator) + second}, installed) + suite.Require().NoError(err) + suite.True(managed) + suite.True(present) + suite.True(valid) + suite.True(config.VerifyInstallReceiptLibraryIntegrity(finalLibrary, finalReceipt)) + }) + } +} + +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.installPackage = func(_ context.Context, cfg config.Config, driver string, archive *os.File, expected config.ExpectedPackageMetadata, options config.InstallOptions) (config.Manifest, error) { + installCalls++ + return config.InstallPackage(cfg, driver, archive, expected, options) + } + 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) + }) + } +} + type syncProgramRun struct { program *tea.Program done chan struct{} diff --git a/config/install_transaction_test.go b/config/install_transaction_test.go index 01cbe5d6..2556c058 100644 --- a/config/install_transaction_test.go +++ b/config/install_transaction_test.go @@ -242,6 +242,110 @@ func TestInstallPackageReceiptHashesLibraryAfterVerification(t *testing.T) { } } +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 TestInstallPackageRejectsVerifierDeletedLibraryAndPreservesOldGeneration(t *testing.T) { root := t.TempDir() cfg := Config{Level: ConfigEnv, Location: root} diff --git a/config/package_archive.go b/config/package_archive.go index ce18f707..3003f919 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -818,6 +818,120 @@ func readManagedPackageReceipt(location, runtimeID, directory string) (InstallRe } 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 { @@ -859,15 +973,6 @@ func readPackageReceipt(location, runtimeID, directory string) (InstallReceipt, if _, err := parseSHA256(receipt.InstalledLibraryHash); err != nil { return InstallReceipt{}, false } - libraryPath := filepath.Join(absDirectory, receipt.InstalledLibrary) - libraryInfo, err := os.Lstat(libraryPath) - if err != nil || !libraryInfo.Mode().IsRegular() { - return InstallReceipt{}, false - } - actualHash, err := hashFile(libraryPath) - if err != nil || actualHash != receipt.InstalledLibraryHash { - return InstallReceipt{}, false - } return receipt, true } From b5febf25ed039334bfb709bf15706980c6976aa8 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 00:49:08 +0000 Subject: [PATCH 048/115] fix: preserve manifest-only legacy proofs --- cmd/dbc/sync.go | 74 +++++-- cmd/dbc/sync_test.go | 335 +++++++++++++++++++++++++++++ config/install_transaction_test.go | 54 +++++ config/package_archive.go | 23 +- 4 files changed, 467 insertions(+), 19 deletions(-) diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 88d9dd1c..8b60bba8 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -756,25 +756,41 @@ func (s syncModel) prepareInstallItems(ctx context.Context, items []installItem) } verifiedLibraryHash := strings.TrimPrefix(validation.VerifiedLibraryHash, "sha256:") item.ValidatedLibraryHash = verifiedLibraryHash - if item.AlreadyInstalled == nil && sameVersionInstalled != nil { - receipt, managed, _, valid, err := config.InspectDriverInstallReceipt(s.cfg, *sameVersionInstalled) - if err != nil { - return prepared, fmt.Errorf("failed to resolve installed driver receipt location: %w", err) + legacyExternalProof := item.LockEntry != nil && item.LockEntry.Legacy != nil && + samePlatformTarget(item.LockEntry.Legacy.Platform, config.PlatformTuple()) && verifiedLibraryHash == "" + if legacyExternalProof { + proofHash := item.LockEntry.Legacy.LibraryHash + candidateLibrary := validation.Registration.Driver.Shared.Get(config.PlatformTuple()) + if err := verifyLegacyExternalLibrary(candidateLibrary, proofHash); err != nil { + return prepared, fmt.Errorf("candidate package external library does not match the legacy lock proof: %w", err) } - libraryPath := sameVersionInstalled.Driver.Shared.Get(config.PlatformTuple()) - if managed && valid && installReceiptMatchesExpected(receipt, expected) && config.VerifyInstallReceiptLibraryIntegrity(libraryPath, receipt) { - markAlreadyInstalled(item, *sameVersionInstalled, receipt.InstalledLibraryHash) + item.InstalledLibraryHash = proofHash + if sameVersionInstalled != nil && config.SameRuntimeDriverRegistration(*sameVersionInstalled, validation.Registration, config.PlatformTuple()) { + markAlreadyInstalled(item, *sameVersionInstalled, proofHash) + } else { + item.AlreadyInstalled = nil + } + } else { + if item.AlreadyInstalled == nil && sameVersionInstalled != nil { + receipt, managed, _, valid, err := config.InspectDriverInstallReceipt(s.cfg, *sameVersionInstalled) + if err != nil { + return prepared, fmt.Errorf("failed to resolve installed driver receipt location: %w", err) + } + libraryPath := sameVersionInstalled.Driver.Shared.Get(config.PlatformTuple()) + if managed && valid && installReceiptMatchesExpected(receipt, expected) && config.VerifyInstallReceiptLibraryIntegrity(libraryPath, receipt) { + markAlreadyInstalled(item, *sameVersionInstalled, receipt.InstalledLibraryHash) + } + } + if item.AlreadyInstalled != nil && sameVersionInstalled != nil && + (verifiedLibraryHash == "" || item.InstalledLibraryHash != verifiedLibraryHash) { + // A receipt or v1 library proof can tentatively identify the + // installed artifact. Once the candidate archive is validated, + // require its measured library bytes to preserve that identity. + item.AlreadyInstalled = nil + } + if item.AlreadyInstalled == nil { + item.InstalledLibraryHash = verifiedLibraryHash } - } - if item.AlreadyInstalled != nil && sameVersionInstalled != nil && - (verifiedLibraryHash == "" || item.InstalledLibraryHash != verifiedLibraryHash) { - // A receipt or v1 library proof can tentatively identify the - // installed artifact. Once the candidate archive is validated, - // require its measured library bytes to preserve that identity. - item.AlreadyInstalled = nil - } - if item.AlreadyInstalled == nil { - item.InstalledLibraryHash = verifiedLibraryHash } } else { // The exact locked artifact is already installed, so it can be reused @@ -815,6 +831,30 @@ func markAlreadyInstalled(item *installItem, installed config.DriverInfo, librar 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 +} + type syncChecksumError struct{ err error } func (e syncChecksumError) Error() string { return e.err.Error() } diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index ec3bbdff..f2096b42 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -690,6 +690,341 @@ func (suite *SubcommandTestSuite) TestSyncLegacyProofDoesNotSkipDifferentCandida 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, candidateExists bool, currentEntrypoint, candidateEntrypoint string, registered bool) 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) + } + } + if registered { + writeExternal(currentPath, proofData) + if err := os.WriteFile(filepath.Join(filepath.Dir(currentPath), "LICENSE"), []byte("external 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 = currentEntrypoint + current.Driver.Shared.Set(config.PlatformTuple(), currentPath) + if err := config.CreateManifest(config.Config{Level: config.ConfigEnv, Location: root}, current); err != nil { + t.Fatal(err) + } + } + if candidateExists { + if candidatePath != currentPath || !registered { + writeExternal(candidatePath, candidateData) + } + if err := os.WriteFile(filepath.Join(filepath.Dir(candidatePath), "LICENSE"), []byte("candidate sibling"), 0o644); 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, candidateEntrypoint) + 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, root, 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.installPackage = func(_ context.Context, cfg config.Config, driver string, archive *os.File, expected config.ExpectedPackageMetadata, options config.InstallOptions) (config.Manifest, error) { + count.install++ + return config.InstallPackage(cfg, driver, archive, expected, options) + } + 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.WithoutRenderer(), 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, true, "DriverInit", "DriverInit", true) + counts := &syncArchiveRunCounts{} + _, err := run(t, fixture.root, fixture.listPath, fixture.archivePath, counts, getTestDriverRegistry) + suite.NoError(err) + 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.root, 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{}, *replayCounts) + lockAfterReplay, err := os.ReadFile(fixture.lockPath) + suite.Require().NoError(err) + suite.Equal(lockAfterMigration, lockAfterReplay) + }) + + for _, test := range []struct { + name string + candidatePathChanged bool + candidateEntrypoint string + }{ + {name: "external path changed", candidatePathChanged: true, candidateEntrypoint: "DriverInit"}, + {name: "entrypoint changed", candidateEntrypoint: "OtherInit"}, + } { + suite.Run(test.name, func() { + t := suite.T() + root := t.TempDir() + currentPath := filepath.Join(root, "external-a", "library.so") + candidatePath := currentPath + if test.candidatePathChanged { + candidatePath = filepath.Join(root, "external-b", "library.so") + } + data := []byte("same legacy external library") + fixture := setup(t, currentPath, candidatePath, data, data, true, "DriverInit", test.candidateEntrypoint, true) + before := map[string][]byte{} + for _, external := range []string{currentPath, candidatePath} { + for _, file := range []string{external, filepath.Join(filepath.Dir(external), "LICENSE")} { + if _, ok := before[file]; ok { + continue + } + var readErr error + before[file], readErr = os.ReadFile(file) + suite.Require().NoError(readErr) + } + } + counts := &syncArchiveRunCounts{} + _, err := run(t, fixture.root, fixture.listPath, fixture.archivePath, counts, getTestDriverRegistry) + suite.NoError(err) + suite.Equal(1, counts.install) + installed, err := config.GetDriver(config.Config{Level: config.ConfigEnv, Location: fixture.root}, "test-driver-1") + suite.Require().NoError(err) + suite.Equal(candidatePath, installed.Driver.Shared.Get(config.PlatformTuple())) + suite.Equal(test.candidateEntrypoint, installed.Driver.Entrypoint) + installedHash, err := checksum(candidatePath) + suite.Require().NoError(err) + suite.Equal(fixture.proofHash, installedHash) + 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) + for file, expected := range before { + dataAfter, readErr := os.ReadFile(file) + suite.NoError(readErr) + suite.Equal(expected, dataAfter, "external files and siblings must remain untouched") + } + }) + } + + for _, test := range []struct { + name string + candidateData []byte + candidateFound bool + }{ + {name: "candidate bytes mismatch", candidateData: []byte("not the legacy library"), candidateFound: true}, + {name: "candidate file missing", candidateFound: false}, + } { + suite.Run(test.name, 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") + fixture := setup(t, currentPath, candidatePath, proofData, test.candidateData, test.candidateFound, "DriverInit", "DriverInit", true) + beforeManifest, err := os.ReadFile(filepath.Join(fixture.root, "test-driver-1.toml")) + suite.Require().NoError(err) + counts := &syncArchiveRunCounts{} + out, err := run(t, fixture.root, 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) + if test.candidateFound { + candidateAfter, err := os.ReadFile(candidatePath) + suite.Require().NoError(err) + suite.Equal(test.candidateData, candidateAfter) + } else { + _, err := os.Stat(candidatePath) + suite.ErrorIs(err, os.ErrNotExist) + } + }) + } + + for _, test := range []struct { + name string + candidateData []byte + candidateFound bool + wantInstall int + }{ + {name: "unregistered external proof installs", candidateData: []byte("unregistered legacy library"), candidateFound: true, wantInstall: 1}, + {name: "unregistered mismatched external proof fails", candidateData: []byte("wrong bytes"), candidateFound: true}, + {name: "unregistered missing external proof fails", candidateFound: false}, + } { + suite.Run(test.name, func() { + t := suite.T() + root := t.TempDir() + candidatePath := filepath.Join(root, "external", "library.so") + proofData := []byte("unregistered legacy library") + fixture := setup(t, "", candidatePath, proofData, test.candidateData, test.candidateFound, "", "DriverInit", false) + counts := &syncArchiveRunCounts{} + out, err := run(t, fixture.root, fixture.listPath, fixture.archivePath, counts, getTestDriverRegistry) + if test.wantInstall == 0 { + suite.Error(err) + suite.Contains(out, "candidate package external library does not match the legacy lock proof") + suite.Equal(0, counts.install) + lockAfter, readErr := os.ReadFile(fixture.lockPath) + suite.Require().NoError(readErr) + suite.Equal(fixture.oldLock, lockAfter) + _, driverErr := config.GetDriver(config.Config{Level: config.ConfigEnv, Location: fixture.root}, "test-driver-1") + suite.Error(driverErr) + } else { + suite.NoError(err) + suite.Equal(1, counts.install) + installed, driverErr := config.GetDriver(config.Config{Level: config.ConfigEnv, Location: fixture.root}, "test-driver-1") + suite.Require().NoError(driverErr) + suite.Equal(candidatePath, installed.Driver.Shared.Get(config.PlatformTuple())) + installedHash, hashErr := checksum(candidatePath) + suite.Require().NoError(hashErr) + suite.Equal(fixture.proofHash, installedHash) + updated, loadErr := loadLockFile(fixture.lockPath) + suite.Require().NoError(loadErr) + suite.Require().NotNil(updated.lockinfo["test-driver-1"].Legacy) + } + suite.Equal(1, counts.registry) + suite.Equal(1, counts.download) + if test.candidateFound { + candidateAfter, readErr := os.ReadFile(candidatePath) + suite.Require().NoError(readErr) + suite.Equal(test.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) TestSyncPartialRegistryDownloadsEachArchiveOnceAndRejectsV2WithoutMetadata() { 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)) diff --git a/config/install_transaction_test.go b/config/install_transaction_test.go index 2556c058..6a899223 100644 --- a/config/install_transaction_test.go +++ b/config/install_transaction_test.go @@ -346,6 +346,60 @@ func TestInspectDriverInstallReceiptUsesSelectedEnvironmentPath(t *testing.T) { } } +func TestValidatePackageReturnsAndComparesRuntimeRegistration(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") + validation, err := ValidatePackage("example", file, installExpected("example", "source", archive), InstallOptions{}) + _ = file.Close() + if err != nil { + t.Fatal(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} diff --git a/config/package_archive.go b/config/package_archive.go index 3003f919..3dec6acf 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -79,6 +79,7 @@ type InstallOptions struct { // driver library file. type PackageValidation struct { VerifiedLibraryHash string + Registration DriverInfo } type packageManifest struct { @@ -359,7 +360,7 @@ func ValidatePackage(runtimeID string, downloaded *os.File, expected ExpectedPac }() finalDir := filepath.Join(workDir, "installed") - _, payloadDir, err := stagePackageArchive(workDir, runtimeID, finalDir, downloaded, expected, options.Verify, workDir) + manifest, payloadDir, err := stagePackageArchive(workDir, runtimeID, finalDir, downloaded, expected, options.Verify, workDir) if err != nil { return PackageValidation{}, err } @@ -367,7 +368,25 @@ func ValidatePackage(runtimeID string, downloaded *os.File, expected ExpectedPac if !ok { return PackageValidation{}, errors.New("could not read validated package receipt") } - return PackageValidation{VerifiedLibraryHash: receipt.InstalledLibraryHash}, nil + return PackageValidation{VerifiedLibraryHash: receipt.InstalledLibraryHash, Registration: manifest.DriverInfo}, 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. +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) || + !reflect.DeepEqual(current.AdbcInfo, candidate.AdbcInfo) { + return false + } + if current.Version == nil || candidate.Version == nil { + return current.Version == nil && candidate.Version == nil + } + return current.Version.String() == candidate.Version.String() } func installPackage(cfg Config, runtimeID string, downloaded *os.File, expected ExpectedPackageMetadata, options InstallOptions, registerManifest func(Config, DriverInfo) error) (Manifest, error) { From 062575a2f0fd42b91cfcc8e5c58527cbd91a9e47 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 00:57:54 +0000 Subject: [PATCH 049/115] docs: note externally managed package variant --- config/package_archive.go | 1 + 1 file changed, 1 insertion(+) diff --git a/config/package_archive.go b/config/package_archive.go index 3dec6acf..e9c161b6 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -1399,6 +1399,7 @@ func validatePackageFileReferences(manifest Manifest, files map[string]string, r driver := manifest.Files.Driver if driver == "" { if requireDriver { + // TODO: Model externally managed drivers as an explicit package variant with ownership semantics, not an empty Files.driver exception. return fmt.Errorf("%w: Files.driver is required", ErrInvalidManifest) } } else { From 3a593c651abb214a7ef5e988bdf9a1e235934302 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 01:28:11 +0000 Subject: [PATCH 050/115] feat: add atomic package ensure transaction --- config/ensure_package_test.go | 678 ++++++++++++++++++++++++++++++++++ config/install_lock_js.go | 32 +- config/install_lock_native.go | 9 + config/package_archive.go | 349 ++++++++++++++++- 4 files changed, 1052 insertions(+), 16 deletions(-) create mode 100644 config/ensure_package_test.go diff --git a/config/ensure_package_test.go b/config/ensure_package_test.go new file mode 100644 index 00000000..367ae5cf --- /dev/null +++ b/config/ensure_package_test.go @@ -0,0 +1,678 @@ +// 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 packageFileProvider(t *testing.T, data []byte) (*os.File, func() (*os.File, error)) { + t.Helper() + file := writeInstallArchive(t, data, "ensure-candidate") + return file, func() (*os.File, error) { return file, nil } +} + +func expectedEnsurePackage(id, version, source string, archive []byte) ExpectedPackageMetadata { + expected := installExpected(id, source, archive) + expected.Version = version + return expected +} + +func TestEnsurePackageSkipsWithoutRequestingArchive(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 + var validated EnsurePackageResult + result, err := EnsurePackage(context.Background(), cfg, "example", installExpected("example", "source", archive), InstallOptions{}, EnsurePackageCallbacks{ + CurrentMatches: func(current *DriverInfo) (bool, error) { + return current != nil && sameDriverRegistration(initial, *current), nil + }, + Archive: func() (*os.File, error) { + providerCalls++ + return nil, errors.New("archive provider 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 { + t.Fatalf("EnsurePackage result skipped=%t provider calls=%d, want skip and zero calls", result.Skipped, providerCalls) + } + 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 TestEnsurePackageInstallsExactProvidedArchiveAndLeavesItOpen(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")) + archiveFile, provider := packageFileProvider(t, newArchive) + providerCalls := 0 + result, err := EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "new-source", newArchive), InstallOptions{}, EnsurePackageCallbacks{ + CurrentMatches: func(current *DriverInfo) (bool, error) { return false, nil }, + Archive: func() (*os.File, error) { + providerCalls++ + return provider() + }, + 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) + } + if _, err := archiveFile.Stat(); err != nil { + t.Fatalf("EnsurePackage closed the caller-owned archive: %v", err) + } + _ = archiveFile.Close() +} + +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), InstallOptions{}, EnsurePackageCallbacks{ + CurrentMatches: func(*DriverInfo) (bool, error) { return false, callbackErr }, + Archive: func() (*os.File, error) { + providerCalls++ + return writeInstallArchive(t, newArchive, "unused"), nil + }, + }) + if !errors.Is(err, callbackErr) || providerCalls != 0 { + t.Fatalf("predicate error = %v, provider calls = %d", err, providerCalls) + } + + providerErr := errors.New("provider failed") + _, err = EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "new-source", newArchive), InstallOptions{}, EnsurePackageCallbacks{ + CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, + Archive: func() (*os.File, error) { return nil, providerErr }, + }) + if !errors.Is(err, providerErr) { + t.Fatalf("provider error = %v, want %v", 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") + archiveFile, provider := packageFileProvider(t, newArchive) + result, err := EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "new-source", newArchive), InstallOptions{}, EnsurePackageCallbacks{ + CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, + Archive: provider, + 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() + if _, err := archiveFile.Stat(); err != nil { + t.Fatalf("EnsurePackage closed caller archive after validation error: %v", err) + } + _ = archiveFile.Close() +} + +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), InstallOptions{}, EnsurePackageCallbacks{ + CurrentMatches: func(current *DriverInfo) (bool, error) { + return current != nil && current.Version.String() == "1.0.0", nil + }, + Archive: func() (*os.File, error) { + providerCalls++ + return nil, errors.New("provider must not run") + }, + }) + done <- struct { + result EnsurePackageResult + err error + }{result, err} + }() + <-started + time.Sleep(120 * time.Millisecond) + 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() + started := make(chan struct{}) + done := make(chan error, 1) + go func() { + close(started) + _, err := EnsurePackage(ctx, cfg, "example", ExpectedPackageMetadata{ID: "example"}, InstallOptions{}, EnsurePackageCallbacks{ + CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, + Archive: func() (*os.File, error) { return nil, errors.New("provider must not run") }, + }) + done <- err + }() + <-started + time.Sleep(120 * time.Millisecond) + 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 lock was not held while EnsurePackage waited on later root: %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 TestEnsurePackageCancellationWaitsForContextlessProviderAndInstall(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")) + installEnsureFixture(t, cfg, "1.0.0", "old", oldArchive) + newArchive := makeInstallArchive(t, "example", "2.0.0", "new.so", []byte("new library")) + providerArchive := writeInstallArchive(t, newArchive, "cancelled-provider") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + providerStarted := make(chan struct{}) + releaseProvider := make(chan struct{}) + var releaseOnce sync.Once + unblockProvider := func() { releaseOnce.Do(func() { close(releaseProvider) }) } + defer unblockProvider() + done := make(chan error, 1) + go func() { + _, err := EnsurePackage(ctx, cfg, "example", expectedEnsurePackage("example", "2.0.0", "new", newArchive), InstallOptions{}, EnsurePackageCallbacks{ + CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, + Archive: func() (*os.File, error) { + close(providerStarted) + <-releaseProvider + return providerArchive, nil + }, + }) + done <- err + }() + select { + case <-providerStarted: + case <-time.After(5 * time.Second): + t.Fatal("EnsurePackage did not enter provider") + } + cancel() + select { + case err := <-done: + t.Fatalf("EnsurePackage returned before contextless provider finished: %v", err) + case <-time.After(100 * time.Millisecond): + } + lockCtx, cancelLock := context.WithTimeout(context.Background(), 100*time.Millisecond) + lockRelease, lockErr := acquireDriverInstallLockContext(lockCtx, root, "example") + cancelLock() + if lockRelease != nil { + lockRelease() + } + if !errors.Is(lockErr, context.DeadlineExceeded) { + t.Fatalf("driver lock was not held while provider was blocked: %v", lockErr) + } + if _, err := os.Stat(filepath.Join(root, "example.toml")); err != nil { + t.Fatalf("provider cancellation changed the old registration: %v", err) + } + unblockProvider() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("provider cancellation error = %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("EnsurePackage did not finish after provider returned") + } + 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) + } + release, err := acquireDriverInstallLock(root, "example") + if err != nil { + t.Fatalf("provider cancellation leaked driver lock: %v", err) + } + release() + if _, err := providerArchive.Stat(); err != nil { + t.Fatalf("EnsurePackage closed caller-owned archive: %v", err) + } + _ = providerArchive.Close() + }) + + 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")) + archiveFile := writeInstallArchive(t, newArchive, "cancelled-install") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + verifyStarted := make(chan struct{}) + releaseVerify := make(chan struct{}) + var releaseOnce sync.Once + unblockVerify := func() { releaseOnce.Do(func() { close(releaseVerify) }) } + defer unblockVerify() + done := make(chan struct { + result EnsurePackageResult + err error + }, 1) + go func() { + result, err := EnsurePackage(ctx, cfg, "example", expectedEnsurePackage("example", "2.0.0", "new", newArchive), InstallOptions{ + Verify: func(string, Manifest) error { + close(verifyStarted) + <-releaseVerify + return nil + }, + }, EnsurePackageCallbacks{ + CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, + Archive: func() (*os.File, error) { return archiveFile, nil }, + }) + done <- struct { + result EnsurePackageResult + err error + }{result, err} + }() + select { + case <-verifyStarted: + case <-time.After(5 * time.Second): + t.Fatal("EnsurePackage did not enter install verification") + } + cancel() + select { + case result := <-done: + t.Fatalf("EnsurePackage returned before contextless install finished: %#v", result) + case <-time.After(100 * time.Millisecond): + } + 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 install was blocked: %v", lockErr) + } + if lockRelease != nil { + lockRelease() + } + unblockVerify() + 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) + } + if _, err := archiveFile.Stat(); err != nil { + t.Fatalf("EnsurePackage closed caller-owned archive: %v", err) + } + _ = archiveFile.Close() + }) +} + +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), InstallOptions{}, EnsurePackageCallbacks{ + CurrentMatches: func(current *DriverInfo) (bool, error) { + return current != nil && current.FilePath == primary && current.Version.String() == "1.0.0", nil + }, + Archive: func() (*os.File, error) { + providerCalls++ + return nil, errors.New("provider must not run") + }, + }) + if err != nil || !result.Skipped || providerCalls != 0 || result.Current.FilePath != primary { + t.Fatalf("ConfigEnv precedence result=%#v provider 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), InstallOptions{}, 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 TestEnsurePackageLocksAgainstConcurrentUninstall(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")) + archiveFile := writeInstallArchive(t, newArchive, "blocked-provider") + 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) + go func() { + result, err := EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "new", newArchive), InstallOptions{}, EnsurePackageCallbacks{ + CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, + Archive: func() (*os.File, error) { + close(providerStarted) + <-releaseProvider + return archiveFile, nil + }, + }) + ensureDone <- struct { + result EnsurePackageResult + err error + }{result, err} + }() + select { + case <-providerStarted: + case <-time.After(5 * time.Second): + t.Fatal("EnsurePackage did not reach archive provider") + } + uninstallStarted := make(chan struct{}) + uninstallDone := make(chan error, 1) + go func() { + close(uninstallStarted) + uninstallDone <- UninstallDriver(cfg, oldInfo) + }() + <-uninstallStarted + select { + case err := <-uninstallDone: + t.Fatalf("uninstall escaped EnsurePackage driver lock: %v", err) + case <-time.After(150 * time.Millisecond): + } + unblockProvider() + select { + case result := <-ensureDone: + if result.err != 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 archive provider returned") + } + select { + case err := <-uninstallDone: + if err == nil || !strings.Contains(err.Error(), "registration changed before uninstall") { + t.Fatalf("uninstall result = %v, want stale registration rejection", err) + } + case <-time.After(5 * time.Second): + t.Fatal("uninstall did not resume after EnsurePackage released its lock") + } + 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) + } + if _, err := archiveFile.Stat(); err != nil { + t.Fatalf("EnsurePackage closed caller-owned archive: %v", err) + } + _ = archiveFile.Close() +} + +func TestEnsurePackageSerializesConcurrentInstall(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")) + ensuredFile := writeInstallArchive(t, ensuredArchive, "ensure-provider") + concurrentArchive := makeInstallArchive(t, "example", "3.0.0", "concurrent.so", []byte("concurrent library")) + concurrentFile := writeInstallArchive(t, concurrentArchive, "concurrent-install") + providerStarted := make(chan struct{}) + releaseProvider := make(chan struct{}) + var releaseOnce sync.Once + unblockProvider := func() { releaseOnce.Do(func() { close(releaseProvider) }) } + defer unblockProvider() + ensureDone := make(chan error, 1) + go func() { + _, err := EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "ensured", ensuredArchive), InstallOptions{}, EnsurePackageCallbacks{ + CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, + Archive: func() (*os.File, error) { + close(providerStarted) + <-releaseProvider + return ensuredFile, nil + }, + }) + ensureDone <- err + }() + select { + case <-providerStarted: + case <-time.After(5 * time.Second): + t.Fatal("EnsurePackage did not reach archive provider") + } + installStarted := make(chan struct{}) + installDone := make(chan error, 1) + go func() { + close(installStarted) + _, err := InstallPackage(cfg, "example", concurrentFile, expectedEnsurePackage("example", "3.0.0", "concurrent", concurrentArchive), InstallOptions{}) + installDone <- err + }() + <-installStarted + select { + case err := <-installDone: + t.Fatalf("concurrent install escaped EnsurePackage driver lock: %v", err) + case <-time.After(150 * time.Millisecond): + } + unblockProvider() + select { + case err := <-ensureDone: + if err != nil { + t.Fatalf("EnsurePackage failed: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("EnsurePackage did not finish after provider returned") + } + select { + case err := <-installDone: + if err != nil { + t.Fatalf("concurrent InstallPackage failed after lock release: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("concurrent InstallPackage did not resume after EnsurePackage released its lock") + } + current, err := GetDriver(cfg, "example") + if err != nil || current.Version.String() != "3.0.0" { + t.Fatalf("concurrent install was not serialized after EnsurePackage: current=%#v error=%v", current, err) + } + _ = ensuredFile.Close() + _ = concurrentFile.Close() +} + +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 index 1e63fd95..e5b6bb94 100644 --- a/config/install_lock_js.go +++ b/config/install_lock_js.go @@ -16,11 +16,33 @@ package config -import "sync" +import ( + "context" + "path/filepath" + "sync" +) -var packageInstallLock sync.Mutex +var packageInstallLocksMu sync.Mutex +var packageInstallLocks = map[string]chan struct{}{} -func acquirePackageInstallLock(_ string) (func(), error) { - packageInstallLock.Lock() - return packageInstallLock.Unlock, nil +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 index 4312cc6d..ef035e44 100644 --- a/config/install_lock_native.go +++ b/config/install_lock_native.go @@ -17,6 +17,7 @@ package config import ( + "context" "time" "github.com/columnar-tech/dbc/internal/fslock" @@ -29,3 +30,11 @@ func acquirePackageInstallLock(path string) (func(), error) { } 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/package_archive.go b/config/package_archive.go index e9c161b6..dcb2e1bc 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -17,6 +17,7 @@ package config import ( "archive/tar" "compress/gzip" + "context" "crypto/sha256" "encoding/hex" "encoding/json" @@ -27,7 +28,10 @@ import ( "os" "path/filepath" "reflect" + "runtime" + "slices" "strings" + "sync" "github.com/Masterminds/semver/v3" "github.com/pelletier/go-toml/v2" @@ -335,6 +339,294 @@ func InstallPackage(cfg Config, runtimeID string, downloaded *os.File, expected return installPackage(cfg, runtimeID, downloaded, expected, options, CreateManifest) } +// EnsurePackageCallbacks contains the narrow decisions and resources needed +// to atomically reuse or install a selected package while its driver locks are +// held. CurrentMatches receives the latest registration (nil when absent). +// Archive is called at most once and only when the registration does not +// match. ValidateResult runs before the locks are released; if it fails after +// installation, the installed generation remains committed and is described +// by the returned result. All three callbacks execute 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. +type EnsurePackageCallbacks struct { + CurrentMatches func(current *DriverInfo) (bool, error) + Archive func() (*os.File, error) + ValidateResult func(result EnsurePackageResult) error +} + +// EnsurePackageResult separates the registration observed before the action, +// the registration present afterwards, any registration physically replaced +// at the installation root, and an install manifest (nil on a skip). +type EnsurePackageResult struct { + Skipped bool + Current *DriverInfo + Installed *DriverInfo + Previous *DriverInfo + Manifest *Manifest +} + +// EnsurePackage rechecks the effective runtime registration under all +// configured driver install locks. It returns a skip only when CurrentMatches +// accepts that latest registration; otherwise it obtains one caller-owned +// archive and installs it while retaining the locks. The archive is never +// closed here. Provider and installation work are synchronous and, once +// started, complete before the locks are released even if ctx is canceled. +// CurrentMatches, Archive, and ValidateResult all 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. +func EnsurePackage(ctx context.Context, cfg Config, runtimeID string, expected ExpectedPackageMetadata, installOptions InstallOptions, callbacks EnsurePackageCallbacks) (EnsurePackageResult, error) { + var result EnsurePackageResult + 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 := acquireDriverInstallLocks(ctx, precedenceRoots, runtimeID) + if err != nil { + return result, fmt.Errorf("could not lock driver installation: %w", err) + } + defer releaseLocks() + if err := ctx.Err(); err != nil { + return result, err + } + + current, err := loadEffectiveInstalledDriver(cfg, precedenceRoots, runtimeID) + if err != nil { + return result, fmt.Errorf("could not inspect current driver registration: %w", err) + } + if err := ctx.Err(); err != nil { + return result, err + } + result.Current = cloneDriverInfo(current) + matches, err := callbacks.CurrentMatches(cloneDriverInfo(current)) + if err != nil { + return result, fmt.Errorf("could not compare current driver registration: %w", err) + } + if err := ctx.Err(); err != nil { + return result, err + } + if matches { + if current == nil { + return result, errors.New("current-registration predicate accepted a missing registration") + } + result.Skipped = true + result.Installed = cloneDriverInfo(current) + if callbacks.ValidateResult != nil { + if err := callbacks.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 + } + if callbacks.Archive == nil { + return result, errors.New("package archive provider is nil") + } + archive, providerErr := callbacks.Archive() + if err := ctx.Err(); err != nil { + return result, err + } + if providerErr != nil { + return result, fmt.Errorf("could not provide package archive: %w", providerErr) + } + if archive == nil { + return result, errors.New("package archive provider returned nil") + } + if err := ctx.Err(); err != nil { + return result, err + } + manifest, previous, err := installPackageLocked(cfg, runtimeID, primary, archive, expected, installOptions, 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 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) { + lockRoots := slices.Clone(roots) + 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) + } + 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 +} + // ValidatePackage verifies and stages an already-downloaded package in a // private temporary directory without registering it or changing shared // configuration. The temporary directory is removed before ValidatePackage @@ -415,49 +707,74 @@ func installPackageWithCleanup(cfg Config, runtimeID string, downloaded *os.File 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{}, fmt.Errorf("could not inspect existing driver registration: %w", err) + 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{}, fmt.Errorf("could not create private installation staging directory: %w", err) + 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{}, fmt.Errorf("could not reserve package generation directory: %w", err) + return Manifest{}, previous, fmt.Errorf("could not reserve package generation directory: %w", err) } if err := os.Remove(reservedDir); err != nil { - return Manifest{}, fmt.Errorf("could not prepare package generation directory: %w", err) + 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{}, err + return Manifest{}, previous, err } if err := os.Rename(payloadDir, finalDir); err != nil { - return Manifest{}, fmt.Errorf("could not publish verified package generation: %w", err) + 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{}, fmt.Errorf("could not register driver manifest; preserving verified package at %s: %w", finalDir, err) + 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{}, 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; could not remove unregistered package at %s: %v", err, finalDir, removeErr) } - return Manifest{}, fmt.Errorf("could not register driver manifest: %w", err) + 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, nil + return manifest, previous, nil } func normalizePackageInstallMetadata(runtimeID string, expected ExpectedPackageMetadata) (ExpectedPackageMetadata, error) { @@ -513,9 +830,19 @@ func loadInstalledDriver(cfg Config, loc, runtimeID string) (*DriverInfo, error) } 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 acquirePackageInstallLock(filepath.Join(location, ".dbc-package-install-"+hex.EncodeToString(lockHash[:])+".lock")) + return acquire(filepath.Join(location, ".dbc-package-install-"+hex.EncodeToString(lockHash[:])+".lock")) } func driverInstallLockLocation(cfg Config, info DriverInfo) (string, error) { From 37d7f588f662b53cf5e7fc7aed1c1726ca70e29d Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 01:34:16 +0000 Subject: [PATCH 051/115] test: synchronize partial install lock coverage --- config/ensure_package_test.go | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/config/ensure_package_test.go b/config/ensure_package_test.go index 367ae5cf..8f50be5b 100644 --- a/config/ensure_package_test.go +++ b/config/ensure_package_test.go @@ -302,15 +302,25 @@ func TestEnsurePackageCancellationReleasesPartiallyAcquiredLocks(t *testing.T) { done <- err }() <-started - time.Sleep(120 * time.Millisecond) - 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 lock was not held while EnsurePackage waited on later root: %v", contenderErr) + lockHeldDeadline := time.Now().Add(3 * time.Second) + earlyLockObserved := false + for time.Now().Before(lockHeldDeadline) { + probeCtx, probeCancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + contenderRelease, contenderErr := acquireDriverInstallLockContext(probeCtx, early, "example") + probeCancel() + if contenderErr == nil { + contenderRelease() + time.Sleep(10 * time.Millisecond) + continue + } + if !errors.Is(contenderErr, context.DeadlineExceeded) { + t.Fatalf("could not observe earlier driver lock acquisition: %v", contenderErr) + } + earlyLockObserved = true + break + } + if !earlyLockObserved { + t.Fatal("EnsurePackage did not acquire the earlier root lock before waiting on the later root") } cancel() select { From ff3126c84a33e27e2fd2660c85959bb40c16bc18 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 01:41:09 +0000 Subject: [PATCH 052/115] test: observe partial install lock acquisition --- config/ensure_package_test.go | 40 ++++++++++++++++------------------- config/package_archive.go | 16 +++++++++++++- 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/config/ensure_package_test.go b/config/ensure_package_test.go index 8f50be5b..c91b84ae 100644 --- a/config/ensure_package_test.go +++ b/config/ensure_package_test.go @@ -291,36 +291,32 @@ func TestEnsurePackageCancellationReleasesPartiallyAcquiredLocks(t *testing.T) { }() ctx, cancel := context.WithCancel(context.Background()) defer cancel() - started := make(chan struct{}) + earlyLockAcquired := make(chan struct{}, 1) done := make(chan error, 1) go func() { - close(started) - _, err := EnsurePackage(ctx, cfg, "example", ExpectedPackageMetadata{ID: "example"}, InstallOptions{}, EnsurePackageCallbacks{ + _, err := ensurePackageWithLockObserver(ctx, cfg, "example", ExpectedPackageMetadata{ID: "example"}, InstallOptions{}, EnsurePackageCallbacks{ CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, Archive: func() (*os.File, error) { return nil, errors.New("provider must not run") }, + }, func(root string) { + if root == early { + earlyLockAcquired <- struct{}{} + } }) done <- err }() - <-started - lockHeldDeadline := time.Now().Add(3 * time.Second) - earlyLockObserved := false - for time.Now().Before(lockHeldDeadline) { - probeCtx, probeCancel := context.WithTimeout(context.Background(), 25*time.Millisecond) - contenderRelease, contenderErr := acquireDriverInstallLockContext(probeCtx, early, "example") - probeCancel() - if contenderErr == nil { - contenderRelease() - time.Sleep(10 * time.Millisecond) - continue - } - if !errors.Is(contenderErr, context.DeadlineExceeded) { - t.Fatalf("could not observe earlier driver lock acquisition: %v", contenderErr) - } - earlyLockObserved = true - break + 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 !earlyLockObserved { - t.Fatal("EnsurePackage did not acquire the earlier root lock before waiting on the later root") + if !errors.Is(contenderErr, context.DeadlineExceeded) { + t.Fatalf("earlier root lock was not held after the acquisition observer signal: %v", contenderErr) } cancel() select { diff --git a/config/package_archive.go b/config/package_archive.go index dcb2e1bc..4dafc1bc 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -377,6 +377,13 @@ type EnsurePackageResult struct { // EnsurePackage, UninstallDriver, or another API that reacquires a lock for // the same driver, because that would deadlock. func EnsurePackage(ctx context.Context, cfg Config, runtimeID string, expected ExpectedPackageMetadata, installOptions InstallOptions, callbacks EnsurePackageCallbacks) (EnsurePackageResult, error) { + return ensurePackageWithLockObserver(ctx, cfg, runtimeID, expected, installOptions, callbacks, nil) +} + +// ensurePackageWithLockObserver is the internal lock-acquisition seam used by +// tests to synchronize with a partially acquired ConfigEnv lock set. The +// observer runs synchronously while the acquired root lock remains held. +func ensurePackageWithLockObserver(ctx context.Context, cfg Config, runtimeID string, expected ExpectedPackageMetadata, installOptions InstallOptions, callbacks EnsurePackageCallbacks, lockObserver func(root string)) (EnsurePackageResult, error) { var result EnsurePackageResult if ctx == nil { return result, errors.New("package ensure context is nil") @@ -401,7 +408,7 @@ func EnsurePackage(ctx context.Context, cfg Config, runtimeID string, expected E if err := ctx.Err(); err != nil { return result, err } - releaseLocks, err := acquireDriverInstallLocks(ctx, precedenceRoots, runtimeID) + releaseLocks, err := acquireDriverInstallLocksWithObserver(ctx, precedenceRoots, runtimeID, lockObserver) if err != nil { return result, fmt.Errorf("could not lock driver installation: %w", err) } @@ -556,6 +563,10 @@ func absoluteCleanLocation(location string) (string, error) { } 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) slices.Sort(lockRoots) releases := make([]func(), 0, len(lockRoots)) @@ -578,6 +589,9 @@ func acquireDriverInstallLocks(ctx context.Context, roots []string, runtimeID st return nil, err } releases = append(releases, release) + if observer != nil { + observer(root) + } } return releaseAll, nil } From 326922dbfcdd03a4753bed43f5a852a6bf9c7be4 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 02:03:11 +0000 Subject: [PATCH 053/115] fix: recheck package after unlocked retrieval --- config/ensure_package_test.go | 359 +++++++++++++++++++++++----------- config/package_archive.go | 153 ++++++++++----- 2 files changed, 342 insertions(+), 170 deletions(-) diff --git a/config/ensure_package_test.go b/config/ensure_package_test.go index c91b84ae..346541f2 100644 --- a/config/ensure_package_test.go +++ b/config/ensure_package_test.go @@ -46,10 +46,10 @@ func installEnsureFixture(t *testing.T, cfg Config, version, source string, arch return info } -func packageFileProvider(t *testing.T, data []byte) (*os.File, func() (*os.File, error)) { +func packageFileProvider(t *testing.T, data []byte) (*os.File, func(context.Context) (*os.File, error)) { t.Helper() file := writeInstallArchive(t, data, "ensure-candidate") - return file, func() (*os.File, error) { return file, nil } + return file, func(context.Context) (*os.File, error) { return file, nil } } func expectedEnsurePackage(id, version, source string, archive []byte) ExpectedPackageMetadata { @@ -64,12 +64,14 @@ func TestEnsurePackageSkipsWithoutRequestingArchive(t *testing.T) { 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), InstallOptions{}, EnsurePackageCallbacks{ CurrentMatches: func(current *DriverInfo) (bool, error) { + currentMatchesCalls++ return current != nil && sameDriverRegistration(initial, *current), nil }, - Archive: func() (*os.File, error) { + Archive: func(context.Context) (*os.File, error) { providerCalls++ return nil, errors.New("archive provider must not run for a skip") }, @@ -81,8 +83,8 @@ func TestEnsurePackageSkipsWithoutRequestingArchive(t *testing.T) { if err != nil { t.Fatal(err) } - if !result.Skipped || providerCalls != 0 { - t.Fatalf("EnsurePackage result skipped=%t provider calls=%d, want skip and zero calls", result.Skipped, providerCalls) + if !result.Skipped || providerCalls != 0 || currentMatchesCalls != 1 { + t.Fatalf("EnsurePackage result skipped=%t provider calls=%d CurrentMatches calls=%d, want skip, zero provider 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) @@ -102,9 +104,9 @@ func TestEnsurePackageInstallsExactProvidedArchiveAndLeavesItOpen(t *testing.T) providerCalls := 0 result, err := EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "new-source", newArchive), InstallOptions{}, EnsurePackageCallbacks{ CurrentMatches: func(current *DriverInfo) (bool, error) { return false, nil }, - Archive: func() (*os.File, error) { + Archive: func(ctx context.Context) (*os.File, error) { providerCalls++ - return provider() + return provider(ctx) }, ValidateResult: func(result EnsurePackageResult) error { if result.Skipped || result.Current == nil || result.Previous == nil || result.Installed == nil || result.Manifest == nil { @@ -149,7 +151,7 @@ func TestEnsurePackageErrorsBeforeMutationAndReportsPostInstallValidationFailure providerCalls := 0 _, err = EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "new-source", newArchive), InstallOptions{}, EnsurePackageCallbacks{ CurrentMatches: func(*DriverInfo) (bool, error) { return false, callbackErr }, - Archive: func() (*os.File, error) { + Archive: func(context.Context) (*os.File, error) { providerCalls++ return writeInstallArchive(t, newArchive, "unused"), nil }, @@ -159,12 +161,12 @@ func TestEnsurePackageErrorsBeforeMutationAndReportsPostInstallValidationFailure } providerErr := errors.New("provider failed") - _, err = EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "new-source", newArchive), InstallOptions{}, EnsurePackageCallbacks{ + providerResult, err := EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "new-source", newArchive), InstallOptions{}, EnsurePackageCallbacks{ CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, - Archive: func() (*os.File, error) { return nil, providerErr }, + Archive: func(context.Context) (*os.File, error) { return nil, providerErr }, }) - if !errors.Is(err, providerErr) { - t.Fatalf("provider error = %v, want %v", err, 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) { @@ -233,7 +235,7 @@ func TestEnsurePackageRechecksRegistrationAfterAcquiringDriverLock(t *testing.T) CurrentMatches: func(current *DriverInfo) (bool, error) { return current != nil && current.Version.String() == "1.0.0", nil }, - Archive: func() (*os.File, error) { + Archive: func(context.Context) (*os.File, error) { providerCalls++ return nil, errors.New("provider must not run") }, @@ -244,7 +246,6 @@ func TestEnsurePackageRechecksRegistrationAfterAcquiringDriverLock(t *testing.T) }{result, err} }() <-started - time.Sleep(120 * time.Millisecond) 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 { @@ -296,7 +297,7 @@ func TestEnsurePackageCancellationReleasesPartiallyAcquiredLocks(t *testing.T) { go func() { _, err := ensurePackageWithLockObserver(ctx, cfg, "example", ExpectedPackageMetadata{ID: "example"}, InstallOptions{}, EnsurePackageCallbacks{ CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, - Archive: func() (*os.File, error) { return nil, errors.New("provider must not run") }, + Archive: func(context.Context) (*os.File, error) { return nil, errors.New("provider must not run") }, }, func(root string) { if root == early { earlyLockAcquired <- struct{}{} @@ -336,78 +337,209 @@ func TestEnsurePackageCancellationReleasesPartiallyAcquiredLocks(t *testing.T) { releaseEarly() } -func TestEnsurePackageCancellationWaitsForContextlessProviderAndInstall(t *testing.T) { +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), InstallOptions{}, EnsurePackageCallbacks{ + CurrentMatches: func(*DriverInfo) (bool, error) { + currentMatchesCalls++ + return false, nil + }, + Archive: func(context.Context) (*os.File, error) { + close(providerStarted) + <-releaseProvider + return archiveFile, nil + }, + }, 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), InstallOptions{}, EnsurePackageCallbacks{ + CurrentMatches: func(*DriverInfo) (bool, error) { + currentMatchesCalls++ + return false, nil + }, + Archive: func(context.Context) (*os.File, error) { return archiveFile, nil }, + }, 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")) - installEnsureFixture(t, cfg, "1.0.0", "old", oldArchive) + initial := installEnsureFixture(t, cfg, "1.0.0", "old", oldArchive) newArchive := makeInstallArchive(t, "example", "2.0.0", "new.so", []byte("new library")) - providerArchive := writeInstallArchive(t, newArchive, "cancelled-provider") ctx, cancel := context.WithCancel(context.Background()) defer cancel() providerStarted := make(chan struct{}) - releaseProvider := make(chan struct{}) - var releaseOnce sync.Once - unblockProvider := func() { releaseOnce.Do(func() { close(releaseProvider) }) } - defer unblockProvider() - done := make(chan error, 1) + done := make(chan struct { + result EnsurePackageResult + err error + }, 1) go func() { - _, err := EnsurePackage(ctx, cfg, "example", expectedEnsurePackage("example", "2.0.0", "new", newArchive), InstallOptions{}, EnsurePackageCallbacks{ + result, err := EnsurePackage(ctx, cfg, "example", expectedEnsurePackage("example", "2.0.0", "new", newArchive), InstallOptions{}, EnsurePackageCallbacks{ CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, - Archive: func() (*os.File, error) { + Archive: func(providerCtx context.Context) (*os.File, error) { close(providerStarted) - <-releaseProvider - return providerArchive, nil + <-providerCtx.Done() + return nil, providerCtx.Err() }, }) - done <- 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") } - cancel() - select { - case err := <-done: - t.Fatalf("EnsurePackage returned before contextless provider finished: %v", err) - case <-time.After(100 * time.Millisecond): - } - lockCtx, cancelLock := context.WithTimeout(context.Background(), 100*time.Millisecond) - lockRelease, lockErr := acquireDriverInstallLockContext(lockCtx, root, "example") - cancelLock() - if lockRelease != nil { - lockRelease() - } - if !errors.Is(lockErr, context.DeadlineExceeded) { - t.Fatalf("driver lock was not held while provider was blocked: %v", lockErr) - } - if _, err := os.Stat(filepath.Join(root, "example.toml")); err != nil { - t.Fatalf("provider cancellation changed the old registration: %v", err) + release, err := acquireDriverInstallLockContext(context.Background(), root, "example") + if err != nil { + t.Fatalf("provider ran while retaining the driver lock: %v", err) } - unblockProvider() + release() + cancel() + var cancelledResult EnsurePackageResult select { - case err := <-done: - if !errors.Is(err, context.Canceled) { - t.Fatalf("provider cancellation error = %v", err) + 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 returned") + 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) } - release, err := acquireDriverInstallLock(root, "example") + 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() - if _, err := providerArchive.Stat(); err != nil { - t.Fatalf("EnsurePackage closed caller-owned archive: %v", err) - } - _ = providerArchive.Close() }) t.Run("install", func(t *testing.T) { @@ -437,7 +569,7 @@ func TestEnsurePackageCancellationWaitsForContextlessProviderAndInstall(t *testi }, }, EnsurePackageCallbacks{ CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, - Archive: func() (*os.File, error) { return archiveFile, nil }, + Archive: func(context.Context) (*os.File, error) { return archiveFile, nil }, }) done <- struct { result EnsurePackageResult @@ -450,11 +582,6 @@ func TestEnsurePackageCancellationWaitsForContextlessProviderAndInstall(t *testi t.Fatal("EnsurePackage did not enter install verification") } cancel() - select { - case result := <-done: - t.Fatalf("EnsurePackage returned before contextless install finished: %#v", result) - case <-time.After(100 * time.Millisecond): - } lockCtx, cancelLock := context.WithTimeout(context.Background(), 100*time.Millisecond) lockRelease, lockErr := acquireDriverInstallLockContext(lockCtx, root, "example") cancelLock() @@ -511,7 +638,7 @@ func TestEnsurePackageUsesConfigEnvPrecedenceAndExplicitlyRejectsMissingRoots(t CurrentMatches: func(current *DriverInfo) (bool, error) { return current != nil && current.FilePath == primary && current.Version.String() == "1.0.0", nil }, - Archive: func() (*os.File, error) { + Archive: func(context.Context) (*os.File, error) { providerCalls++ return nil, errors.New("provider must not run") }, @@ -529,7 +656,7 @@ func TestEnsurePackageUsesConfigEnvPrecedenceAndExplicitlyRejectsMissingRoots(t } } -func TestEnsurePackageLocksAgainstConcurrentUninstall(t *testing.T) { +func TestEnsurePackageArchiveProviderDoesNotBlockConcurrentUninstall(t *testing.T) { root := t.TempDir() cfg := Config{Level: ConfigEnv, Location: root} oldArchive := makeInstallArchive(t, "example", "1.0.0", "old.so", []byte("old library")) @@ -548,7 +675,7 @@ func TestEnsurePackageLocksAgainstConcurrentUninstall(t *testing.T) { go func() { result, err := EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "new", newArchive), InstallOptions{}, EnsurePackageCallbacks{ CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, - Archive: func() (*os.File, error) { + Archive: func(context.Context) (*os.File, error) { close(providerStarted) <-releaseProvider return archiveFile, nil @@ -564,35 +691,18 @@ func TestEnsurePackageLocksAgainstConcurrentUninstall(t *testing.T) { case <-time.After(5 * time.Second): t.Fatal("EnsurePackage did not reach archive provider") } - uninstallStarted := make(chan struct{}) - uninstallDone := make(chan error, 1) - go func() { - close(uninstallStarted) - uninstallDone <- UninstallDriver(cfg, oldInfo) - }() - <-uninstallStarted - select { - case err := <-uninstallDone: - t.Fatalf("uninstall escaped EnsurePackage driver lock: %v", err) - case <-time.After(150 * time.Millisecond): + if err := UninstallDriver(cfg, oldInfo); err != nil { + t.Fatalf("uninstall did not complete while archive retrieval was blocked: %v", err) } unblockProvider() select { case result := <-ensureDone: - if result.err != nil || result.result.Installed == nil || result.result.Installed.Version.String() != "2.0.0" { + 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 archive provider returned") } - select { - case err := <-uninstallDone: - if err == nil || !strings.Contains(err.Error(), "registration changed before uninstall") { - t.Fatalf("uninstall result = %v, want stale registration rejection", err) - } - case <-time.After(5 * time.Second): - t.Fatal("uninstall did not resume after EnsurePackage released its lock") - } 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) @@ -603,70 +713,87 @@ func TestEnsurePackageLocksAgainstConcurrentUninstall(t *testing.T) { _ = archiveFile.Close() } -func TestEnsurePackageSerializesConcurrentInstall(t *testing.T) { +func TestEnsurePackageRechecksConcurrentInstallAfterArchiveRetrieval(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-provider") - concurrentArchive := makeInstallArchive(t, "example", "3.0.0", "concurrent.so", []byte("concurrent library")) - concurrentFile := writeInstallArchive(t, concurrentArchive, "concurrent-install") + concurrentFile := writeInstallArchive(t, ensuredArchive, "concurrent-install") providerStarted := make(chan struct{}) releaseProvider := make(chan struct{}) var releaseOnce sync.Once unblockProvider := func() { releaseOnce.Do(func() { close(releaseProvider) }) } defer unblockProvider() - ensureDone := make(chan error, 1) + ensureDone := make(chan struct { + result EnsurePackageResult + err error + }, 1) + currentMatchesCalls := 0 go func() { - _, err := EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "ensured", ensuredArchive), InstallOptions{}, EnsurePackageCallbacks{ - CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, - Archive: func() (*os.File, error) { + result, err := EnsurePackage(context.Background(), cfg, "example", expected, InstallOptions{}, 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 + }, + Archive: func(context.Context) (*os.File, error) { close(providerStarted) <-releaseProvider return ensuredFile, nil }, }) - ensureDone <- err + ensureDone <- struct { + result EnsurePackageResult + err error + }{result, err} }() select { case <-providerStarted: case <-time.After(5 * time.Second): t.Fatal("EnsurePackage did not reach archive provider") } - installStarted := make(chan struct{}) - installDone := make(chan error, 1) - go func() { - close(installStarted) - _, err := InstallPackage(cfg, "example", concurrentFile, expectedEnsurePackage("example", "3.0.0", "concurrent", concurrentArchive), InstallOptions{}) - installDone <- err - }() - <-installStarted - select { - case err := <-installDone: - t.Fatalf("concurrent install escaped EnsurePackage driver lock: %v", err) - case <-time.After(150 * time.Millisecond): + 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 err := <-ensureDone: - if err != nil { - t.Fatalf("EnsurePackage failed: %v", err) + 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 provider returned") } - select { - case err := <-installDone: - if err != nil { - t.Fatalf("concurrent InstallPackage failed after lock release: %v", err) - } - case <-time.After(5 * time.Second): - t.Fatal("concurrent InstallPackage did not resume after EnsurePackage released its lock") + 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() != "3.0.0" { - t.Fatalf("concurrent install was not serialized after EnsurePackage: current=%#v error=%v", current, err) + 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 _, err := ensuredFile.Stat(); err != nil { + t.Fatalf("EnsurePackage closed the caller-owned archive after phase-two skip: %v", err) } _ = ensuredFile.Close() _ = concurrentFile.Close() diff --git a/config/package_archive.go b/config/package_archive.go index 4dafc1bc..e52ef5a0 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -340,24 +340,27 @@ func InstallPackage(cfg Config, runtimeID string, downloaded *os.File, expected } // EnsurePackageCallbacks contains the narrow decisions and resources needed -// to atomically reuse or install a selected package while its driver locks are -// held. CurrentMatches receives the latest registration (nil when absent). -// Archive is called at most once and only when the registration does not -// match. ValidateResult runs before the locks are released; if it fails after -// installation, the installed generation remains committed and is described -// by the returned result. All three callbacks execute 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. +// to atomically reuse or install a selected package. CurrentMatches receives +// the latest registration (nil when absent) once before and, if needed, once +// after obtaining the archive. Archive 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. Archive +// 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) - Archive func() (*os.File, error) + Archive func(context.Context) (*os.File, error) ValidateResult func(result EnsurePackageResult) error } -// EnsurePackageResult separates the registration observed before the action, -// the registration present afterwards, any registration physically replaced -// at the installation root, and an install manifest (nil on a skip). +// 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 @@ -366,23 +369,30 @@ type EnsurePackageResult struct { Manifest *Manifest } -// EnsurePackage rechecks the effective runtime registration under all -// configured driver install locks. It returns a skip only when CurrentMatches -// accepts that latest registration; otherwise it obtains one caller-owned -// archive and installs it while retaining the locks. The archive is never -// closed here. Provider and installation work are synchronous and, once -// started, complete before the locks are released even if ctx is canceled. -// CurrentMatches, Archive, and ValidateResult all 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. +// EnsurePackage checks the effective runtime registration under all +// configured driver install locks. If it does not match, EnsurePackage +// releases those locks, obtains one archive, reacquires all locks, and checks +// the latest registration again before deciding to skip or install. This +// prevents archive retrieval from blocking unrelated driver operations while +// ensuring no stale registration snapshot is used for the final decision. +// The archive is caller-owned and is never closed here, 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. Archive +// 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 archive retrieval and again against the latest registration after. +// Result.Current is the most recently inspected registration: it remains the +// phase-one snapshot on provider or phase-two lock acquisition failure. func EnsurePackage(ctx context.Context, cfg Config, runtimeID string, expected ExpectedPackageMetadata, installOptions InstallOptions, callbacks EnsurePackageCallbacks) (EnsurePackageResult, error) { return ensurePackageWithLockObserver(ctx, cfg, runtimeID, expected, installOptions, callbacks, nil) } // ensurePackageWithLockObserver is the internal lock-acquisition seam used by -// tests to synchronize with a partially acquired ConfigEnv lock set. The -// observer runs synchronously while the acquired root lock remains held. +// 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, installOptions InstallOptions, callbacks EnsurePackageCallbacks, lockObserver func(root string)) (EnsurePackageResult, error) { var result EnsurePackageResult if ctx == nil { @@ -413,45 +423,22 @@ func ensurePackageWithLockObserver(ctx context.Context, cfg Config, runtimeID st return result, fmt.Errorf("could not lock driver installation: %w", err) } defer releaseLocks() - if err := ctx.Err(); err != nil { - return result, err - } - - current, err := loadEffectiveInstalledDriver(cfg, precedenceRoots, runtimeID) + current, matches, err := inspectEnsureCurrent(ctx, cfg, precedenceRoots, runtimeID, callbacks.CurrentMatches) + result.Current = cloneDriverInfo(current) if err != nil { - return result, fmt.Errorf("could not inspect current driver registration: %w", err) - } - if err := ctx.Err(); err != nil { return result, err } - result.Current = cloneDriverInfo(current) - matches, err := callbacks.CurrentMatches(cloneDriverInfo(current)) - if err != nil { - return result, fmt.Errorf("could not compare current driver registration: %w", err) + if matches { + return finishEnsureSkip(ctx, result, current, callbacks.ValidateResult) } + releaseLocks() if err := ctx.Err(); err != nil { return result, err } - if matches { - if current == nil { - return result, errors.New("current-registration predicate accepted a missing registration") - } - result.Skipped = true - result.Installed = cloneDriverInfo(current) - if callbacks.ValidateResult != nil { - if err := callbacks.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 - } if callbacks.Archive == nil { return result, errors.New("package archive provider is nil") } - archive, providerErr := callbacks.Archive() + archive, providerErr := callbacks.Archive(ctx) if err := ctx.Err(); err != nil { return result, err } @@ -464,6 +451,26 @@ func ensurePackageWithLockObserver(ctx context.Context, cfg Config, runtimeID st if err := ctx.Err(); 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 := installPackageLocked(cfg, runtimeID, primary, archive, expected, installOptions, CreateManifest, cleanupOwnedPackageDirectories) if err != nil { return result, err @@ -492,6 +499,44 @@ func ensurePackageWithLockObserver(ctx context.Context, cfg Config, runtimeID st 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 { From a99d42dcb0f63fa79a4483579a5b888fdf932ec5 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 02:24:26 +0000 Subject: [PATCH 054/115] feat: fingerprint package runtime registrations --- config/package_archive.go | 316 ++++++++++++++++++++---- config/registration_fingerprint_test.go | 311 +++++++++++++++++++++++ 2 files changed, 580 insertions(+), 47 deletions(-) create mode 100644 config/registration_fingerprint_test.go diff --git a/config/package_archive.go b/config/package_archive.go index e52ef5a0..cadc1b2e 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -25,9 +25,9 @@ import ( "fmt" "io" "io/fs" + "maps" "os" "path/filepath" - "reflect" "runtime" "slices" "strings" @@ -38,10 +38,12 @@ import ( ) const ( - installReceiptName = "dbc-install-receipt.json" - legacyPackageManifestName = "MANIFEST" - packageV2MetadataName = "dbc-package.toml" - maxPackageMetadataSize = 1 << 20 + installReceiptName = "dbc-install-receipt.json" + legacyPackageManifestName = "MANIFEST" + packageV2MetadataName = "dbc-package.toml" + maxPackageMetadataSize = 1 << 20 + registrationFingerprintAlgorithm = "sha256" + registrationFingerprintVersion = 1 ) // ExpectedPackageMetadata describes the resolution that selected an archive. @@ -61,15 +63,18 @@ type ExpectedPackageMetadata struct { // 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"` + 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 @@ -82,8 +87,34 @@ type InstallOptions struct { // archive. VerifiedLibraryHash is empty when the package has no separate // driver library file. type PackageValidation struct { - VerifiedLibraryHash string - Registration DriverInfo + VerifiedLibraryHash string + Registration DriverInfo + RegistrationFingerprintAlgorithm string + RegistrationFingerprintVersion int + RegistrationFingerprint string + registrationSharedIdentity registrationSharedIdentity +} + +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"` } type packageManifest struct { @@ -711,7 +742,7 @@ func ValidatePackage(runtimeID string, downloaded *os.File, expected ExpectedPac }() finalDir := filepath.Join(workDir, "installed") - manifest, payloadDir, err := stagePackageArchive(workDir, runtimeID, finalDir, downloaded, expected, options.Verify, workDir) + manifest, payloadDir, sharedIdentity, err := stagePackageArchive(workDir, runtimeID, finalDir, downloaded, expected, options.Verify, workDir) if err != nil { return PackageValidation{}, err } @@ -719,25 +750,92 @@ func ValidatePackage(runtimeID string, downloaded *os.File, expected ExpectedPac if !ok { return PackageValidation{}, errors.New("could not read validated package receipt") } - return PackageValidation{VerifiedLibraryHash: receipt.InstalledLibraryHash, Registration: manifest.DriverInfo}, nil + return PackageValidation{ + VerifiedLibraryHash: receipt.InstalledLibraryHash, Registration: manifest.DriverInfo, + RegistrationFingerprintAlgorithm: receipt.RegistrationFingerprintAlgorithm, + RegistrationFingerprintVersion: receipt.RegistrationFingerprintVersion, + RegistrationFingerprint: receipt.RegistrationFingerprint, + registrationSharedIdentity: sharedIdentity, + }, 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. +// 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) || - !reflect.DeepEqual(current.AdbcInfo, candidate.AdbcInfo) { + !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 +} + +// PackageValidationMatchesRuntimeRegistration compares a current registration +// with evidence returned by ValidatePackage. 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 current.Version == nil || candidate.Version == nil { - return current.Version == nil && candidate.Version == nil + if !registrationSharedIdentityValid(candidate.registrationSharedIdentity) || + !sharedIdentityMatchesRegistration(candidate.registrationSharedIdentity, current, platform) { + return false } - return current.Version.String() == candidate.Version.String() + 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) { @@ -811,7 +909,7 @@ func installPackageLocked(cfg Config, runtimeID, loc string, downloaded *os.File } finalDir := reservedDir - manifest, payloadDir, err := stagePackageArchive(loc, runtimeID, finalDir, downloaded, expected, options.Verify, workDir) + manifest, payloadDir, _, err := stagePackageArchive(loc, runtimeID, finalDir, downloaded, expected, options.Verify, workDir) if err != nil { return Manifest{}, previous, err } @@ -971,9 +1069,117 @@ func uninstallDriverWithInstallLock(cfg Config, info DriverInfo, uninstall func( } func sameDriverRegistration(first, second DriverInfo) bool { - first.FilePath = normalizedRegistrationPath(first.FilePath) - second.FilePath = normalizedRegistrationPath(second.FilePath) - return reflect.DeepEqual(first, second) + 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) +} + +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 { @@ -1540,7 +1746,7 @@ func installPackageArchive(cfg Config, targetName, runtimeID string, downloaded 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) + manifest, payloadDir, _, err := stagePackageArchive(loc, runtimeID, finalDir, downloaded, expected, nil, workDir) if err != nil { return result, err } @@ -1550,38 +1756,39 @@ func installPackageArchive(cfg Config, targetName, runtimeID string, downloaded return manifest, nil } -func stagePackageArchive(location, runtimeID, finalDir string, downloaded *os.File, expected ExpectedPackageMetadata, verify func(string, Manifest) error, workDir string) (Manifest, string, error) { +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, "", fmt.Errorf("could not snapshot package archive: %w", err) + return result, "", sharedIdentity, fmt.Errorf("could not snapshot package archive: %w", err) } if expected.ArchiveHash != "" && expected.ArchiveHash != archiveHash { - return result, "", fmt.Errorf("package archive hash mismatch: got %s, expected %s", archiveHash, expected.ArchiveHash) + return result, "", sharedIdentity, fmt.Errorf("package archive hash mismatch: got %s, expected %s", archiveHash, expected.ArchiveHash) } if expected.ArchiveSize > 0 && expected.ArchiveSize != archiveSize { - return result, "", fmt.Errorf("package archive size mismatch: got %d, expected %d", archiveSize, expected.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, "", fmt.Errorf("could not create private package staging directory: %w", err) + return result, "", sharedIdentity, fmt.Errorf("could not create private package staging directory: %w", err) } manifest, meta, files, err := extractPackageArchive(archivePath, payloadDir) if err != nil { - return result, "", fmt.Errorf("failed to extract package archive: %w", err) + return result, "", sharedIdentity, fmt.Errorf("failed to extract package archive: %w", err) } if expected.ID != "" && meta.v2 && expected.ID != meta.id { - return result, "", fmt.Errorf("package id mismatch: archive declares %q, expected %q", meta.id, expected.ID) + return result, "", sharedIdentity, fmt.Errorf("package id mismatch: archive declares %q, expected %q", meta.id, expected.ID) } if expected.Version != "" && manifest.Version.String() != expected.Version { - return result, "", fmt.Errorf("package version mismatch: archive declares %q, expected %q", manifest.Version, expected.Version) + return result, "", sharedIdentity, fmt.Errorf("package version mismatch: archive declares %q, expected %q", manifest.Version, expected.Version) } platform := expected.Platform if meta.v2 { if platform != "" && meta.platform != platform { - return result, "", fmt.Errorf("package platform mismatch: archive declares %q, expected %q", meta.platform, platform) + return result, "", sharedIdentity, fmt.Errorf("package platform mismatch: archive declares %q, expected %q", meta.platform, platform) } platform = meta.platform } @@ -1589,10 +1796,18 @@ func stagePackageArchive(location, runtimeID, finalDir string, downloaded *os.Fi platform = PlatformTuple() } if err := validatePackageFileReferences(manifest, files, meta.v2); err != nil { - return result, "", err + return result, "", sharedIdentity, err } if _, exists := files[strings.ToLower(installReceiptName)]; exists { - return result, "", fmt.Errorf("package archive uses reserved file name %q", installReceiptName) + 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 @@ -1605,11 +1820,11 @@ func stagePackageArchive(location, runtimeID, finalDir string, downloaded *os.Fi manifest.DriverInfo.Driver.Shared.Set(platform, finalDir) } if err := os.Chmod(payloadDir, 0o755); err != nil { - return result, "", fmt.Errorf("could not prepare package directory for publication: %w", err) + 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, "", fmt.Errorf("package verification failed: %w", err) + return result, "", sharedIdentity, fmt.Errorf("package verification failed: %w", err) } } installedHash := "" @@ -1617,26 +1832,33 @@ func stagePackageArchive(location, runtimeID, finalDir string, downloaded *os.Fi libraryPath := filepath.Join(payloadDir, installedLibrary) libraryInfo, err := os.Lstat(libraryPath) if err != nil { - return result, "", fmt.Errorf("could not inspect verified driver file: %w", err) + return result, "", sharedIdentity, fmt.Errorf("could not inspect verified driver file: %w", err) } if !libraryInfo.Mode().IsRegular() { - return result, "", errors.New("verified driver file is not a regular file") + return result, "", sharedIdentity, errors.New("verified driver file is not a regular file") } installedHash, err = hashFile(libraryPath) if err != nil { - return result, "", fmt.Errorf("could not hash verified driver file: %w", err) + 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, + InstalledLibraryHash: installedHash, + RegistrationFingerprintAlgorithm: registrationFingerprintAlgorithm, + RegistrationFingerprintVersion: registrationFingerprintVersion, + RegistrationFingerprint: registrationFingerprint, } if err := writeInstallReceipt(payloadDir, receipt); err != nil { - return result, "", fmt.Errorf("could not write installation receipt: %w", err) + return result, "", sharedIdentity, fmt.Errorf("could not write installation receipt: %w", err) } - return manifest, payloadDir, nil + return manifest, payloadDir, sharedIdentity, nil } func hasRuntimeSharedPath(shared driverMap) bool { diff --git a/config/registration_fingerprint_test.go b/config/registration_fingerprint_test.go new file mode 100644 index 00000000..90589ff2 --- /dev/null +++ b/config/registration_fingerprint_test.go @@ -0,0 +1,311 @@ +// 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 := ValidatePackage("example", validationFile, expected, InstallOptions{}) + if err != nil { + t.Fatal(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") + } +} From 32c1fb086cf2087a1f97630905cb807f8b18b40a Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 03:04:03 +0000 Subject: [PATCH 055/115] fix: verify runtime state during sync execution --- cmd/dbc/sync.go | 447 ++++++++++++++++++++++++++----------------- cmd/dbc/sync_test.go | 324 ++++++++++++++++++++++++++++--- 2 files changed, 564 insertions(+), 207 deletions(-) diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 8b60bba8..1229999f 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -182,7 +182,7 @@ type syncModel struct { type syncWorkerHooks struct { duringPrepare func(context.Context, int, installItem) error beforeCandidateSave func(context.Context) error - installPackage func(context.Context, config.Config, string, *os.File, config.ExpectedPackageMetadata, config.InstallOptions) (config.Manifest, error) + ensurePackage func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.InstallOptions, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) } type syncWorker struct { @@ -342,8 +342,8 @@ type installItem struct { LockEntry *lockInfo Archive *os.File Expected config.ExpectedPackageMetadata + Validation *config.PackageValidation AlreadyInstalled *config.DriverInfo - RemovedDriver *config.DriverInfo } type preparedSyncMsg struct { @@ -522,58 +522,6 @@ type alreadyInstalledDrvMsg struct { item installItem } -func (s syncModel) installPreparedPackage(ctx context.Context, item installItem) (installedDrvMsg, error) { - if err := ctx.Err(); err != nil { - return installedDrvMsg{}, err - } - if item.Archive == nil { - return installedDrvMsg{}, errors.New("prepared package archive is missing") - } - if _, err := item.Archive.Seek(0, io.SeekStart); err != nil { - return installedDrvMsg{}, fmt.Errorf("failed to rewind prepared driver archive: %w", err) - } - var verify func(string, config.Manifest) error - if !s.NoVerify { - verify = func(stagingDir string, manifest config.Manifest) error { - return dbc.VerifyPackageSignature(stagingDir, manifest) - } - } - install := s.worker.hooks.installPackage - if install == nil { - // config.InstallPackage is contextless. Keep this call synchronous so - // cancellation cannot release the project lock or archive prematurely. - install = func(_ context.Context, cfg config.Config, driver string, archive *os.File, expected config.ExpectedPackageMetadata, options config.InstallOptions) (config.Manifest, error) { - return config.InstallPackage(cfg, driver, archive, expected, options) - } - } - manifest, err := install(ctx, s.cfg, item.Driver.Path, item.Archive, item.Expected, config.InstallOptions{Verify: verify}) - if err := ctx.Err(); err != nil { - return installedDrvMsg{}, err - } - if err != nil { - if isPackageVerificationFailure(err) { - return installedDrvMsg{}, fmt.Errorf("failed to verify signature: %w", packageVerificationError(err)) - } - return installedDrvMsg{}, fmt.Errorf("failed to install driver: %w", err) - } - if err := ctx.Err(); err != nil { - return installedDrvMsg{}, err - } - checksumHash, err := checksum(manifest.DriverInfo.Driver.Shared.Get(config.PlatformTuple())) - if err != nil { - return installedDrvMsg{}, syncChecksumError{err: err} - } - if item.InstalledLibraryHash != "" && item.InstalledLibraryHash != checksumHash { - return installedDrvMsg{}, syncChecksumError{err: errors.New("installed library checksum does not match validated package")} - } - return installedDrvMsg{ - removed: item.RemovedDriver, - info: manifest.DriverInfo, - postInstall: manifest.PostInstall.Messages, - item: item, - }, nil -} - func (s syncModel) writeLockFile() error { s.locked.Version = lockFileVersion return s.persistCandidateLock(s.locked) @@ -656,145 +604,56 @@ func (s syncModel) prepareInstallItems(ctx context.Context, items []installItem) var sameVersionInstalled *config.DriverInfo if s.cfg.Exists { if installed, ok := s.cfg.Drivers[item.Driver.Path]; ok { - if item.Package.Version.Equal(installed.Version) { + if installed.Version != nil && item.Package.Version.String() == installed.Version.String() { installedCopy := installed sameVersionInstalled = &installedCopy - expected, hasMetadata, err := expectedRegistryPackageMetadata(item.Package) + expected, _, err := expectedRegistryPackageMetadata(item.Package) if err != nil { return prepared, err } - receipt, managed, receiptPresent, valid, err := config.InspectDriverInstallReceipt(s.cfg, installed) + item.Expected = expected + matches, err := s.itemCurrentMatches(item, &installedCopy) if err != nil { - return prepared, fmt.Errorf("failed to resolve installed driver receipt location: %w", err) + return prepared, err } - libraryPath := installed.Driver.Shared.Get(config.PlatformTuple()) - if managed { - if !receiptPresent && item.LockEntry != nil && item.LockEntry.Legacy != nil { - // A v1 lock's library digest is its proof. Preserve that - // compatibility only for genuinely receipt-less generations. - libraryHash, checksumErr := checksum(libraryPath) - if checksumErr == nil && item.Checksum != "" && libraryHash == item.Checksum { - item.InstalledLibraryHash = libraryHash - item.AlreadyInstalled = &installedCopy - } - } else if hasMetadata && valid && installReceiptMatchesExpected(receipt, expected) && config.VerifyInstallReceiptLibraryIntegrity(libraryPath, receipt) { - markAlreadyInstalled(item, installed, receipt.InstalledLibraryHash) - } - } else { - // Manifest-only packages may reference an external library and - // therefore have no managed receipt beside that path. A v1 - // library checksum may tentatively prove continuity, but a v2 - // lock without runtime proof cannot authorize a same-version skip. - libraryHash, checksumErr := checksum(libraryPath) - if checksumErr == nil && item.Checksum != "" && libraryHash == item.Checksum { - item.InstalledLibraryHash = libraryHash - item.AlreadyInstalled = &installedCopy + if matches { + libraryHash, hashErr := checksum(installed.Driver.Shared.Get(config.PlatformTuple())) + if hashErr != nil { + return prepared, fmt.Errorf("failed to checksum installed driver: %w", hashErr) } + markAlreadyInstalled(item, installed, libraryHash) } - } else { - installedCopy := installed - item.RemovedDriver = &installedCopy } } } needsArchive := item.AlreadyInstalled == nil || !canReuseLockedEntry(*item) if needsArchive { - // Determine whether the registry supplied source metadata before - // measured values are copied into Package below. - expected, hasMetadata, err := expectedRegistryPackageMetadata(item.Package) - if err != nil { + if err := s.downloadAndValidateItem(ctx, item); err != nil { return prepared, err } - // downloadPkg is contextless; wait for it to return while retaining - // the project lock, then observe cancellation and clean up its archive. - archive, err := s.downloadPkg(item.Package) - if archive != nil { - item.Archive = archive - } - if ctx.Err() != nil { - return prepared, ctx.Err() - } - if err != nil { - return prepared, fmt.Errorf("failed to download driver: %w", err) - } - if err := snapshotDownloadedArchive(item, archive); err != nil { - return prepared, fmt.Errorf("failed to snapshot downloaded driver archive: %w", err) - } - if !hasMetadata { - if _, err := archive.Seek(0, io.SeekStart); err != nil { - return prepared, fmt.Errorf("failed to rewind downloaded driver archive: %w", err) - } - packageManifest, inspectErr := config.InspectPackageMetadata(archive) - if inspectErr != nil { - return prepared, inspectErr - } - if packageManifest.PackageVersion == 2 { - return prepared, errors.New("registry package v2 requires archive hash and size metadata") - } - } - item.Package.ArtifactHash = item.ArchiveHash - item.Package.ArtifactSize = cloneInt64(&item.ArchiveSize) - expected.ArchiveHash = item.ArchiveHash - expected.ArchiveSize = item.ArchiveSize - item.Expected = expected - if _, err := archive.Seek(0, io.SeekStart); err != nil { - return prepared, fmt.Errorf("failed to rewind downloaded driver archive: %w", err) - } - var verify func(string, config.Manifest) error - if !s.NoVerify { - verify = func(stagingDir string, manifest config.Manifest) error { - return dbc.VerifyPackageSignature(stagingDir, manifest) - } - } - validation, err := config.ValidatePackage(item.Driver.Path, archive, expected, config.InstallOptions{Verify: verify}) - if err != nil { - if isPackageVerificationFailure(err) { - return prepared, fmt.Errorf("failed to verify signature: %w", packageVerificationError(err)) - } - return prepared, fmt.Errorf("failed to validate driver package: %w", err) - } - verifiedLibraryHash := strings.TrimPrefix(validation.VerifiedLibraryHash, "sha256:") - item.ValidatedLibraryHash = verifiedLibraryHash - legacyExternalProof := item.LockEntry != nil && item.LockEntry.Legacy != nil && - samePlatformTarget(item.LockEntry.Legacy.Platform, config.PlatformTuple()) && verifiedLibraryHash == "" - if legacyExternalProof { - proofHash := item.LockEntry.Legacy.LibraryHash - candidateLibrary := validation.Registration.Driver.Shared.Get(config.PlatformTuple()) - if err := verifyLegacyExternalLibrary(candidateLibrary, proofHash); err != nil { - return prepared, fmt.Errorf("candidate package external library does not match the legacy lock proof: %w", err) - } - item.InstalledLibraryHash = proofHash - if sameVersionInstalled != nil && config.SameRuntimeDriverRegistration(*sameVersionInstalled, validation.Registration, config.PlatformTuple()) { - markAlreadyInstalled(item, *sameVersionInstalled, proofHash) - } else { - item.AlreadyInstalled = nil + if sameVersionInstalled != nil { + matches, err := s.itemCurrentMatches(item, sameVersionInstalled) + if err != nil { + return prepared, err } - } else { - if item.AlreadyInstalled == nil && sameVersionInstalled != nil { - receipt, managed, _, valid, err := config.InspectDriverInstallReceipt(s.cfg, *sameVersionInstalled) - if err != nil { - return prepared, fmt.Errorf("failed to resolve installed driver receipt location: %w", err) - } - libraryPath := sameVersionInstalled.Driver.Shared.Get(config.PlatformTuple()) - if managed && valid && installReceiptMatchesExpected(receipt, expected) && config.VerifyInstallReceiptLibraryIntegrity(libraryPath, receipt) { - markAlreadyInstalled(item, *sameVersionInstalled, receipt.InstalledLibraryHash) + if matches { + libraryHash, hashErr := checksum(sameVersionInstalled.Driver.Shared.Get(config.PlatformTuple())) + if hashErr != nil { + return prepared, fmt.Errorf("failed to checksum installed driver: %w", hashErr) } + markAlreadyInstalled(item, *sameVersionInstalled, libraryHash) } - if item.AlreadyInstalled != nil && sameVersionInstalled != nil && - (verifiedLibraryHash == "" || item.InstalledLibraryHash != verifiedLibraryHash) { - // A receipt or v1 library proof can tentatively identify the - // installed artifact. Once the candidate archive is validated, - // require its measured library bytes to preserve that identity. - item.AlreadyInstalled = nil - } - if item.AlreadyInstalled == nil { - item.InstalledLibraryHash = verifiedLibraryHash + } + 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, config.PlatformTuple()) { + item.InstalledLibraryHash = item.LockEntry.Legacy.LibraryHash } } } else { - // The exact locked artifact is already installed, so it can be reused - // without another download or package validation. + // The exact locked artifact is already proven by its managed receipt. expected, _, err := expectedRegistryPackageMetadata(item.Package) if err != nil { return prepared, err @@ -816,6 +675,221 @@ func (s syncModel) prepareInstallItems(ctx context.Context, items []installItem) return prepared, nil } +func (s syncModel) downloadAndValidateItem(ctx context.Context, item *installItem) error { + if err := ctx.Err(); err != nil { + return err + } + if item.Archive == nil { + // downloadPkg has no context API. The worker retains the project lock and + // checks cancellation immediately after this synchronous call returns. + archive, err := s.downloadPkg(item.Package) + if archive != nil { + item.Archive = archive + } + if ctx.Err() != nil { + return ctx.Err() + } + if err != nil { + return fmt.Errorf("failed to download driver: %w", err) + } + } + archive := item.Archive + if err := snapshotDownloadedArchive(item, archive); err != nil { + return fmt.Errorf("failed to snapshot downloaded driver archive: %w", err) + } + expected, hasMetadata, err := expectedRegistryPackageMetadata(item.Package) + if err != nil { + return err + } + if !hasMetadata { + if _, err := archive.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("failed to rewind downloaded driver archive: %w", err) + } + packageManifest, inspectErr := config.InspectPackageMetadata(archive) + if inspectErr != nil { + return inspectErr + } + if packageManifest.PackageVersion == 2 { + return errors.New("registry package v2 requires archive hash and size metadata") + } + } + item.Package.ArtifactHash = item.ArchiveHash + item.Package.ArtifactSize = cloneInt64(&item.ArchiveSize) + expected.ArchiveHash = item.ArchiveHash + expected.ArchiveSize = item.ArchiveSize + item.Expected = expected + if _, err := archive.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("failed to rewind downloaded driver archive: %w", err) + } + var verify func(string, config.Manifest) error + if !s.NoVerify { + verify = func(stagingDir string, manifest config.Manifest) error { + return dbc.VerifyPackageSignature(stagingDir, manifest) + } + } + validation, err := config.ValidatePackage(item.Driver.Path, 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 validate driver package: %w", err) + } + item.Validation = &validation + item.ValidatedLibraryHash = strings.TrimPrefix(validation.VerifiedLibraryHash, "sha256:") + if item.ValidatedLibraryHash == "" { + candidateLibrary := validation.Registration.Driver.Shared.Get(config.PlatformTuple()) + if item.LockEntry != nil && item.LockEntry.Legacy != nil && samePlatformTarget(item.LockEntry.Legacy.Platform, config.PlatformTuple()) { + 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) + } + } + } + return nil +} + +func (s syncModel) 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(s.cfg, *current) + if err != nil { + return false, fmt.Errorf("failed to resolve installed driver receipt location: %w", err) + } + libraryPath := current.Driver.Shared.Get(config.PlatformTuple()) + if managed && present { + if !valid || !installReceiptMatchesExpected(receipt, item.Expected) || + !config.VerifyInstallReceiptLibraryIntegrity(libraryPath, receipt) || + !config.InstallReceiptMatchesRuntimeRegistration(receipt, *current, config.PlatformTuple()) { + return false, nil + } + if item.Validation != nil { + if item.Validation.VerifiedLibraryHash == "" || item.Validation.VerifiedLibraryHash != receipt.InstalledLibraryHash || + !config.PackageValidationMatchesRuntimeRegistration(*current, *item.Validation, config.PlatformTuple()) { + return false, nil + } + } + return true, nil + } + if present || item.Validation == nil || item.LockEntry == nil || item.LockEntry.Legacy == nil || + !samePlatformTarget(item.LockEntry.Legacy.Platform, config.PlatformTuple()) || + !config.PackageValidationMatchesRuntimeRegistration(*current, *item.Validation, config.PlatformTuple()) { + 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(config.PlatformTuple()), proofHash); err != nil { + return false, nil + } + return true, nil +} + +func (s syncModel) ensurePreparedPackage(ctx context.Context, item *installItem) (config.EnsurePackageResult, error) { + if err := ctx.Err(); err != nil { + return config.EnsurePackageResult{}, err + } + callbacks := config.EnsurePackageCallbacks{ + CurrentMatches: func(current *config.DriverInfo) (bool, error) { + return s.itemCurrentMatches(item, current) + }, + Archive: func(ctx context.Context) (*os.File, error) { + if item.Archive == nil || item.Validation == nil { + if err := s.downloadAndValidateItem(ctx, item); err != nil { + return nil, err + } + } + if err := ctx.Err(); err != nil { + return nil, err + } + if _, err := item.Archive.Seek(0, io.SeekStart); err != nil { + return nil, fmt.Errorf("failed to rewind prepared driver archive: %w", err) + } + return item.Archive, nil + }, + ValidateResult: func(result config.EnsurePackageResult) error { + return s.validateEnsureResult(item, result) + }, + } + var verify func(string, config.Manifest) error + if !s.NoVerify { + verify = func(stagingDir string, manifest config.Manifest) error { + return dbc.VerifyPackageSignature(stagingDir, manifest) + } + } + ensure := s.worker.hooks.ensurePackage + if ensure == nil { + ensure = config.EnsurePackage + } + result, err := ensure(ctx, s.cfg, item.Driver.Path, item.Expected, config.InstallOptions{Verify: verify}, callbacks) + if err != nil && isPackageVerificationFailure(err) { + return result, fmt.Errorf("failed to verify signature: %w", packageVerificationError(err)) + } + return result, err +} + +func (s syncModel) 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 := s.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, config.PlatformTuple()) || + !config.PackageValidationMatchesRuntimeRegistration(result.Manifest.DriverInfo, *item.Validation, config.PlatformTuple()) { + 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(config.PlatformTuple()) + if item.LockEntry != nil && item.LockEntry.Legacy != nil && samePlatformTarget(item.LockEntry.Legacy.Platform, config.PlatformTuple()) { + 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(s.cfg, *result.Installed) + if err != nil { + return fmt.Errorf("failed to resolve installed driver receipt location: %w", err) + } + libraryPath := result.Installed.Driver.Shared.Get(config.PlatformTuple()) + if !managed || !present || !valid || !installReceiptMatchesExpected(receipt, item.Expected) || + receipt.InstalledLibraryHash != item.Validation.VerifiedLibraryHash || + !config.InstallReceiptMatchesRuntimeRegistration(receipt, *result.Installed, config.PlatformTuple()) || + !config.VerifyInstallReceiptLibraryIntegrity(libraryPath, receipt) { + return errors.New("installed package receipt does not match the validated package") + } + actualHash, err := checksum(libraryPath) + if err != nil { + return syncChecksumError{err: err} + } + if actualHash != item.ValidatedLibraryHash { + return syncChecksumError{err: errors.New("installed library checksum does not match validated package")} + } + return nil +} + func installReceiptMatchesExpected(receipt config.InstallReceipt, expected config.ExpectedPackageMetadata) bool { return expected.ID != "" && expected.Version != "" && expected.Platform != "" && expected.SourceType != "" && expected.SourceIdentity != "" && expected.ArchiveHash != "" && expected.ArchiveSize > 0 && @@ -982,19 +1056,12 @@ func (s syncModel) runSyncWorker(worker *syncWorker) syncWorkerResultMsg { } result.lock = prepared.lock - for _, item := range prepared.items { + for i := range prepared.items { if err := ctx.Err(); err != nil { return fail("sync_failed", err) } - if item.AlreadyInstalled != nil { - progress := alreadyInstalledDrvMsg{info: *item.AlreadyInstalled, item: item} - progress.item.Archive = nil - if !worker.send(ctx, progress) { - return fail("sync_failed", ctx.Err()) - } - continue - } - installed, err := s.installPreparedPackage(ctx, item) + item := &prepared.items[i] + ensured, err := s.ensurePreparedPackage(ctx, item) if err != nil { var checksumErr syncChecksumError if errors.As(err, &checksumErr) { @@ -1002,6 +1069,26 @@ func (s syncModel) runSyncWorker(worker *syncWorker) syncWorkerResultMsg { } 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()) diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index f2096b42..7f9ec2b0 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -462,25 +462,27 @@ func (suite *SubcommandTestSuite) TestSyncPrepareFailurePreservesRuntimeAndLock( oldLock, err := os.ReadFile(lockPath) suite.Require().NoError(err) - badArchivePath := filepath.Join(suite.tempdir, "bad-package.tar.gz") - suite.Require().NoError(os.WriteFile(badArchivePath, []byte("not a package archive"), 0600)) downloaded := map[string]int{} downloadCalls := 0 - var firstArchive *os.File + 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++ - if downloadCalls == 2 { - return os.Open(badArchivePath) - } archive, err := downloadTestPkg(pkg) - firstArchive = archive + archives[pkg.Driver.Path] = archive return archive, err }, - }) - suite.runCmdErr(model) + }).(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) @@ -492,8 +494,10 @@ func (suite *SubcommandTestSuite) TestSyncPrepareFailurePreservesRuntimeAndLock( suite.Equal(oldLock, newLock) suite.Equal(2, len(downloaded)) suite.Equal(2, downloadCalls) - _, err = firstArchive.Stat() - suite.ErrorIs(err, os.ErrClosed) + for driver, archive := range archives { + _, err = archive.Stat() + suite.ErrorIs(err, os.ErrClosed, "prepared archive for %s must be closed", driver) + } } func (suite *SubcommandTestSuite) TestSyncCandidateLockFailureDoesNotInstall() { @@ -770,9 +774,12 @@ func (suite *SubcommandTestSuite) TestSyncLegacyManifestOnlyProofCompatibility() return os.Open(archivePath) }, }).(syncModel) - model.worker.hooks.installPackage = func(_ context.Context, cfg config.Config, driver string, archive *os.File, expected config.ExpectedPackageMetadata, options config.InstallOptions) (config.Manifest, error) { - count.install++ - return config.InstallPackage(cfg, driver, archive, expected, options) + model.worker.hooks.ensurePackage = func(ctx context.Context, cfg config.Config, driver string, expected config.ExpectedPackageMetadata, options config.InstallOptions, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + result, err := config.EnsurePackage(ctx, cfg, driver, expected, options, callbacks) + if result.Manifest != nil { + count.install++ + } + return result, err } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -838,7 +845,7 @@ func (suite *SubcommandTestSuite) TestSyncLegacyManifestOnlyProofCompatibility() return nil, errors.New("locked v2 replay must not discover registry") }) suite.NoError(err) - suite.Equal(syncArchiveRunCounts{}, *replayCounts) + 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) @@ -1126,6 +1133,32 @@ func (suite *SubcommandTestSuite) TestSyncSameVersionRequiresMatchingManagedRece receipt.ArchiveSize++ writeSyncReceipt(t, receiptPath, receipt) }}, + {name: "missing fingerprint repairs", mutate: func(t *testing.T, _ string, receiptPath string, receipt config.InstallReceipt) { + receipt.RegistrationFingerprintAlgorithm = "" + receipt.RegistrationFingerprintVersion = 0 + receipt.RegistrationFingerprint = "" + writeSyncReceipt(t, receiptPath, receipt) + }}, + {name: "unknown fingerprint repairs", mutate: func(t *testing.T, _ string, receiptPath string, receipt config.InstallReceipt) { + receipt.RegistrationFingerprintVersion++ + writeSyncReceipt(t, receiptPath, receipt) + }}, + {name: "fingerprint mismatch repairs", mutate: func(t *testing.T, _ string, receiptPath string, receipt config.InstallReceipt) { + receipt.RegistrationFingerprint = "sha256:" + strings.Repeat("0", 64) + writeSyncReceipt(t, receiptPath, receipt) + }}, + {name: "runtime registration metadata mismatch repairs", mutate: func(t *testing.T, libraryPath, _ string, _ config.InstallReceipt) { + root := filepath.Dir(filepath.Dir(libraryPath)) + cfg := config.Config{Level: config.ConfigEnv, Location: root} + installed, err := config.GetDriver(cfg, "test-driver-1") + if err != nil { + t.Fatal(err) + } + installed.Name = "Tampered Runtime Name" + if err := config.CreateManifest(cfg, installed); err != nil { + t.Fatal(err) + } + }}, {name: "tampered library repairs", mutate: func(t *testing.T, libraryPath, _ string, _ config.InstallReceipt) { if err := os.WriteFile(libraryPath, []byte("tampered"), 0o644); err != nil { t.Fatal(err) @@ -1167,6 +1200,7 @@ func (suite *SubcommandTestSuite) TestSyncSameVersionRequiresMatchingManagedRece test.mutate(t, libraryPath, receiptPath, receipt) registryCalls, downloadCalls := 0, 0 + installCalls := 0 model := SyncCmd{Path: driverListPath, NoVerify: true}.GetModelCustom(baseModel{ getDriverRegistry: func() ([]dbc.Driver, error) { registryCalls++ @@ -1176,13 +1210,22 @@ func (suite *SubcommandTestSuite) TestSyncSameVersionRequiresMatchingManagedRece downloadCalls++ return downloadTestPkg(pkg) }, - }) + }).(syncModel) + model.worker.hooks.ensurePackage = func(ctx context.Context, cfg config.Config, driver string, expected config.ExpectedPackageMetadata, options config.InstallOptions, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + result, err := config.EnsurePackage(ctx, cfg, driver, expected, options, callbacks) + if result.Manifest != nil { + installCalls++ + } + return result, err + } suite.runCmd(model) suite.Equal(0, registryCalls) if strings.Contains(test.name, "healthy") { suite.Equal(0, downloadCalls) + suite.Zero(installCalls) } else { suite.Equal(1, downloadCalls) + suite.Equal(1, installCalls) } installed, err = config.GetDriver(cfg, "test-driver-1") @@ -1194,6 +1237,9 @@ func (suite *SubcommandTestSuite) TestSyncSameVersionRequiresMatchingManagedRece if !managed || !present || !valid || !config.VerifyInstallReceiptLibraryIntegrity(libraryPath, receipt) { t.Fatalf("repaired installation has invalid receipt/library: managed %v present %v valid %v", managed, present, valid) } + if !config.InstallReceiptMatchesRuntimeRegistration(receipt, installed, config.PlatformTuple()) { + t.Fatal("repaired receipt does not prove current runtime registration") + } }) } } @@ -1255,6 +1301,94 @@ func (suite *SubcommandTestSuite) TestSyncManifestOnlyExternalLibraryWithoutProo suite.True(config.VerifyInstallReceiptLibraryIntegrity(managedLibrary, receipt)) } +func (suite *SubcommandTestSuite) TestSyncManifestOnlyInstallFailureConvergesFromCandidateLock() { + 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) + } + firstOutput := suite.runCmdErr(first) + suite.Contains(firstOutput, "archive hash mismatch") + suite.Equal("test-driver-1", preparedPackage.Driver.Path) + candidateLock, 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.Require().NotNil(artifact.Size) + suite.Equal(int64(len(archiveBytes)), *artifact.Size) + _, err = config.GetDriver(config.Config{Level: config.ConfigEnv, Location: root}, "test-driver-1") + suite.Error(err, "failed installation must not register the candidate") + + registryCalls, downloadCalls := 0, 0 + var replayPackage dbc.PkgInfo + second := SyncCmd{Path: driverListPath, NoVerify: true}.GetModelCustom(baseModel{ + getDriverRegistry: func() ([]dbc.Driver, error) { + registryCalls++ + return nil, errors.New("candidate lock replay must not discover the registry") + }, + downloadPkg: func(pkg dbc.PkgInfo) (*os.File, error) { + downloadCalls++ + replayPackage = pkg + return makeArchive() + }, + }) + suite.runCmd(second) + suite.Zero(registryCalls) + suite.Equal(1, downloadCalls) + suite.Equal(artifact.Location.Value, replayPackage.Path.String()) + suite.Equal(artifact.Hash, replayPackage.ArtifactHash) + suite.Require().NotNil(replayPackage.ArtifactSize) + suite.Equal(*artifact.Size, *replayPackage.ArtifactSize) + convergedLock, err := os.ReadFile(lockPath) + suite.Require().NoError(err) + suite.Equal(candidateLock, convergedLock) + 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) TestSyncReceiptRepairUsesSelectedMultiPathRegistrationRoot() { tests := []struct { name string @@ -1379,9 +1513,12 @@ func (suite *SubcommandTestSuite) TestSyncPostDownloadReceiptMatchControlsSkip() return downloadTestPkg(pkg) }, }).(syncModel) - model.worker.hooks.installPackage = func(_ context.Context, cfg config.Config, driver string, archive *os.File, expected config.ExpectedPackageMetadata, options config.InstallOptions) (config.Manifest, error) { - installCalls++ - return config.InstallPackage(cfg, driver, archive, expected, options) + model.worker.hooks.ensurePackage = func(ctx context.Context, cfg config.Config, driver string, expected config.ExpectedPackageMetadata, options config.InstallOptions, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + result, err := config.EnsurePackage(ctx, cfg, driver, expected, options, callbacks) + if result.Manifest != nil { + installCalls++ + } + return result, err } suite.runCmd(model) suite.Equal(1, registryCalls) @@ -1408,6 +1545,128 @@ func (suite *SubcommandTestSuite) TestSyncPostDownloadReceiptMatchControlsSkip() } } +func (suite *SubcommandTestSuite) TestSyncRechecksPreparedReceiptBeforeSkipping() { + 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())) + 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()) + + downloadCalls, installCalls := 0, 0 + model := SyncCmd{Path: driverListPath, NoVerify: true}.GetModelCustom(baseModel{ + getDriverRegistry: func() ([]dbc.Driver, error) { + return nil, errors.New("exact lock replay must not discover registries") + }, + downloadPkg: func(pkg dbc.PkgInfo) (*os.File, error) { + downloadCalls++ + return downloadTestPkg(pkg) + }, + }).(syncModel) + model.worker.hooks.duringPrepare = func(_ context.Context, _ int, _ installItem) error { + current, err := config.GetDriver(cfg, "test-driver-1") + if err != nil { + return err + } + return config.UninstallDriver(cfg, current) + } + model.worker.hooks.ensurePackage = func(ctx context.Context, cfg config.Config, driver string, expected config.ExpectedPackageMetadata, options config.InstallOptions, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + result, err := config.EnsurePackage(ctx, cfg, driver, expected, options, callbacks) + if result.Manifest != nil { + installCalls++ + } + return result, err + } + suite.runCmd(model) + suite.Equal(1, downloadCalls, "a prepared fast-skip hint that became stale must lazily fetch the exact locked artifact") + suite.Equal(1, installCalls) + after, err := config.GetDriver(cfg, "test-driver-1") + suite.Require().NoError(err) + suite.NotEqual(beforeLibrary, after.Driver.Shared.Get(config.PlatformTuple())) +} + +func (suite *SubcommandTestSuite) TestSyncSkipsExactCandidateInstalledDuringArchiveProvider() { + 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())) + cfg := config.Config{Level: config.ConfigEnv, Location: root} + current, err := config.GetDriver(cfg, "test-driver-1") + suite.Require().NoError(err) + libraryPath := current.Driver.Shared.Get(config.PlatformTuple()) + receiptPath := filepath.Join(filepath.Dir(libraryPath), "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://stale.example" + writeSyncReceipt(suite.T(), receiptPath, receipt) + + downloadCalls, directInstalls, ensureInstalls := 0, 0, 0 + var predicateCalls int + model := SyncCmd{Path: driverListPath, NoVerify: true}.GetModelCustom(baseModel{ + getDriverRegistry: func() ([]dbc.Driver, error) { + return nil, errors.New("exact lock replay must not discover registries") + }, + 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, options config.InstallOptions, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + currentMatches := callbacks.CurrentMatches + callbacks.CurrentMatches = func(current *config.DriverInfo) (bool, error) { + predicateCalls++ + return currentMatches(current) + } + archiveProvider := callbacks.Archive + callbacks.Archive = func(ctx context.Context) (*os.File, error) { + archive, err := archiveProvider(ctx) + if err != nil { + return nil, err + } + concurrentArchive, err := os.Open(archive.Name()) + if err != nil { + return nil, err + } + _, installErr := config.InstallPackage(cfg, driver, concurrentArchive, expected, options) + closeErr := concurrentArchive.Close() + if installErr != nil { + return nil, installErr + } + if closeErr != nil { + return nil, closeErr + } + directInstalls++ + return archive, nil + } + result, err := config.EnsurePackage(ctx, cfg, driver, expected, options, callbacks) + if result.Manifest != nil { + ensureInstalls++ + } + return result, err + } + run := startSyncProgram(model) + resultModel := waitSyncProgram(suite.T(), run) + suite.Require().NoError(resultModel.Err(), run.output.String()) + suite.Equal(1, downloadCalls) + suite.Equal(1, directInstalls) + suite.Equal(0, ensureInstalls, "phase two must skip after the provider's concurrent exact install") + suite.Equal(2, predicateCalls, "phase one and phase two must both inspect current registration") + after, err := config.GetDriver(cfg, "test-driver-1") + suite.Require().NoError(err) + finalReceipt, managed, present, valid, err := config.InspectDriverInstallReceipt(cfg, after) + suite.Require().NoError(err) + suite.True(managed) + suite.True(present) + suite.True(valid) + suite.True(config.InstallReceiptMatchesRuntimeRegistration(finalReceipt, after, config.PlatformTuple())) +} + type syncProgramRun struct { program *tea.Program done chan struct{} @@ -1513,11 +1772,19 @@ func (suite *SubcommandTestSuite) TestSyncCancellationWaitsForWorkerCleanup() { case "candidate-save": model.worker.hooks.beforeCandidateSave = block case "install": - model.worker.hooks.installPackage = func(ctx context.Context, _ config.Config, _ string, file *os.File, _ config.ExpectedPackageMetadata, _ config.InstallOptions) (config.Manifest, error) { - archive = file - close(entered) - <-release - return config.Manifest{}, ctx.Err() + model.worker.hooks.ensurePackage = func(ctx context.Context, cfg config.Config, driver string, expected config.ExpectedPackageMetadata, options config.InstallOptions, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + archiveProvider := callbacks.Archive + callbacks.Archive = func(ctx context.Context) (*os.File, error) { + file, err := archiveProvider(ctx) + if err != nil { + return nil, err + } + archive = file + close(entered) + <-release + return nil, ctx.Err() + } + return config.EnsurePackage(ctx, cfg, driver, expected, options, callbacks) } } @@ -1731,9 +1998,12 @@ func (suite *SubcommandTestSuite) TestSyncSerializesSameProjectUntilWorkerFinish return downloadTestPkg(pkg) }, }).(syncModel) - second.worker.hooks.installPackage = func(_ context.Context, cfg config.Config, driver string, archive *os.File, expected config.ExpectedPackageMetadata, options config.InstallOptions) (config.Manifest, error) { - secondInstalls.Add(1) - return config.InstallPackage(cfg, driver, archive, expected, options) + second.worker.hooks.ensurePackage = func(ctx context.Context, cfg config.Config, driver string, expected config.ExpectedPackageMetadata, options config.InstallOptions, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + result, err := config.EnsurePackage(ctx, cfg, driver, expected, options, callbacks) + if result.Manifest != nil { + secondInstalls.Add(1) + } + return result, err } secondRun := startSyncProgram(second) deadline := time.After(2 * time.Second) From f9e02fa6791ddd086cf20bdb5fcd4da4a369fa09 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 03:09:36 +0000 Subject: [PATCH 056/115] docs: defer prototype follow-up refinements --- config/package_archive.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/package_archive.go b/config/package_archive.go index cadc1b2e..d5f1b9b8 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -416,6 +416,7 @@ type EnsurePackageResult struct { // before archive retrieval and again against the latest registration after. // Result.Current is the most recently inspected registration: it remains the // phase-one snapshot on provider 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, installOptions InstallOptions, callbacks EnsurePackageCallbacks) (EnsurePackageResult, error) { return ensurePackageWithLockObserver(ctx, cfg, runtimeID, expected, installOptions, callbacks, nil) } @@ -644,6 +645,7 @@ func acquireDriverInstallLocks(ctx context.Context, roots []string, runtimeID st 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 From bd39de281b56645991e48a4665ee14248c3c270c Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 03:23:22 +0000 Subject: [PATCH 057/115] feat: model project driver sources --- cmd/dbc/add.go | 8 ++++ cmd/dbc/driver_list.go | 20 +++++++++ cmd/dbc/driver_list_test.go | 71 +++++++++++++++++++++++++++++ cmd/dbc/helpers.go | 3 ++ cmd/dbc/registry_wiring_test.go | 50 ++++++++++++++++++++- cmd/dbc/remove.go | 3 ++ drivers.go | 3 ++ source.go | 73 ++++++++++++++++++++++++++++++ source_test.go | 80 +++++++++++++++++++++++++++++++++ 9 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 source.go create mode 100644 source_test.go diff --git a/cmd/dbc/add.go b/cmd/dbc/add.go index eaeb606c..ed86510b 100644 --- a/cmd/dbc/add.go +++ b/cmd/dbc/add.go @@ -152,6 +152,11 @@ func (m addModel) Init() tea.Cmd { readLock.Release() return err } + if err := m.list.validateSources(); err != nil { + f.Close() + readLock.Release() + return err + } f.Close() readLock.Release() @@ -256,6 +261,9 @@ 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 diff --git a/cmd/dbc/driver_list.go b/cmd/dbc/driver_list.go index 4489ffa4..6fb466fa 100644 --- a/cmd/dbc/driver_list.go +++ b/cmd/dbc/driver_list.go @@ -37,6 +37,18 @@ 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) + } + } + 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 @@ -171,12 +183,16 @@ 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"` + Source *dbc.DriverSource `toml:"source,omitempty"` } func GetDriverList(fname string) ([]dbc.PkgInfo, error) { @@ -189,6 +205,9 @@ func GetDriverList(fname string) ([]dbc.PkgInfo, error) { if err = toml.NewDecoder(f).Decode(&m); err != nil { return nil, fmt.Errorf("error decoding driver list %s: %w", fname, err) } + if err := m.validateSources(); 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 @@ -221,6 +240,7 @@ func GetDriverList(fname string) ([]dbc.PkgInfo, error) { if err != nil { return nil, fmt.Errorf("error finding version for driver %s: %w", name, err) } + pkg.Source = spec.Source pkgs = append(pkgs, pkg) } diff --git a/cmd/dbc/driver_list_test.go b/cmd/dbc/driver_list_test.go index 75239125..f6aa990d 100644 --- a/cmd/dbc/driver_list_test.go +++ b/cmd/dbc/driver_list_test.go @@ -81,6 +81,77 @@ 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]\n[drivers.example.source]\ntype = 'packslip'\nproject = 'owner/project'\n", + want: dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "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 + wantErr string + }{ + {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 = 'owner/project'", wantErr: "path source contains fields for another source type"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + 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) + }) + } +} + func must[T any](v T, err error) T { if err != nil { panic(err) diff --git a/cmd/dbc/helpers.go b/cmd/dbc/helpers.go index 70ee08bb..08147cbf 100644 --- a/cmd/dbc/helpers.go +++ b/cmd/dbc/helpers.go @@ -83,5 +83,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/registry_wiring_test.go b/cmd/dbc/registry_wiring_test.go index 7ed35f65..95737c66 100644 --- a/cmd/dbc/registry_wiring_test.go +++ b/cmd/dbc/registry_wiring_test.go @@ -739,6 +739,54 @@ func TestAddAbortsOnConcurrentRegistryConfigChange(t *testing.T) { assert.NotContains(t, s, "test-driver-1", "aborted add must not write the driver entry") } +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 = '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 +1681,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/drivers.go b/drivers.go index a03b9614..18461ffd 100644 --- a/drivers.go +++ b/drivers.go @@ -262,6 +262,9 @@ type PkgInfo struct { Driver Driver Version *semver.Version PlatformTuple string + // Source preserves the source explicitly declared by a project config. + // A nil value means the legacy/default registry source was requested. + Source *DriverSource `json:"source,omitempty"` Path *url.URL // ArtifactHash is the optional digest of the downloaded archive. diff --git a/source.go b/source.go new file mode 100644 index 00000000..7b27b6ac --- /dev/null +++ b/source.go @@ -0,0 +1,73 @@ +// 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" +) + +// 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. +// URL, project identity, and path resolution are validated by their adapters. +func (s DriverSource) Validate() error { + switch s.Type { + case DriverSourceRegistry: + if s.URL == "" { + return errors.New("registry source has no URL") + } + 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 s.URL != "" || s.Path != "" { + return errors.New("packslip source contains fields for another source type") + } + case DriverSourcePath: + if s.Path == "" { + return errors.New("path source has no path") + } + 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..1db066bf --- /dev/null +++ b/source_test.go @@ -0,0 +1,80 @@ +// 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 ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "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: "owner/project"}}, + {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 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: "owner/project", URL: "https://registry.example.test"}, wantErr: "packslip source contains fields for another source type"}, + {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) + }) + } +} + +func TestDriverSourceJSONRoundTrip(t *testing.T) { + sources := []DriverSource{ + {Type: DriverSourceRegistry, URL: "https://registry.example.test"}, + {Type: DriverSourcePackslip, Project: "owner/project"}, + {Type: DriverSourcePath, Path: "../packages/driver.tar.gz"}, + } + for _, source := range sources { + t.Run(string(source.Type), func(t *testing.T) { + data, err := json.Marshal(PkgInfo{Source: &source}) + require.NoError(t, err) + + var output PkgInfo + require.NoError(t, json.Unmarshal(data, &output)) + require.NotNil(t, output.Source) + assert.Equal(t, source, *output.Source) + }) + } + + legacy := PkgInfo{} + legacyData, err := json.Marshal(legacy) + require.NoError(t, err) + var legacyRoundTrip PkgInfo + require.NoError(t, json.Unmarshal(legacyData, &legacyRoundTrip)) + assert.Nil(t, legacyRoundTrip.Source, "omitted legacy source must remain distinguishable from explicit registry") +} From a44cbebc5449b595d1f8e77cb6e141f79460f49b Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 03:30:12 +0000 Subject: [PATCH 058/115] fix: preserve project driver sources on add --- cmd/dbc/add.go | 15 +++++++++++--- cmd/dbc/add_test.go | 50 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/cmd/dbc/add.go b/cmd/dbc/add.go index ed86510b..a50001a5 100644 --- a/cmd/dbc/add.go +++ b/cmd/dbc/add.go @@ -212,10 +212,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 = spec.Vers + 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 { @@ -283,7 +286,13 @@ func (m addModel) Init() tea.Cmd { 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] + if latest, ok := current.Drivers[spec.Name]; ok { + // `add` only changes the version selection. Preserve the source + // from the locked re-read in case it changed during registry lookup. + updated.Source = latest.Source + } + current.Drivers[spec.Name] = updated } wf, err := os.Create(p) diff --git a/cmd/dbc/add_test.go b/cmd/dbc/add_test.go index bcce93f4..6baf9ef9 100644 --- a/cmd/dbc/add_test.go +++ b/cmd/dbc/add_test.go @@ -27,6 +27,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/columnar-tech/dbc" "github.com/columnar-tech/dbc/internal/jsonschema" + "github.com/pelletier/go-toml/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -158,6 +159,55 @@ 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() + msg := runTeaCmdToCompletion(t, cmd.GetModelCustom(testBaseModel()).(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 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 From 61984baa98bd3d31c06c0126f917a898ff0118cf Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 03:45:09 +0000 Subject: [PATCH 059/115] fix: require strict package v2 versions --- config/package_archive.go | 20 ++++++---- config/package_archive_test.go | 67 ++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/config/package_archive.go b/config/package_archive.go index d5f1b9b8..08c64ce0 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -125,12 +125,12 @@ type packageManifest struct { } type packageManifestV2Wire struct { - PackageVersion int64 `toml:"package_version"` - ManifestVersion *int64 `toml:"manifest_version"` - ID string `toml:"id"` - Name string `toml:"name"` - Version *semver.Version `toml:"version"` - Platform string `toml:"platform"` + PackageVersion int64 `toml:"package_version"` + ManifestVersion *int64 `toml:"manifest_version"` + ID string `toml:"id"` + Name string `toml:"name"` + Version string `toml:"version"` + Platform string `toml:"platform"` Driver struct { Entrypoint string `toml:"entrypoint"` } `toml:"Driver"` @@ -214,9 +214,13 @@ func decodePackageV2Metadata(data []byte) (packageManifest, error) { if strings.TrimSpace(wire.Name) == "" { return packageManifest{}, fmt.Errorf("%w: name is required", ErrInvalidManifest) } - if wire.Version == nil { + if wire.Version == "" { return packageManifest{}, fmt.Errorf("%w: version is required", ErrInvalidManifest) } + parsedVersion, err := semver.StrictNewVersion(wire.Version) + if err != nil { + return packageManifest{}, fmt.Errorf("%w: version %q must be valid SemVer 2.0.0: %v", ErrInvalidManifest, wire.Version, err) + } if err := validatePlatformIdentifier(wire.Platform); err != nil { return packageManifest{}, fmt.Errorf("%w: invalid platform: %v", ErrInvalidManifest, err) } @@ -233,7 +237,7 @@ func decodePackageV2Metadata(data []byte) (packageManifest, error) { DriverInfo: DriverInfo{ ID: wire.ID, Name: wire.Name, - Version: wire.Version, + Version: parsedVersion, Driver: struct { Entrypoint string Shared driverMap diff --git a/config/package_archive_test.go b/config/package_archive_test.go index 793dc6fb..7a934289 100644 --- a/config/package_archive_test.go +++ b/config/package_archive_test.go @@ -235,6 +235,73 @@ entrypoint = "AdbcDriverLegacyTableInit" }) } +func TestPackageV2RequiresStrictSemVer(t *testing.T) { + for _, version := range []string{ + "1.2.3", + "1.2.3-rc.1", + "1.2.3+build.5", + "1.2.3-rc.1+build.5", + } { + t.Run("accepts "+version, func(t *testing.T) { + archive := makePackageArchive(t, + archiveEntry{name: "dbc-package.toml", data: packageV2Manifest("example", version, config.PlatformTuple(), "driver.so")}, + archiveEntry{name: "driver.so", data: []byte("library")}, + ) + for name, inspect := range map[string]func(*os.File) (config.Manifest, error){ + "inspect": config.InspectPackageMetadata, + "extract": func(file *os.File) (config.Manifest, error) { + return config.InflateTarball(file, t.TempDir()) + }, + } { + t.Run(name, func(t *testing.T) { + manifest, err := inspect(openPackageArchive(t, archive)) + require.NoError(t, err) + require.NotNil(t, manifest.Version) + assert.Equal(t, version, manifest.Version.String()) + }) + } + }) + } + + for _, version := range []string{"v1.2.3", "1.2", "01.2.3"} { + t.Run("rejects "+version, func(t *testing.T) { + archive := makePackageArchive(t, + archiveEntry{name: "dbc-package.toml", data: packageV2Manifest("example", version, config.PlatformTuple(), "driver.so")}, + archiveEntry{name: "driver.so", data: []byte("library")}, + ) + for name, inspect := range map[string]func(*os.File) error{ + "inspect": func(file *os.File) error { + _, err := config.InspectPackageMetadata(file) + return err + }, + "extract": func(file *os.File) error { + _, err := config.InflateTarball(file, t.TempDir()) + return err + }, + } { + t.Run(name, func(t *testing.T) { + err := inspect(openPackageArchive(t, archive)) + require.ErrorIs(t, err, config.ErrInvalidManifest) + assert.Contains(t, err.Error(), "must be valid SemVer 2.0.0") + }) + } + }) + } + + 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") v2 := packageV2Manifest("example", "1.2.3", config.PlatformTuple(), "driver.so") From e6f8bc5635a7014baba8be01c7088555df72d8e5 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 03:50:37 +0000 Subject: [PATCH 060/115] fix: enforce exact packslip source versions --- cmd/dbc/driver_list.go | 9 ++++ cmd/dbc/driver_list_test.go | 59 ++++++++++++++++++++++- internal/packslip/resolver_native_test.go | 56 +++++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/cmd/dbc/driver_list.go b/cmd/dbc/driver_list.go index 6fb466fa..2a3c9a51 100644 --- a/cmd/dbc/driver_list.go +++ b/cmd/dbc/driver_list.go @@ -45,6 +45,15 @@ func (m DriversList) validateSources() error { if err := spec.Source.Validate(); err != nil { return fmt.Errorf("driver %q source: %w", id, err) } + if spec.Source.Type == dbc.DriverSourcePackslip { + if spec.Version == nil { + return fmt.Errorf("driver %q packslip source requires an exact SemVer 2.0.0 version", id) + } + versionText := spec.Version.String() + if _, err := semver.StrictNewVersion(versionText); err != nil { + return fmt.Errorf("driver %q packslip source requires an exact SemVer 2.0.0 version, got %q", id, versionText) + } + } } return nil } diff --git a/cmd/dbc/driver_list_test.go b/cmd/dbc/driver_list_test.go index f6aa990d..774236eb 100644 --- a/cmd/dbc/driver_list_test.go +++ b/cmd/dbc/driver_list_test.go @@ -94,7 +94,7 @@ func TestDriverSourceProjectConfigRoundTrip(t *testing.T) { }, { name: "packslip project", - toml: "[drivers.example]\n[drivers.example.source]\ntype = 'packslip'\nproject = 'owner/project'\n", + toml: "[drivers.example]\nversion = '1.2.3'\n[drivers.example.source]\ntype = 'packslip'\nproject = 'owner/project'\n", want: dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "owner/project"}, }, { @@ -152,6 +152,63 @@ func TestDriverSourceProjectConfigValidation(t *testing.T) { } } +func TestPackslipSourceRequiresExactStrictSemVer(t *testing.T) { + tests := []struct { + name string + version string + wantErr bool + }{ + {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: "exact prerelease and build metadata", version: "1.2.3-rc.1+build.5"}, + {name: "explicit equality operator", version: "=1.2.3", wantErr: true}, + {name: "range", version: ">=1.2.3", wantErr: true}, + {name: "latest", version: "latest", wantErr: true}, + {name: "v prefix", version: "v1.2.3", wantErr: true}, + {name: "abbreviated version", version: "1.2", wantErr: true}, + {name: "leading zero", version: "01.2.3", wantErr: true}, + {name: "missing version", version: "", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + contents := "[drivers.example]\n" + if tt.version != "" { + contents += "version = '" + tt.version + "'\n" + } + contents += "[drivers.example.source]\ntype = 'packslip'\nproject = 'owner/project'\n" + + var list DriversList + err := toml.Unmarshal([]byte(contents), &list) + if err == nil { + err = list.validateSources() + } + if tt.wantErr { + require.Error(t, err) + if list.Drivers["example"].Source != nil && list.Drivers["example"].Version != nil { + require.ErrorContains(t, err, "exact SemVer 2.0.0", + "a parsed constraint must retain enough spelling for strict validation") + } + return + } + require.NoError(t, err) + }) + } + + // Packslip's exact-version requirement must not tighten the legacy source + // behavior: registry constraints and unconstrained registry entries remain + // valid. + for _, contents := range []string{ + "[drivers.example]\nversion = '>=1.2.3'\n", + "[drivers.example]\n", + } { + var list DriversList + require.NoError(t, toml.Unmarshal([]byte(contents), &list)) + require.NoError(t, list.validateSources()) + } +} + func must[T any](v T, err error) T { if err != nil { panic(err) diff --git a/internal/packslip/resolver_native_test.go b/internal/packslip/resolver_native_test.go index 7dc9bdc7..bee9fac8 100644 --- a/internal/packslip/resolver_native_test.go +++ b/internal/packslip/resolver_native_test.go @@ -138,6 +138,7 @@ type resolverHTTPFixtures struct { customReleaseAssets bool apiHits atomic.Int32 requestHits atomic.Int32 + bundleHits atomic.Int32 archiveHits atomic.Int32 } @@ -166,6 +167,7 @@ func newResolverTestServer(t *testing.T, project, tag string, listBytes, release } _, _ = 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) @@ -518,6 +520,60 @@ func TestResolveRequiresExactSemVerBeforeDiscovery(t *testing.T) { 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 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, "") From 8abcf972dd0d98292d507c3fa2aab37aee536092 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 04:01:49 +0000 Subject: [PATCH 061/115] feat: resolve packslip and local sources --- config/driver.go | 3 + config/package_archive.go | 3 +- internal/sourceresolution/opener.go | 155 ++++++++++++ internal/sourceresolution/opener_test.go | 123 ++++++++++ internal/sourceresolution/resolver.go | 201 ++++++++++++++++ internal/sourceresolution/resolver_test.go | 261 +++++++++++++++++++++ 6 files changed, 745 insertions(+), 1 deletion(-) create mode 100644 internal/sourceresolution/opener.go create mode 100644 internal/sourceresolution/opener_test.go create mode 100644 internal/sourceresolution/resolver.go create mode 100644 internal/sourceresolution/resolver_test.go diff --git a/config/driver.go b/config/driver.go index a7da6644..346ed244 100644 --- a/config/driver.go +++ b/config/driver.go @@ -37,6 +37,9 @@ type Manifest struct { // PackageVersion is the parsed package wire version. It is in-memory // metadata only; runtime ADBC manifests continue to use ManifestVersion. PackageVersion int + // PackagePlatform is set when package metadata declares a concrete platform. + // Legacy package MANIFEST files do not carry this field. + PackagePlatform string Files struct { Driver string `toml:"driver,omitempty"` diff --git a/config/package_archive.go b/config/package_archive.go index 08c64ce0..5300ff7e 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -233,7 +233,8 @@ func decodePackageV2Metadata(data []byte) (packageManifest, error) { return packageManifest{ manifest: Manifest{ - PackageVersion: 2, + PackageVersion: 2, + PackagePlatform: wire.Platform, DriverInfo: DriverInfo{ ID: wire.ID, Name: wire.Name, 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/resolver.go b/internal/sourceresolution/resolver.go new file mode 100644 index 00000000..2954c67a --- /dev/null +++ b/internal/sourceresolution/resolver.go @@ -0,0 +1,201 @@ +// 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" +) + +// 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 by package v2 metadata. +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") + } + if driverID == "" { + return resolution.ResolvedRelease{}, errors.New("packslip resolution requires a driver ID") + } + 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 != 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) + } + wantIdentity := canonicalPackslipProject(project) + if release.Source.Type != "packslip" || release.Source.Reference != wantIdentity { + return resolution.ResolvedRelease{}, fmt.Errorf("packslip release source identity %q does not match declared project %q", release.Source.Reference, wantIdentity) + } + if err := resolution.ValidateResolvedRelease(release); err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("packslip resolver returned an invalid release: %w", err) + } + return release, nil +} + +func canonicalPackslipProject(project string) string { + parts := strings.Split(project, "/") + for i := 0; i < len(parts) && i < 3; i++ { + parts[i] = strings.ToLower(parts[i]) + } + return strings.Join(parts, "/") +} + +// 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) + } + } + if err := resolution.ValidateConcreteTarget(request.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 != resolution.CanonicalTarget(request.Target) { + return resolution.ResolvedRelease{}, fmt.Errorf("current target %v does not match platform tuple %q", request.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) + } + if manifest.PackageVersion == 2 { + if manifest.ID != request.DriverID { + return resolution.ResolvedRelease{}, fmt.Errorf("local package ID %q does not match requested driver %q", manifest.ID, request.DriverID) + } + if manifest.PackagePlatform != request.Platform { + return resolution.ResolvedRelease{}, fmt.Errorf("local package platform %q does not match current platform %q", manifest.PackagePlatform, request.Platform) + } + } + // 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: request.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..f57a7ecd --- /dev/null +++ b/internal/sourceresolution/resolver_test.go @@ -0,0 +1,261 @@ +// 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 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: "no finalized hash"}, + {name: "artifact size", change: func(r *resolution.ResolvedRelease) { r.Artifacts[0].Size = nil }, want: "no finalized 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, "dbc-package.toml", v2Metadata("example", "1.2.3", "linux_amd64")) + 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 TestResolvePathRejectsV2DriverIDAndPlatformMismatch(t *testing.T) { + for _, test := range []struct { + name string + metadata []byte + platform string + want string + }{ + {name: "driver ID", metadata: v2Metadata("other", "1.2.3", "linux_amd64"), platform: "linux_amd64", want: "ID"}, + {name: "platform", metadata: v2Metadata("example", "1.2.3", "macos_arm64"), platform: "linux_amd64", want: "platform"}, + } { + t.Run(test.name, func(t *testing.T) { + projectDir := t.TempDir() + archive := testPackageArchive(t, "dbc-package.toml", test.metadata) + 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: "linux", Arch: "amd64", LibC: "gnu"}, + Platform: test.platform, BaseDir: projectDir, + }) + require.ErrorContains(t, err, test.want) + }) + } +} + +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, "dbc-package.toml", v2Metadata("example", "1.2.3", "linux_amd64")) + secondBytes := testPackageArchive(t, "dbc-package.toml", v2Metadata("example", "1.2.3", "linux_amd64")) + 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 v2Metadata(id, version, platform string) []byte { + return []byte(fmt.Sprintf("package_version = 2\nid = %q\nname = 'Example Driver'\nversion = %q\nplatform = %q\n\n[Driver]\nentrypoint = 'ExampleInit'\n\n[Files]\ndriver = 'driver.so'\n", id, version, platform)) +} + +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, "dbc-package.toml", v2Metadata("example", "1.2.3", "linux_amd64")) + 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, "dbc-package.toml", v2Metadata("example", "1.2.3", "linux_amd64")) + 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, "dbc-package.toml", v2Metadata("example", "1.2.3", "linux_amd64")) + 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, "dbc-package.toml", v2Metadata("example", "1.2.3", "linux_amd64")) + 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") +} From f1cf0000b081482a71f8defb131822fdeac9eba0 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 04:08:22 +0000 Subject: [PATCH 062/115] fix: canonicalize local source targets --- internal/sourceresolution/resolver.go | 9 +++--- internal/sourceresolution/resolver_test.go | 35 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/internal/sourceresolution/resolver.go b/internal/sourceresolution/resolver.go index 2954c67a..9ca4e742 100644 --- a/internal/sourceresolution/resolver.go +++ b/internal/sourceresolution/resolver.go @@ -98,7 +98,8 @@ func ResolvePath(ctx context.Context, declaredPath string, request Request) (res return resolution.ResolvedRelease{}, fmt.Errorf("requested path package version %q must be an exact SemVer 2.0.0 version", request.Version) } } - if err := resolution.ValidateConcreteTarget(request.Target); err != nil { + 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 == "" { @@ -108,8 +109,8 @@ func ResolvePath(ctx context.Context, declaredPath string, request Request) (res if err != nil { return resolution.ResolvedRelease{}, fmt.Errorf("invalid current platform tuple: %w", err) } - if platformTarget != resolution.CanonicalTarget(request.Target) { - return resolution.ResolvedRelease{}, fmt.Errorf("current target %v does not match platform tuple %q", request.Target, request.Platform) + 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") @@ -160,7 +161,7 @@ func ResolvePath(ctx context.Context, declaredPath string, request Request) (res Version: resolvedVersion, Source: resolution.SourceSpec{Type: "path", Reference: declaredPath}, Artifacts: []resolution.Artifact{{ - Target: request.Target, + Target: target, Format: format, Location: location, Hash: archiveHash, diff --git a/internal/sourceresolution/resolver_test.go b/internal/sourceresolution/resolver_test.go index f57a7ecd..7fca3856 100644 --- a/internal/sourceresolution/resolver_test.go +++ b/internal/sourceresolution/resolver_test.go @@ -259,3 +259,38 @@ func TestResolvePathRequiresPlatformTargetConsistency(t *testing.T) { }) 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) + }) + } +} From f45c8f06408239e7ff622a6dc61e370d8db46635 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 04:23:18 +0000 Subject: [PATCH 063/115] fix: preserve locked artifact execution metadata --- cmd/dbc/lockfile_v2.go | 76 +++++++++----- cmd/dbc/lockfile_v2_test.go | 194 ++++++++++++++++++++++++++++++++++++ cmd/dbc/sync.go | 145 ++++++++++++++++++++++++--- cmd/dbc/sync_test.go | 16 +++ 4 files changed, 393 insertions(+), 38 deletions(-) diff --git a/cmd/dbc/lockfile_v2.go b/cmd/dbc/lockfile_v2.go index 99e35210..e65e96aa 100644 --- a/cmd/dbc/lockfile_v2.go +++ b/cmd/dbc/lockfile_v2.go @@ -112,19 +112,52 @@ func lockInfoFromResolvedRelease(name string, release resolution.ResolvedRelease 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: lockHostRequirements{ - OSMin: artifact.HostRequirements.OSMin, - GLibCMin: artifact.HostRequirements.GLibCMin, - Libs: append([]string(nil), artifact.HostRequirements.Libs...), - }, + 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 artifact.HostRequirements.Bins { - result.HostRequirements.Bins = append(result.HostRequirements.Bins, lockNamedRequirement{Name: bin.Name, Min: bin.Min}) + 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 } @@ -149,19 +182,12 @@ func (d lockInfo) resolvedRelease() resolution.ResolvedRelease { } 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: resolution.HostRequirements{ - OSMin: artifact.HostRequirements.OSMin, - GLibCMin: artifact.HostRequirements.GLibCMin, - Libs: append([]string(nil), artifact.HostRequirements.Libs...), - }, - } - for _, bin := range artifact.HostRequirements.Bins { - resolved.HostRequirements.Bins = append(resolved.HostRequirements.Bins, resolution.NamedRequirement{Name: bin.Name, Min: bin.Min}) + 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) } diff --git a/cmd/dbc/lockfile_v2_test.go b/cmd/dbc/lockfile_v2_test.go index 3a529dd1..ac357fe4 100644 --- a/cmd/dbc/lockfile_v2_test.go +++ b/cmd/dbc/lockfile_v2_test.go @@ -15,6 +15,7 @@ package main import ( + "context" "crypto/sha256" "encoding/hex" "errors" @@ -550,6 +551,13 @@ func TestSyncAdapterReusesCompleteV2SnapshotWithoutRegistryHashes(t *testing.T) assert.Equal(t, "https://assets.example.test/archive.tar.gz", item.Package.Path.String(), "artifact host is independent of source identity") assert.Equal(t, entry.Artifacts[0].Hash, item.Package.ArtifactHash) assert.Equal(t, *entry.Artifacts[0].Size, *item.Package.ArtifactSize) + assert.True(t, canReuseLockedEntry(item)) + item.ArtifactFormat = "tgz" + assert.False(t, canReuseLockedEntry(item), "a locked artifact with a different format is not the selected artifact proof") + item.ArtifactFormat = entry.Artifacts[0].Format + item.HostRequirements.Libs = []string{"libc.so.6"} + assert.False(t, canReuseLockedEntry(item), "a locked artifact with different host requirements is not the selected artifact proof") + item.HostRequirements.Libs = nil item.InstalledLibraryHash = strings.Repeat("f", 64) updated, err := lockEntryForItem(item) @@ -843,6 +851,192 @@ func TestLockResolvedReleaseRoundTripPreservesEvidenceAndSelectors(t *testing.T) 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) + assert.Equal(t, "tgz", item.ArtifactFormat) + + model := syncModel{baseModel: baseModel{downloadPkg: func(dbc.PkgInfo) (*os.File, error) { + return os.Open(archivePath) + }}} + prepared, err := model.prepareInstallItems(context.Background(), []installItem{item}) + require.NoError(t, err) + defer closePreparedArchives(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 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"}}, + } + item := installItem{} + require.NoError(t, setSelectedArtifactMetadata(&item, resolution.Artifact{Format: "tgz", HostRequirements: requirements})) + requirements.Libs[0] = "changed" + requirements.Bins[0].Name = "changed" + assert.Equal(t, "tgz", item.ArtifactFormat) + assert.Equal(t, []string{"libssl.so.3"}, item.HostRequirements.Libs) + assert.Equal(t, []resolution.NamedRequirement{{Name: "git", Min: "2.40"}}, item.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) + + registryURL, err := url.Parse("https://registry.example.test") + require.NoError(t, err) + packageURL, err := url.Parse("https://assets.example.test/example.tgz") + require.NoError(t, err) + item := installItem{ + Driver: dbc.Driver{Path: "example", Registry: &dbc.Registry{BaseURL: registryURL}}, + Package: dbc.PkgInfo{Version: semver.MustParse("1.2.3"), PlatformTuple: config.PlatformTuple(), Path: packageURL}, + ArtifactFormat: "tgz", + HostRequirements: tt.requirements, + } + downloadCalls, ensureCalls := 0, 0 + worker := newSyncWorker() + worker.hooks.ensurePackage = func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.InstallOptions, 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) + + _, ensureErr := model.ensurePreparedPackage(context.Background(), &item) + require.ErrorContains(t, ensureErr, "unsupported host requirements for example") + assert.Zero(t, ensureCalls, "unsupported requirements must be rejected before EnsurePackage") + 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) { diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 1229999f..4d85b1ce 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -23,6 +23,8 @@ import ( "net/url" "os" "path/filepath" + "reflect" + "sort" "strings" "sync" "time" @@ -334,6 +336,8 @@ func reloadConfigTarget(selected config.Config) config.Config { type installItem struct { Driver dbc.Driver Package dbc.PkgInfo + ArtifactFormat string + HostRequirements resolution.HostRequirements Checksum string ArchiveHash string ArchiveSize int64 @@ -418,9 +422,11 @@ func (s syncModel) createInstallList(list DriversList) ([]installItem, error) { } items = append(items, installItem{ - Driver: drv, - Package: pkg, - Checksum: info.legacyChecksumFor(config.PlatformTuple()), + Driver: drv, + Package: pkg, + ArtifactFormat: "tar.gz", + HostRequirements: resolution.HostRequirements{}, + Checksum: info.legacyChecksumFor(config.PlatformTuple()), LockEntry: func() *lockInfo { if info.Version == nil { return nil @@ -481,8 +487,12 @@ func installItemFromLockedArtifact(name string, entry lockInfo, artifact lockArt if artifact.Location.Kind != resolution.ArtifactLocationURL || artifact.Location.Value == "" { return installItem{}, fmt.Errorf("locked registry artifact for %s is not a remote URL artifact", name) } - if artifact.Format != "" && artifact.Format != "tar.gz" { - return installItem{}, fmt.Errorf("locked artifact format %q for %s is not supported by sync yet", artifact.Format, name) + 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 } packageURL, err := url.Parse(artifact.Location.Value) if err != nil || !packageURL.IsAbs() || packageURL.Hostname() == "" { @@ -501,13 +511,93 @@ func installItemFromLockedArtifact(name string, entry lockInfo, artifact lockArt ArtifactHash: artifact.Hash, ArtifactSize: cloneInt64(artifact.Size), } - copy := entry - return installItem{ + copy := cloneLockInfo(entry) + item := installItem{ Driver: driver, Package: pkg, Checksum: entry.legacyChecksumFor(config.PlatformTuple()), LockEntry: ©, - }, nil + } + if err := setSelectedArtifactMetadata(&item, resolution.Artifact{ + Format: artifact.Format, + HostRequirements: requirements, + }); err != nil { + return installItem{}, err + } + return item, nil +} + +// setSelectedArtifactMetadata copies source-neutral execution metadata from a +// resolved artifact onto an install item. Source integrations should call this +// after selecting the concrete target artifact. +func setSelectedArtifactMetadata(item *installItem, artifact resolution.Artifact) error { + if item == nil { + return errors.New("cannot set artifact metadata on a nil install item") + } + if err := validateInstallableArtifactFormat(artifact.Format); err != nil { + return err + } + item.ArtifactFormat = artifact.Format + item.HostRequirements = cloneHostRequirements(artifact.HostRequirements) + return 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 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...), + } +} + +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, "; ")) } type installedDrvMsg struct { @@ -548,6 +638,10 @@ func canReuseLockedEntry(item installItem) bool { if err != nil || artifact.Location.Kind != resolution.ArtifactLocationURL || artifact.Location.Value == "" || item.Package.Path == nil || item.Package.ArtifactSize == nil { return false } + if artifact.Format != item.ArtifactFormat || + !reflect.DeepEqual(canonicalHostRequirements(artifact.HostRequirements), canonicalHostRequirements(lockHostRequirementsFromResolution(item.HostRequirements))) { + return false + } lockedURL, err := url.Parse(artifact.Location.Value) if err != nil || lockedURL == nil { return false @@ -601,6 +695,12 @@ func (s syncModel) prepareInstallItems(ctx context.Context, items []installItem) return prepared, err } item := &prepared.items[i] + if err := validateInstallableArtifactFormat(item.ArtifactFormat); err != nil { + return prepared, fmt.Errorf("driver %s: %w", item.Driver.Path, err) + } + if err := validateHostRequirements(item.Driver.Path, item.HostRequirements); err != nil { + return prepared, err + } var sameVersionInstalled *config.DriverInfo if s.cfg.Exists { if installed, ok := s.cfg.Drivers[item.Driver.Path]; ok { @@ -679,6 +779,12 @@ func (s syncModel) downloadAndValidateItem(ctx context.Context, item *installIte if err := ctx.Err(); err != nil { return err } + if err := validateInstallableArtifactFormat(item.ArtifactFormat); err != nil { + return fmt.Errorf("driver %s: %w", item.Driver.Path, err) + } + if err := validateHostRequirements(item.Driver.Path, item.HostRequirements); err != nil { + return err + } if item.Archive == nil { // downloadPkg has no context API. The worker retains the project lock and // checks cancellation immediately after this synchronous call returns. @@ -797,6 +903,12 @@ func (s syncModel) ensurePreparedPackage(ctx context.Context, item *installItem) if err := ctx.Err(); err != nil { return config.EnsurePackageResult{}, err } + if err := validateInstallableArtifactFormat(item.ArtifactFormat); err != nil { + return config.EnsurePackageResult{}, fmt.Errorf("driver %s: %w", item.Driver.Path, err) + } + if err := validateHostRequirements(item.Driver.Path, item.HostRequirements); err != nil { + return config.EnsurePackageResult{}, err + } callbacks := config.EnsurePackageCallbacks{ CurrentMatches: func(current *config.DriverInfo) (bool, error) { return s.itemCurrentMatches(item, current) @@ -1134,6 +1246,12 @@ func (s syncModel) fail(code string, err error) (syncModel, tea.Cmd) { } func lockEntryForItem(item installItem) (lockInfo, error) { + if err := validateInstallableArtifactFormat(item.ArtifactFormat); err != nil { + return lockInfo{}, fmt.Errorf("driver %s: %w", item.Driver.Path, err) + } + if err := validateHostRequirements(item.Driver.Path, item.HostRequirements); err != nil { + return lockInfo{}, err + } legacyProofMatches := true if item.LockEntry != nil && item.LockEntry.Legacy != nil && samePlatformTarget(item.LockEntry.Legacy.Platform, config.PlatformTuple()) { if err := verifyLegacyLibraryProof(*item.LockEntry, config.PlatformTuple(), item.InstalledLibraryHash); err != nil { @@ -1173,11 +1291,12 @@ func lockEntryForItem(item installItem) (lockInfo, error) { Version: item.Package.Version.String(), Source: resolution.SourceSpec{Type: source.Type, Reference: source.URL}, Artifacts: []resolution.Artifact{{ - Target: target, - Format: "tar.gz", - Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: item.Package.Path.String()}, - Hash: hash, - Size: &size, + Target: target, + Format: item.ArtifactFormat, + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: item.Package.Path.String()}, + Hash: hash, + Size: &size, + HostRequirements: cloneHostRequirements(item.HostRequirements), }}, } candidate, err := lockInfoFromResolvedRelease(item.Driver.Path, release) diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index 7f9ec2b0..fd42fe18 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -37,6 +37,8 @@ import ( "github.com/columnar-tech/dbc/config" "github.com/columnar-tech/dbc/internal/fslock" "github.com/columnar-tech/dbc/internal/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestSyncProgressPercentTracksCompletedItems(t *testing.T) { @@ -48,6 +50,20 @@ func TestSyncProgressPercentTracksCompletedItems(t *testing.T) { } } +func TestFreshRegistryInstallItemDefaultsToTarGZWithoutHostRequirements(t *testing.T) { + drivers, err := getTestDriverRegistry() + require.NoError(t, err) + model := syncModel{ + LockFilePath: filepath.Join(t.TempDir(), "dbc.lock"), + driverIndex: drivers, + } + items, err := model.createInstallList(DriversList{Drivers: map[string]driverSpec{"test-driver-1": {}}}) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "tar.gz", items[0].ArtifactFormat) + assert.Empty(t, items[0].HostRequirements) +} + func TestAcquireSyncProjectLockDeadlineIsContentionButCancelIsNot(t *testing.T) { lockPath := filepath.Join(t.TempDir(), ".dbc.project.lock") held, err := fslock.Acquire(lockPath, time.Second) From 5620cbdd752967b030994514a57af4e1ec1186a7 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 04:56:05 +0000 Subject: [PATCH 064/115] fix: align source-aware CLI contracts --- cmd/dbc/add.go | 21 ++++++++++++-- cmd/dbc/add_test.go | 26 +++++++++++++++++ cmd/dbc/driver_list_test.go | 8 +++--- cmd/dbc/registry_wiring_test.go | 49 ++++++++++++++++++++++++++++++++- cmd/dbc/sync.go | 19 +++++++++++-- cmd/dbc/sync_test.go | 31 +++++++++++++++++++++ source.go | 28 ++++++++++++++++++- source_test.go | 13 +++++++-- 8 files changed, 182 insertions(+), 13 deletions(-) diff --git a/cmd/dbc/add.go b/cmd/dbc/add.go index a50001a5..4f58a030 100644 --- a/cmd/dbc/add.go +++ b/cmd/dbc/add.go @@ -21,11 +21,13 @@ import ( "fmt" "os" "path/filepath" + "reflect" "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/pelletier/go-toml/v2" @@ -160,6 +162,12 @@ func (m addModel) Init() tea.Cmd { f.Close() readLock.Release() + for _, spec := range specs { + if existing, ok := m.list.Drivers[spec.Name]; ok && existing.Source != nil && existing.Source.Type != dbc.DriverSourceRegistry { + return fmt.Errorf("cannot update driver %q with %s source using `dbc add` yet", spec.Name, existing.Source.Type) + } + } + if err := applyProjectRegistries(m.list); err != nil { return err } @@ -287,13 +295,22 @@ func (m addModel) Init() tea.Cmd { } for _, spec := range specs { updated := m.list.Drivers[spec.Name] - if latest, ok := current.Drivers[spec.Name]; ok { + latest := current.Drivers[spec.Name] + initial := m.list.Drivers[spec.Name] + if !reflect.DeepEqual(initial.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 - // from the locked re-read in case it changed during registry lookup. + // 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) if err != nil { diff --git a/cmd/dbc/add_test.go b/cmd/dbc/add_test.go index 6baf9ef9..958f0696 100644 --- a/cmd/dbc/add_test.go +++ b/cmd/dbc/add_test.go @@ -208,6 +208,32 @@ func TestAddUpdatingDriverPreservesSource(t *testing.T) { assert.Equal(t, "allow", got.Prerelease) } +func TestAddRejectsPackslipVersionUpdateWithoutMutatingProject(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/test-driver'\n" + require.NoError(t, os.WriteFile(path, []byte(initial), 0o644)) + + msg := runTeaCmdToCompletion(t, AddCmd{ + Path: path, + Driver: []string{"test-driver-1>=1.0.0"}, + }.GetModelCustom(testBaseModel()).(interface { + Init() tea.Cmd + Update(tea.Msg) (tea.Model, tea.Cmd) + })) + err, ok := msg.(error) + require.True(t, ok, "registry-centric add must reject an existing Packslip source") + assert.ErrorContains(t, err, "cannot update driver \"test-driver-1\" with packslip source") + + data, readErr := os.ReadFile(path) + require.NoError(t, readErr) + assert.Equal(t, initial, string(data), "rejected 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_test.go b/cmd/dbc/driver_list_test.go index 774236eb..ce76ceb0 100644 --- a/cmd/dbc/driver_list_test.go +++ b/cmd/dbc/driver_list_test.go @@ -94,8 +94,8 @@ func TestDriverSourceProjectConfigRoundTrip(t *testing.T) { }, { name: "packslip project", - toml: "[drivers.example]\nversion = '1.2.3'\n[drivers.example.source]\ntype = 'packslip'\nproject = 'owner/project'\n", - want: dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "owner/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", @@ -132,7 +132,7 @@ func TestDriverSourceProjectConfigValidation(t *testing.T) { {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 = 'owner/project'", wantErr: "path source contains fields for another source type"}, + {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 { @@ -177,7 +177,7 @@ func TestPackslipSourceRequiresExactStrictSemVer(t *testing.T) { if tt.version != "" { contents += "version = '" + tt.version + "'\n" } - contents += "[drivers.example.source]\ntype = 'packslip'\nproject = 'owner/project'\n" + contents += "[drivers.example.source]\ntype = 'packslip'\nproject = 'github.com/owner/project'\n" var list DriversList err := toml.Unmarshal([]byte(contents), &list) diff --git a/cmd/dbc/registry_wiring_test.go b/cmd/dbc/registry_wiring_test.go index 95737c66..f0ed5e59 100644 --- a/cmd/dbc/registry_wiring_test.go +++ b/cmd/dbc/registry_wiring_test.go @@ -739,6 +739,53 @@ 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 TestAddAbortsOnConcurrentInvalidDriverSource(t *testing.T) { t.Setenv("DBC_BASE_URL", "") @@ -772,7 +819,7 @@ func TestAddAbortsOnConcurrentInvalidDriverSource(t *testing.T) { "[drivers.invalid.source]\n" + "type = 'path'\n" + "path = '../packages/driver.tar.gz'\n" + - "project = 'owner/project'\n" + "project = 'github.com/owner/project'\n" require.NoError(t, os.WriteFile(tomlPath, []byte(concurrentContent), 0o644)) close(unblock) diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 4d85b1ce..717a7c44 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -33,6 +33,7 @@ import ( "charm.land/bubbles/v2/spinner" 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/fslock" @@ -476,6 +477,9 @@ func lockVersionSatisfiesSpec(entry lockInfo, spec driverSpec) bool { return false } if spec.Version != nil { + if spec.Source != nil && spec.Source.Type == dbc.DriverSourcePackslip { + return entry.Version.String() == spec.Version.String() + } // An explicit constraint can name a prerelease directly. Let semver's // constraint evaluation decide whether that locked version is allowed. return spec.Version.Check(entry.Version) @@ -627,7 +631,7 @@ func (s syncModel) persistCandidateLock(lock LockFile) error { func canReuseLockedEntry(item installItem) bool { if item.LockEntry == nil || item.LockEntry.Version == nil || item.Package.Version == nil || - !item.LockEntry.Version.Equal(item.Package.Version) { + !packageVersionsMatch(item.LockEntry.Source.Type, item.LockEntry.Version, item.Package.Version) { return false } source, err := packageLockSource(item) @@ -650,6 +654,16 @@ func canReuseLockedEntry(item installItem) bool { item.Package.ArtifactHash == artifact.Hash && *item.Package.ArtifactSize == *artifact.Size } +func packageVersionsMatch(sourceType string, locked, resolved *semver.Version) bool { + if locked == nil || resolved == nil { + return false + } + if sourceType == string(dbc.DriverSourcePackslip) { + return locked.String() == resolved.String() + } + return locked.Equal(resolved) +} + func packageLockSource(item installItem) (lockSource, error) { if item.Driver.Registry == nil || item.Driver.Registry.BaseURL == nil { return lockSource{}, fmt.Errorf("driver %q has no registry identity", item.Driver.Path) @@ -1303,7 +1317,8 @@ func lockEntryForItem(item installItem) (lockInfo, error) { if err != nil { return lockInfo{}, err } - if item.LockEntry == nil || item.LockEntry.Version == nil || !item.LockEntry.Version.Equal(item.Package.Version) { + if item.LockEntry == nil || item.LockEntry.Version == nil || + !packageVersionsMatch(candidate.Source.Type, item.LockEntry.Version, item.Package.Version) { return candidate, nil } if item.LockEntry.Version == nil { diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index fd42fe18..1870d859 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -64,6 +64,37 @@ func TestFreshRegistryInstallItemDefaultsToTarGZWithoutHostRequirements(t *testi assert.Empty(t, items[0].HostRequirements) } +func TestPackslipLockVersionRequiresExactBuildMetadata(t *testing.T) { + requested, err := semver.NewConstraint("1.2.3+foo") + require.NoError(t, err) + entry := lockInfo{Version: semver.MustParse("1.2.3+bar")} + packslipSpec := driverSpec{ + Version: requested, + Source: &dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "github.com/example/driver"}, + } + assert.False(t, lockVersionSatisfiesSpec(entry, packslipSpec), "Packslip lock reuse must compare the complete exact version string") + entry.Version = semver.MustParse("1.2.3+foo") + assert.True(t, lockVersionSatisfiesSpec(entry, packslipSpec)) + + registryConstraint, err := semver.NewConstraint(">=1.2.3") + require.NoError(t, err) + registryEntry := lockInfo{Version: semver.MustParse("1.2.3+bar")} + assert.True(t, lockVersionSatisfiesSpec(registryEntry, driverSpec{Version: registryConstraint}), + "registry constraints retain existing SemVer precedence behavior") +} + +func TestPackageVersionIdentityDependsOnSource(t *testing.T) { + locked := semver.MustParse("1.2.3+foo") + resolvedSame := semver.MustParse("1.2.3+foo") + resolvedDifferent := semver.MustParse("1.2.3+bar") + + assert.True(t, packageVersionsMatch("packslip", locked, resolvedSame)) + assert.False(t, packageVersionsMatch("packslip", locked, resolvedDifferent), + "Packslip artifact identity includes build metadata") + assert.True(t, packageVersionsMatch("registry", locked, resolvedDifferent), + "registry keeps the existing SemVer equality semantics") +} + func TestAcquireSyncProjectLockDeadlineIsContentionButCancelIsNot(t *testing.T) { lockPath := filepath.Join(t.TempDir(), ".dbc.project.lock") held, err := fslock.Acquire(lockPath, time.Second) diff --git a/source.go b/source.go index 7b27b6ac..84438811 100644 --- a/source.go +++ b/source.go @@ -17,6 +17,7 @@ package dbc import ( "errors" "fmt" + "strings" ) // DriverSourceType identifies the source declared for a driver in dbc.toml. @@ -39,7 +40,8 @@ type DriverSource struct { } // Validate checks that the source's type and source-specific field agree. -// URL, project identity, and path resolution are validated by their adapters. +// Registry URL and path resolution remain the responsibility of their +// adapters; Packslip projects use the currently supported GitHub host path. func (s DriverSource) Validate() error { switch s.Type { case DriverSourceRegistry: @@ -53,6 +55,9 @@ func (s DriverSource) Validate() error { if s.Project == "" { return errors.New("packslip source has no project") } + if err := validatePackslipProject(s.Project); err != nil { + return err + } if s.URL != "" || s.Path != "" { return errors.New("packslip source contains fields for another source type") } @@ -71,3 +76,24 @@ func (s DriverSource) Validate() error { } return nil } + +func validatePackslipProject(project string) error { + if project != strings.TrimSpace(project) || strings.Contains(project, "://") || strings.HasSuffix(project, "/") { + return fmt.Errorf("packslip project must be a GitHub host path without a URL scheme: %q", project) + } + parts := strings.Split(project, "/") + if len(parts) < 3 || !strings.EqualFold(parts[0], "github.com") { + return fmt.Errorf("packslip project must use github.com/owner/repo[/tool...]: %q", project) + } + for _, part := range parts { + if part == "" || part == "." || part == ".." { + return fmt.Errorf("packslip project contains an invalid path segment: %q", project) + } + for _, ch := range part { + if !(ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' || strings.ContainsRune("._-", ch)) { + return fmt.Errorf("packslip project contains an invalid path segment: %q", project) + } + } + } + return nil +} diff --git a/source_test.go b/source_test.go index 1db066bf..71f2cb88 100644 --- a/source_test.go +++ b/source_test.go @@ -29,14 +29,21 @@ func TestDriverSourceValidation(t *testing.T) { wantErr string }{ {name: "registry", source: DriverSource{Type: DriverSourceRegistry, URL: "https://registry.example.test"}}, - {name: "packslip", source: DriverSource{Type: DriverSourcePackslip, Project: "owner/project"}}, + {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 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: "owner/project", URL: "https://registry.example.test"}, wantErr: "packslip source contains fields for another source type"}, + {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"}, } @@ -56,7 +63,7 @@ func TestDriverSourceValidation(t *testing.T) { func TestDriverSourceJSONRoundTrip(t *testing.T) { sources := []DriverSource{ {Type: DriverSourceRegistry, URL: "https://registry.example.test"}, - {Type: DriverSourcePackslip, Project: "owner/project"}, + {Type: DriverSourcePackslip, Project: "github.com/owner/project"}, {Type: DriverSourcePath, Path: "../packages/driver.tar.gz"}, } for _, source := range sources { From 959127cf3d0e1b7ecd67ff9bcaf40e8f59f3985b Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 05:08:31 +0000 Subject: [PATCH 065/115] fix: verify source identity before reuse --- cmd/dbc/add.go | 24 +++++++++++-- cmd/dbc/registry_wiring_test.go | 60 +++++++++++++++++++++++++++++++++ cmd/dbc/sync.go | 31 +++++++++++++++++ cmd/dbc/sync_test.go | 60 +++++++++++++++++++++++++++++++-- 4 files changed, 170 insertions(+), 5 deletions(-) diff --git a/cmd/dbc/add.go b/cmd/dbc/add.go index 4f58a030..4a38c569 100644 --- a/cmd/dbc/add.go +++ b/cmd/dbc/add.go @@ -96,6 +96,11 @@ type addDoneMsg struct { resolvedPath string } +type addTargetSnapshot struct { + present bool + source *dbc.DriverSource +} + type addModel struct { baseModel @@ -159,6 +164,16 @@ func (m addModel) Init() tea.Cmd { readLock.Release() return err } + 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 + } f.Close() readLock.Release() @@ -295,9 +310,12 @@ func (m addModel) Init() tea.Cmd { } for _, spec := range specs { updated := m.list.Drivers[spec.Name] - latest := current.Drivers[spec.Name] - initial := m.list.Drivers[spec.Name] - if !reflect.DeepEqual(initial.Source, latest.Source) { + 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 !reflect.DeepEqual(original.source, latest.Source) { return fmt.Errorf("driver %q source changed while resolving drivers; please retry `dbc add`", spec.Name) } if latest.Source != nil { diff --git a/cmd/dbc/registry_wiring_test.go b/cmd/dbc/registry_wiring_test.go index f0ed5e59..338a5a2e 100644 --- a/cmd/dbc/registry_wiring_test.go +++ b/cmd/dbc/registry_wiring_test.go @@ -786,6 +786,66 @@ func TestAddAbortsWhenTargetSourceChangesDuringRegistryLookup(t *testing.T) { 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", "") diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 717a7c44..8a167b80 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -473,6 +473,9 @@ func (s syncModel) registryDiscoveryNeeded(list DriversList) (bool, error) { } func lockVersionSatisfiesSpec(entry lockInfo, spec driverSpec) bool { + if !driverSourceMatchesLock(spec.Source, entry.Source) { + return false + } if entry.Version == nil { return false } @@ -487,6 +490,34 @@ func lockVersionSatisfiesSpec(entry lockInfo, spec driverSpec) bool { return entry.Version.Prerelease() == "" || spec.Prerelease == "allow" } +func driverSourceMatchesLock(source *dbc.DriverSource, locked lockSource) bool { + if source == nil { + return locked.Type == string(dbc.DriverSourceRegistry) + } + switch source.Type { + case dbc.DriverSourceRegistry: + return locked.Type == string(dbc.DriverSourceRegistry) && locked.URL == source.URL + case dbc.DriverSourcePackslip: + return locked.Type == string(dbc.DriverSourcePackslip) && + canonicalPackslipProject(locked.Project) == canonicalPackslipProject(source.Project) + case dbc.DriverSourcePath: + return locked.Type == string(dbc.DriverSourcePath) && locked.Path == source.Path + default: + return false + } +} + +func canonicalPackslipProject(project string) string { + parts := strings.Split(project, "/") + if len(parts) < 3 { + return project + } + for i := 0; i < 3; i++ { + parts[i] = strings.ToLower(parts[i]) + } + return strings.Join(parts, "/") +} + func installItemFromLockedArtifact(name string, entry lockInfo, artifact lockArtifact) (installItem, error) { if artifact.Location.Kind != resolution.ArtifactLocationURL || artifact.Location.Value == "" { return installItem{}, fmt.Errorf("locked registry artifact for %s is not a remote URL artifact", name) diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index 1870d859..c4f15b11 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -67,7 +67,10 @@ func TestFreshRegistryInstallItemDefaultsToTarGZWithoutHostRequirements(t *testi func TestPackslipLockVersionRequiresExactBuildMetadata(t *testing.T) { requested, err := semver.NewConstraint("1.2.3+foo") require.NoError(t, err) - entry := lockInfo{Version: semver.MustParse("1.2.3+bar")} + entry := lockInfo{ + Version: semver.MustParse("1.2.3+bar"), + Source: lockSource{Type: "packslip", Project: "github.com/example/driver"}, + } packslipSpec := driverSpec{ Version: requested, Source: &dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "github.com/example/driver"}, @@ -78,11 +81,64 @@ func TestPackslipLockVersionRequiresExactBuildMetadata(t *testing.T) { registryConstraint, err := semver.NewConstraint(">=1.2.3") require.NoError(t, err) - registryEntry := lockInfo{Version: semver.MustParse("1.2.3+bar")} + registryEntry := lockInfo{ + Version: semver.MustParse("1.2.3+bar"), + Source: lockSource{Type: "registry", URL: "https://registry.example.test"}, + } assert.True(t, lockVersionSatisfiesSpec(registryEntry, driverSpec{Version: registryConstraint}), "registry constraints retain existing SemVer precedence behavior") } +func TestLockVersionSatisfiesSpecRequiresMatchingSource(t *testing.T) { + version := semver.MustParse("1.2.3") + packslipConstraint, err := semver.NewConstraint("1.2.3") + require.NoError(t, err) + packslipSpec := driverSpec{ + Version: packslipConstraint, + Source: &dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "github.com/Example/Driver"}, + } + assert.False(t, lockVersionSatisfiesSpec(lockInfo{ + Version: version, + Source: lockSource{Type: "registry", URL: "https://registry.example.test"}, + }, packslipSpec), "a same-version registry lock cannot satisfy a Packslip declaration") + assert.True(t, lockVersionSatisfiesSpec(lockInfo{ + Version: version, + Source: lockSource{Type: "packslip", Project: "github.com/example/driver"}, + }, packslipSpec), "GitHub host, owner, and repo are canonicalized consistently") + + registryConstraint, err := semver.NewConstraint("1.2.3") + require.NoError(t, err) + defaultRegistrySpec := driverSpec{Version: registryConstraint} + assert.True(t, lockVersionSatisfiesSpec(lockInfo{ + Version: version, + Source: lockSource{Type: "registry", URL: "https://custom.example.test"}, + }, defaultRegistrySpec), "legacy/default registry declarations do not pin an undeclared URL") + assert.False(t, lockVersionSatisfiesSpec(lockInfo{ + Version: version, + Source: lockSource{Type: "packslip", Project: "github.com/example/driver"}, + }, defaultRegistrySpec)) + + explicitRegistry := &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "https://registry.example.test"} + assert.True(t, lockVersionSatisfiesSpec(lockInfo{ + Version: version, + Source: lockSource{Type: "registry", URL: explicitRegistry.URL}, + }, driverSpec{Version: registryConstraint, Source: explicitRegistry})) + assert.False(t, lockVersionSatisfiesSpec(lockInfo{ + Version: version, + Source: lockSource{Type: "registry", URL: "https://other.example.test"}, + }, driverSpec{Version: registryConstraint, Source: explicitRegistry})) + + pathSource := &dbc.DriverSource{Type: dbc.DriverSourcePath, Path: "./packages/driver.tgz"} + assert.True(t, lockVersionSatisfiesSpec(lockInfo{ + Version: version, + Source: lockSource{Type: "path", Path: pathSource.Path}, + }, driverSpec{Version: registryConstraint, Source: pathSource})) + assert.False(t, lockVersionSatisfiesSpec(lockInfo{ + Version: version, + Source: lockSource{Type: "path", Path: "./packages/other.tgz"}, + }, driverSpec{Version: registryConstraint, Source: pathSource})) +} + func TestPackageVersionIdentityDependsOnSource(t *testing.T) { locked := semver.MustParse("1.2.3+foo") resolvedSame := semver.MustParse("1.2.3+foo") From 4161cada099ea99083f3ad5755a0ae851537f6d6 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 05:24:11 +0000 Subject: [PATCH 066/115] fix: fail closed before source dispatch --- cmd/dbc/sync.go | 26 +++++++++++-- cmd/dbc/sync_test.go | 91 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 8a167b80..e4697571 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -368,10 +368,18 @@ func (s syncModel) createInstallList(list DriversList) ([]installItem, error) { // construct our list of driver+version to install var items []installItem for name, spec := range list.Drivers { + if err := requireRegistrySyncSource(name, spec); err != nil { + return nil, err + } var info lockInfo if lf.lockinfo != nil { info = lf.lockinfo[name] } + if lf.Version == lockFileVersion && info.Version != nil && !driverSourceMatchesLock(spec.Source, info.Source) { + // A lock entry for another source must not influence fallback + // resolution or be merged into the new source's candidate snapshot. + info = lockInfo{} + } if lf.Version == lockFileVersion && info.Version != nil { if info.Source.Type != "registry" { return nil, fmt.Errorf("locked source type %q for driver %q is not supported by sync yet; source integration will follow", info.Source.Type, name) @@ -441,6 +449,11 @@ func (s syncModel) createInstallList(list DriversList) ([]installItem, error) { } func (s syncModel) registryDiscoveryNeeded(list DriversList) (bool, error) { + for name, spec := range list.Drivers { + if err := requireRegistrySyncSource(name, spec); err != nil { + return false, err + } + } lf, err := loadLockFile(s.LockFilePath) if errors.Is(err, fs.ErrNotExist) { return true, nil @@ -453,10 +466,8 @@ func (s syncModel) registryDiscoveryNeeded(list DriversList) (bool, error) { if lf.Version != lockFileVersion || !ok || entry.Version == nil { return true, nil } - // Non-registry source adapters are outside this sync slice. Their error - // is reported by createInstallList without contacting the registry. - if entry.Source.Type != "registry" { - continue + if !driverSourceMatchesLock(spec.Source, entry.Source) { + return true, nil } if !lockVersionSatisfiesSpec(entry, spec) { return true, nil @@ -472,6 +483,13 @@ func (s syncModel) registryDiscoveryNeeded(list DriversList) (bool, error) { return false, nil } +func requireRegistrySyncSource(name string, spec driverSpec) error { + if spec.Source == nil || spec.Source.Type == dbc.DriverSourceRegistry { + return nil + } + return fmt.Errorf("source type %q for driver %q is not supported by sync yet; source integration will follow", spec.Source.Type, name) +} + func lockVersionSatisfiesSpec(entry lockInfo, spec driverSpec) bool { if !driverSourceMatchesLock(spec.Source, entry.Source) { return false diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index c4f15b11..81258a19 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -23,6 +23,7 @@ import ( "errors" "fmt" "io" + "net/url" "os" "path/filepath" "strings" @@ -64,6 +65,96 @@ func TestFreshRegistryInstallItemDefaultsToTarGZWithoutHostRequirements(t *testi assert.Empty(t, items[0].HostRequirements) } +func TestSyncRejectsNonRegistrySourcesBeforeRegistryLookup(t *testing.T) { + sources := []struct { + name string + toml string + }{ + { + name: "packslip", + toml: "[drivers.test-driver-1]\nversion = '1.2.3'\n" + + "[drivers.test-driver-1.source]\ntype = 'packslip'\nproject = 'github.com/example/test-driver'\n", + }, + { + name: "path", + toml: "[drivers.test-driver-1]\nversion = '1.2.3'\n" + + "[drivers.test-driver-1.source]\ntype = 'path'\npath = '../packages/test-driver.tar.gz'\n", + }, + } + for _, source := range sources { + for _, withRegistryLock := range []bool{false, true} { + name := source.name + "/without-lock" + if withRegistryLock { + name = source.name + "/registry-lock" + } + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + projectPath := filepath.Join(dir, "dbc.toml") + lockPath := filepath.Join(dir, "dbc.lock") + require.NoError(t, os.WriteFile(projectPath, []byte(source.toml), 0o600)) + if withRegistryLock { + entry := testRegistryLockEntryForPlatform(config.PlatformTuple()) + entry.Name = "test-driver-1" + require.NoError(t, writeLockFileAtomic(lockPath, LockFile{Version: lockFileVersion, Drivers: []lockInfo{entry}})) + } + + var registryCalls atomic.Int32 + model := SyncCmd{Path: projectPath}.GetModelCustom(baseModel{ + getDriverRegistry: func() ([]dbc.Driver, error) { + registryCalls.Add(1) + return getTestDriverRegistry() + }, + downloadPkg: downloadTestPkg, + }).(syncModel) + defer model.worker.cancel() + + result := model.runSyncWorker(model.worker) + require.ErrorContains(t, result.err, "source type \""+source.name+"\"") + assert.Zero(t, registryCalls.Load(), "unsupported source declarations must fail before registry discovery") + }) + } + } +} + +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" + 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} + needsRegistry, err := model.registryDiscoveryNeeded(list) + require.NoError(t, err) + assert.True(t, needsRegistry, "a lock for registry A cannot be reused for registry B") + + items, err := model.createInstallList(list) + 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") + resolvedSource, err := packageLockSource(items[0]) + require.NoError(t, err) + assert.Equal(t, registryBURL.String(), resolvedSource.URL) +} + func TestPackslipLockVersionRequiresExactBuildMetadata(t *testing.T) { requested, err := semver.NewConstraint("1.2.3+foo") require.NoError(t, err) From 3a2ad61986f6c54fe82e1377db36e0d94b5ccae6 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 05:33:50 +0000 Subject: [PATCH 067/115] fix: honor declared registry sources --- cmd/dbc/sync.go | 27 ++++++++++++- cmd/dbc/sync_test.go | 92 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index e4697571..416be03d 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -401,8 +401,9 @@ func (s syncModel) createInstallList(list DriversList) ([]installItem, error) { } } - // locate the driver info in the CDN driver registry index - drv, err := findDriver(name, s.driverIndex) + // Locate the driver in its declared registry when the project pins one. + // Legacy/default registry entries retain the existing registry ordering. + drv, err := findDriverInDeclaredRegistry(name, s.driverIndex, spec.Source) if err != nil { return nil, wrapWithRegistryContext(err, s.registryErrors) } @@ -490,6 +491,28 @@ func requireRegistrySyncSource(name string, spec driverSpec) error { return fmt.Errorf("source type %q for driver %q is not supported by sync yet; source integration will follow", spec.Source.Type, name) } +func findDriverInDeclaredRegistry(name string, drivers []dbc.Driver, source *dbc.DriverSource) (dbc.Driver, error) { + if source == nil || source.Type != dbc.DriverSourceRegistry { + return findDriver(name, drivers) + } + + declaredURL := normalizeRegistryURL(source.URL) + var matches []dbc.Driver + for _, driver := range drivers { + if driver.Registry == nil || driver.Registry.BaseURL == nil { + continue + } + if normalizeRegistryURL(driver.Registry.BaseURL.String()) == declaredURL { + matches = append(matches, driver) + } + } + driver, err := findDriver(name, matches) + if err != nil { + return dbc.Driver{}, fmt.Errorf("driver %q was not found in declared registry %q", name, source.URL) + } + return driver, nil +} + func lockVersionSatisfiesSpec(entry lockInfo, spec driverSpec) bool { if !driverSourceMatchesLock(spec.Source, entry.Source) { return false diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index 81258a19..d97b4a6b 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -155,6 +155,98 @@ func TestRegistrySourceChangeDiscardsOldLockEntryBeforeFallback(t *testing.T) { assert.Equal(t, registryBURL.String(), resolvedSource.URL) } +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/", + } + items, err := model.createInstallList(DriversList{Drivers: map[string]driverSpec{ + "test-driver-1": {Source: declaredSource}, + }}) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "https://registry-b.example.test", items[0].Driver.Registry.BaseURL.String()) + + resolvedSource, err := packageLockSource(items[0]) + require.NoError(t, err) + assert.Equal(t, "https://registry-b.example.test", resolvedSource.URL) + expected, _, err := expectedRegistryPackageMetadata(items[0].Package) + 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, + }, + } + items, err := model.createInstallList(DriversList{Drivers: map[string]driverSpec{ + "test-driver-1": {Source: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: tt.url}}, + }}) + 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}, + } + items, err := model.createInstallList(DriversList{Drivers: map[string]driverSpec{ + "test-driver-1": {}, + }}) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "https://registry-a.example.test", items[0].Driver.Registry.BaseURL.String()) +} + func TestPackslipLockVersionRequiresExactBuildMetadata(t *testing.T) { requested, err := semver.NewConstraint("1.2.3+foo") require.NoError(t, err) From f5e12140ff11f60570be7e4ded313b6d8b786735 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 05:40:41 +0000 Subject: [PATCH 068/115] fix: normalize registry source identity --- cmd/dbc/sync.go | 3 ++- cmd/dbc/sync_test.go | 54 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 416be03d..b182f246 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -537,7 +537,8 @@ func driverSourceMatchesLock(source *dbc.DriverSource, locked lockSource) bool { } switch source.Type { case dbc.DriverSourceRegistry: - return locked.Type == string(dbc.DriverSourceRegistry) && locked.URL == source.URL + return locked.Type == string(dbc.DriverSourceRegistry) && + normalizeRegistryURL(locked.URL) == normalizeRegistryURL(source.URL) case dbc.DriverSourcePackslip: return locked.Type == string(dbc.DriverSourcePackslip) && canonicalPackslipProject(locked.Project) == canonicalPackslipProject(source.Project) diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index d97b4a6b..17a819df 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -304,12 +304,28 @@ func TestLockVersionSatisfiesSpecRequiresMatchingSource(t *testing.T) { explicitRegistry := &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "https://registry.example.test"} assert.True(t, lockVersionSatisfiesSpec(lockInfo{ Version: version, - Source: lockSource{Type: "registry", URL: explicitRegistry.URL}, + Source: lockSource{Type: "registry", URL: "https://registry.example.test/"}, }, driverSpec{Version: registryConstraint, Source: explicitRegistry})) + caseInsensitiveRegistry := &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "HTTPS://REGISTRY.EXAMPLE.TEST/"} + assert.True(t, lockVersionSatisfiesSpec(lockInfo{ + Version: version, + Source: lockSource{Type: "registry", URL: explicitRegistry.URL}, + }, driverSpec{Version: registryConstraint, Source: caseInsensitiveRegistry}), + "scheme/host case and trailing slash use registry URL normalization") assert.False(t, lockVersionSatisfiesSpec(lockInfo{ Version: version, Source: lockSource{Type: "registry", URL: "https://other.example.test"}, }, driverSpec{Version: registryConstraint, Source: explicitRegistry})) + for _, differentURL := range []string{ + "https://registry.example.test/tenant", + "https://registry.example.test?tenant=b", + } { + assert.False(t, lockVersionSatisfiesSpec(lockInfo{ + Version: version, + Source: lockSource{Type: "registry", URL: differentURL}, + }, driverSpec{Version: registryConstraint, Source: explicitRegistry}), + "registry path/query differences remain part of source identity: %s", differentURL) + } pathSource := &dbc.DriverSource{Type: dbc.DriverSourcePath, Path: "./packages/driver.tgz"} assert.True(t, lockVersionSatisfiesSpec(lockInfo{ @@ -322,6 +338,42 @@ func TestLockVersionSatisfiesSpecRequiresMatchingSource(t *testing.T) { }, driverSpec{Version: registryConstraint, Source: pathSource})) } +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} + needsRegistry, err := model.registryDiscoveryNeeded(list) + require.NoError(t, err) + assert.False(t, needsRegistry, "normalized explicit registry identity should permit offline lock replay") + + items, err := model.createInstallList(list) + 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") + resolvedSource, err := packageLockSource(items[0]) + require.NoError(t, err) + assert.Equal(t, "https://registry.example.test", resolvedSource.URL) + expected, _, err := expectedRegistryPackageMetadata(items[0].Package) + require.NoError(t, err) + assert.Equal(t, "https://registry.example.test", expected.SourceIdentity) +} + func TestPackageVersionIdentityDependsOnSource(t *testing.T) { locked := semver.MustParse("1.2.3+foo") resolvedSame := semver.MustParse("1.2.3+foo") From 51d62fba85b929ba471633ecfbae85f301797aee Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 05:55:32 +0000 Subject: [PATCH 069/115] feat: require signed dbc package declarations --- cmd/dbc/lockfile.go | 4 + cmd/dbc/lockfile_v2.go | 21 +++- cmd/dbc/lockfile_v2_test.go | 30 ++++- internal/packslip/dbc_extension.go | 107 +++++++++++++++++ internal/packslip/dbc_extension_test.go | 128 +++++++++++++++++++++ internal/packslip/model.go | 54 +++++---- internal/packslip/model_test.go | 49 +++++--- internal/packslip/resolver_native.go | 26 ++--- internal/packslip/resolver_native_test.go | 30 ++++- internal/packslip/types.go | 6 +- internal/resolution/types.go | 10 +- internal/resolution/types_test.go | 3 + internal/sourceresolution/resolver.go | 13 ++- internal/sourceresolution/resolver_test.go | 12 +- 14 files changed, 427 insertions(+), 66 deletions(-) create mode 100644 internal/packslip/dbc_extension.go create mode 100644 internal/packslip/dbc_extension_test.go diff --git a/cmd/dbc/lockfile.go b/cmd/dbc/lockfile.go index c302dfa8..9d54aa84 100644 --- a/cmd/dbc/lockfile.go +++ b/cmd/dbc/lockfile.go @@ -78,6 +78,7 @@ type lockEvidence struct { type lockArtifact struct { Target resolution.Target `toml:"target"` Format string `toml:"format,omitempty"` + PackageVersion int `toml:"package_version,omitempty"` Location resolution.ArtifactLocation `toml:"location"` Hash string `toml:"hash"` Size *int64 `toml:"size"` @@ -293,6 +294,9 @@ 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 artifact.PackageVersion != 0 && artifact.PackageVersion != 2 { + return fmt.Errorf("artifact %d has unsupported dbc package version %d", i, artifact.PackageVersion) + } if artifact.LegacyURL != nil || artifact.LegacyPath != nil { return fmt.Errorf("artifact %d uses obsolete direct URL or path fields; use the location table", i) } diff --git a/cmd/dbc/lockfile_v2.go b/cmd/dbc/lockfile_v2.go index e65e96aa..b926899f 100644 --- a/cmd/dbc/lockfile_v2.go +++ b/cmd/dbc/lockfile_v2.go @@ -39,9 +39,13 @@ var ( 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) } @@ -102,6 +106,9 @@ func lockInfoFromResolvedRelease(name string, release resolution.ResolvedRelease return lockInfo{}, fmt.Errorf("unsupported resolved source type %q", release.Source.Type) } for _, artifact := range release.Artifacts { + if release.Source.Type == "packslip" && artifact.PackageVersion != 2 { + return lockInfo{}, fmt.Errorf("packslip artifact for %s has no supported dbc package_version declaration", name) + } entry.Artifacts = append(entry.Artifacts, lockArtifactFromResolved(artifact)) } if err := validateLockInfo(entry); err != nil { @@ -114,6 +121,7 @@ func lockArtifactFromResolved(artifact resolution.Artifact) lockArtifact { result := lockArtifact{ Target: resolution.CanonicalTarget(artifact.Target), Format: artifact.Format, + PackageVersion: artifact.PackageVersion, Location: artifact.Location, Hash: artifact.Hash, Size: cloneInt64(artifact.Size), @@ -184,6 +192,7 @@ func (d lockInfo) resolvedRelease() resolution.ResolvedRelease { resolved := resolution.Artifact{ Target: artifact.Target, Format: artifact.Format, + PackageVersion: artifact.PackageVersion, Location: artifact.Location, Hash: artifact.Hash, Size: cloneInt64(artifact.Size), @@ -316,16 +325,20 @@ func refreshLockEntry(existing, refreshed lockInfo) (lockInfo, error) { for _, candidate := range refreshed.Artifacts { candidateIdentity := artifactSelectorIdentity(candidate) found := false - for _, prior := range merged.Artifacts { + for index, 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) || + (prior.PackageVersion != 0 && candidate.PackageVersion != 0 && prior.PackageVersion != candidate.PackageVersion) || !reflect.DeepEqual(canonicalHostRequirements(prior.HostRequirements), canonicalHostRequirements(candidate.HostRequirements)) { return lockInfo{}, fmt.Errorf("metadata refresh contradicts locked artifact %q", candidateIdentity) } + if merged.Artifacts[index].PackageVersion == 0 { + merged.Artifacts[index].PackageVersion = candidate.PackageVersion + } break } if !found { @@ -398,6 +411,12 @@ func selectLockedArtifact(entry lockInfo, platform string, locked bool) (lockArt return lockArtifact{}, fmt.Errorf("%w for %s on %s", ErrArtifactAmbiguous, entry.Name, platform) } if len(matches) == 1 { + if entry.Source.Type == "packslip" && matches[0].PackageVersion != 2 { + return lockArtifact{}, &LockRefreshRequiredError{ + DriverID: entry.Name, Platform: platform, + Reason: "locked Packslip artifact is missing its signed dbc package_version declaration", + } + } return matches[0], nil } if locked { diff --git a/cmd/dbc/lockfile_v2_test.go b/cmd/dbc/lockfile_v2_test.go index ac357fe4..004ce405 100644 --- a/cmd/dbc/lockfile_v2_test.go +++ b/cmd/dbc/lockfile_v2_test.go @@ -65,7 +65,7 @@ func testLockEntry() lockInfo { }}, Artifacts: []lockArtifact{ { - Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu", Variant: "v1"}, Format: "tar.gz", + Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu", Variant: "v1"}, Format: "tar.gz", PackageVersion: 2, 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"}, @@ -73,7 +73,7 @@ func testLockEntry() lockInfo { }, }, { - Target: resolution.Target{OS: "macos", Arch: "arm64"}, Format: "tar.gz", + Target: resolution.Target{OS: "macos", Arch: "arm64"}, Format: "tar.gz", PackageVersion: 2, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/macos.tar.gz"}, Hash: "sha256:" + strings.Repeat("b", 64), Size: int64Pointer(256), }, }, @@ -144,6 +144,7 @@ func TestLockFileV2DeterministicRoundTripPreservesArtifactMetadata(t *testing.T) 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]") + assert.Contains(t, string(first), "package_version = 2") var zeroSizePreserved bool for _, artifact := range loaded.lockinfo["local"].Artifacts { if artifact.Target == (resolution.Target{OS: "windows", Arch: "amd64"}) { @@ -209,6 +210,20 @@ func TestLockFileV2AllowsPartialArtifactSetAndReplayNeedsNoDiscovery(t *testing. assert.Contains(t, err.Error(), "locked mode") } +func TestPackslipLockWithoutPackageVersionRequiresRefresh(t *testing.T) { + entry := testLockEntry() + entry.Artifacts[0].PackageVersion = 0 // Old prototype Packslip lock did not record this proof. + 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) + _, err = selectLockedArtifact(loaded.lockinfo["example"], "linux_amd64_gnu_v1", false) + var refreshErr *LockRefreshRequiredError + require.ErrorAs(t, err, &refreshErr) + require.Contains(t, err.Error(), "missing its signed dbc package_version declaration") + require.ErrorIs(t, err, ErrLockRefreshRequired) +} + func TestLockFileV2RejectsUnknownVersion(t *testing.T) { path := filepath.Join(t.TempDir(), "dbc.lock") require.NoError(t, os.WriteFile(path, []byte("version = 99\n"), 0o600)) @@ -387,6 +402,15 @@ func TestRefreshRejectsArtifactContradictionAndAllowsNewTarget(t *testing.T) { assert.Len(t, merged.Artifacts, 3) } +func TestRefreshBackfillsMissingPackslipPackageVersion(t *testing.T) { + existing := testLockEntry() + existing.Artifacts[0].PackageVersion = 0 + refreshed := testLockEntry() + merged, err := refreshLockEntry(existing, refreshed) + require.NoError(t, err) + require.Equal(t, 2, merged.Artifacts[0].PackageVersion) +} + func TestRefreshReplacesEvidenceWithLatestCanonicalSnapshot(t *testing.T) { oldIndex := lockEvidence{ Kind: resolution.EvidenceKindReleaseIndex, @@ -1068,7 +1092,7 @@ func testResolvedRelease() resolution.ResolvedRelease { Hash: "sha256:" + strings.Repeat("e", 64), }}, Artifacts: []resolution.Artifact{{ - Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu", Variant: "v1"}, Format: "tar.gz", + Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu", Variant: "v1"}, Format: "tar.gz", PackageVersion: 2, 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"}, diff --git a/internal/packslip/dbc_extension.go b/internal/packslip/dbc_extension.go new file mode 100644 index 00000000..9a96cc82 --- /dev/null +++ b/internal/packslip/dbc_extension.go @@ -0,0 +1,107 @@ +// 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"` +} + +type dbcArtifactExtension struct { + PackageVersion int `json:"package_version"` +} + +// 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 + } + var declaration dbcArtifactExtension + if err := decodeDBCObject(raw, &declaration); err != nil { + return fmt.Errorf("invalid packslip artifact %q extensions.dbc declaration: %w", artifact.Name, err) + } + if declaration.PackageVersion != 2 { + return fmt.Errorf("unsupported dbc package_version %d on packslip artifact %q", declaration.PackageVersion, artifact.Name) + } + artifact.dbcPackageVersion = declaration.PackageVersion + } + 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 := decodeStrict(trimmed, destination); err != nil { + return err + } + 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..91585545 --- /dev/null +++ b/internal/packslip/dbc_extension_test.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 + +package packslip + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDBCReleaseExtensionRequiresExactDeclaration(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: "unexpected end of JSON input"}, + {name: "array", raw: json.RawMessage("[]"), want: "non-null object"}, + {name: "unknown field", raw: json.RawMessage(`{"schema_version":1,"driver_id":"driver","future":true}`), want: "unknown field"}, + {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: "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) +} + +func TestDBCArtifactExtensionRequiresExactPackageMarker(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: "unexpected end of JSON input"}, + {name: "array", raw: json.RawMessage("[]"), want: "non-null object"}, + {name: "unknown field", raw: json.RawMessage(`{"package_version":2,"future":true}`), want: "unknown field"}, + {name: "missing package version", raw: json.RawMessage(`{}`), want: "package_version 0"}, + {name: "unsupported package version", raw: json.RawMessage(`{"package_version":1}`), want: "unsupported dbc package_version"}, + } + 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.Equal(t, 2, release.predicate.Artifacts[0].dbcPackageVersion) +} + +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": mustJSON(dbcArtifactExtension{PackageVersion: 2})}, + } + 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/model.go b/internal/packslip/model.go index 057bec2a..cf47019b 100644 --- a/internal/packslip/model.go +++ b/internal/packslip/model.go @@ -73,18 +73,19 @@ type releasePredicate struct { } 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"` + 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"` + dbcPackageVersion int } type requirements struct { @@ -152,6 +153,7 @@ type parsedRelease struct { predicate releasePredicate byName map[string]subject attested string + driverID string } type parsedList struct { @@ -811,7 +813,14 @@ func normalizeLooseTagVersion(value string) (string, bool) { } func selectArtifact(release *parsedRelease, target Target) (*releaseArtifact, error) { - return selectArtifactByFormats(release, target, supportedArchiveFormats) + eligible := *release + eligible.predicate.Artifacts = make([]releaseArtifact, 0, len(release.predicate.Artifacts)) + for _, artifact := range release.predicate.Artifacts { + if artifact.dbcPackageVersion == 2 && 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, @@ -871,17 +880,17 @@ func supportedArchiveFormat(format string) bool { } // validateSupportedArtifactSet ensures the release inventory contains at -// least one archive format supported by dbc. Selection ambiguity is checked -// only after concrete targets are derived. +// least one artifact that declares dbc package support 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 supportedArchiveFormat(stringValue(release.predicate.Artifacts[i].Format)) { + if release.predicate.Artifacts[i].dbcPackageVersion == 2 && supportedArchiveFormat(stringValue(release.predicate.Artifacts[i].Format)) { count++ } } if count == 0 { - return errors.New("packslip release has no supported tar.gz or tgz artifacts") + return errors.New("packslip release has no dbc artifact with a supported tar.gz or tgz format") } return nil } @@ -962,11 +971,12 @@ func convertArtifact(release *parsedRelease, artifact *releaseArtifact, artifact } 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, + Target: resolution.CanonicalTarget(target), + Format: stringValue(artifact.Format), + PackageVersion: artifact.dbcPackageVersion, + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: artifactURL}, + Hash: "sha256:" + subject.Digest["sha256"], + Size: &size, } if artifact.Requires != nil { requires := artifact.Requires diff --git a/internal/packslip/model_test.go b/internal/packslip/model_test.go index 0b4c25a1..b3daa42a 100644 --- a/internal/packslip/model_test.go +++ b/internal/packslip/model_test.go @@ -31,6 +31,11 @@ func validRelease(version string, artifacts ...releaseArtifact) []byte { 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": mustJSON(dbcArtifactExtension{PackageVersion: 2})} + } + } project := testProject issued := GitHubOIDCIssuer envelope := statementEnvelope{ @@ -38,7 +43,8 @@ func validRelease(version string, artifacts ...releaseArtifact) []byte { PredicateType: ReleasePredicateType, Predicate: mustJSON(releasePredicate{ Project: project, Version: version, PublishedAt: "2026-09-01T12:00:00Z", Artifacts: artifacts, - Identity: releaseIdentity{Scheme: "sigstore-oidc", KeyID: "https://github.com/acme/driver/.github/workflows/release.yml@refs/tags/v1", Issuer: &issued}, + 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 { @@ -47,6 +53,17 @@ func validRelease(version string, artifacts ...releaseArtifact) []byte { 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" @@ -141,7 +158,7 @@ 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 := parseRelease(validRelease("1.0.0", + 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), @@ -152,7 +169,7 @@ func TestSelectArtifactUsesPackslipOrderAndRejectsAmbiguity(t *testing.T) { require.NoError(t, err) require.Equal(t, "linux-specific.tar.gz", selected.Name) - ambiguous, err := parseRelease(validRelease("1.0.0", + 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) @@ -161,7 +178,7 @@ func TestSelectArtifactUsesPackslipOrderAndRejectsAmbiguity(t *testing.T) { require.ErrorIs(t, err, ErrAmbiguousArtifact) variant := "fips" - withVariant, err := parseRelease(validRelease("1.0.0", + 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) @@ -180,7 +197,7 @@ 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 := parseRelease(validRelease("1.0.0", + release, err := parseDBCRelease(validRelease("1.0.0", makeArtifact("linux.tar.xz", "linux", "x86_64", "gnu", "tar.xz"), makeArtifact("portable.tar.gz", "", "", "", "tar.gz"), ), testProject) @@ -189,7 +206,7 @@ func TestDBCSupportedFormatsAndPackslipHostLibcSemantics(t *testing.T) { 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 := parseRelease(validRelease("1.0.0", makeArtifact("linux-gnu.tar.gz", "linux", "amd64", "gnu", "tar.gz")), testProject) + 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") @@ -199,13 +216,19 @@ func TestParseReleaseRejectsExactSelectorDuplicatesButDefersOverlappingScopeTies 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 := parseRelease(validRelease("1.0.0", + _, 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", "selector aliases are canonicalized before structural duplicate detection") + 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 := parseRelease(validRelease("1.0.0", + release, err := parseDBCRelease(validRelease("1.0.0", artifact("linux.tar.gz", "linux", ""), artifact("x64.tar.gz", "", "amd64"), ), testProject) @@ -213,7 +236,7 @@ func TestParseReleaseRejectsExactSelectorDuplicatesButDefersOverlappingScopeTies _, err = selectArtifact(release, Target{OS: "linux", Arch: "amd64"}) require.ErrorIs(t, err, ErrAmbiguousArtifact, "the tie is reported only for a matching host") - concreteTie, err := parseRelease(validRelease("1.0.0", + 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) @@ -280,7 +303,7 @@ func TestSupportedArtifactInventoryAllowsOverlappingSelectorsAndChoosesMostSpeci 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 := parseRelease(validRelease("1.0.0", + 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"), @@ -297,9 +320,9 @@ func TestSupportedArtifactInventoryAllowsOverlappingSelectorsAndChoosesMostSpeci 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 := parseRelease(validRelease("1.0.0", unsupported), testProject) + release, err := parseDBCRelease(validRelease("1.0.0", unsupported), testProject) require.NoError(t, err) - require.ErrorContains(t, validateSupportedArtifactSet(release), "no supported tar.gz or tgz artifacts") + require.ErrorContains(t, validateSupportedArtifactSet(release), "no dbc artifact with a supported tar.gz or tgz format") } func TestParseReleaseHasNoUnsignedFallback(t *testing.T) { diff --git a/internal/packslip/resolver_native.go b/internal/packslip/resolver_native.go index 3a85a842..9c2f5cd6 100644 --- a/internal/packslip/resolver_native.go +++ b/internal/packslip/resolver_native.go @@ -83,8 +83,10 @@ func (r *nativeResolver) Resolve(ctx context.Context, source PackslipSource, req if err != nil { return resolution.ResolvedRelease{}, err } - if strings.TrimSpace(request.DriverID) == "" { - return resolution.ResolvedRelease{}, errors.New("packslip resolution requires a driver ID") + 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) @@ -330,17 +332,15 @@ func (r *nativeResolver) finishResolved(ctx context.Context, project string, req 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 } - // TODO(packslip): Before exposing Packslip as a public dbc source, require a - // signed dbc consumer declaration in Packslip predicate/artifact extensions. - // The prototype currently treats supported tar.gz/tgz artifacts as dbc - // package candidates. The declaration should identify the dbc ADBC driver - // and package contract, runtime identity, installable artifacts, and package - // format; release/artifact field ownership remains undecided. After download, - // verify it against dbc-package.toml and fail on mismatches. Keep the schema - // unspecified until that contract is designed. targets, err := deriveConcreteTargets(verified.statement) if err != nil { return resolution.ResolvedRelease{}, err @@ -375,7 +375,7 @@ func (r *nativeResolver) finishResolved(ctx context.Context, project string, req }) } result := resolution.ResolvedRelease{ - DriverID: request.DriverID, + DriverID: verified.statement.driverID, Version: verified.statement.predicate.Version, Source: resolution.SourceSpec{Type: "packslip", Reference: project}, Evidence: evidence, @@ -404,7 +404,7 @@ 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 !supportedArchiveFormat(stringValue(artifact.Format)) || artifact.OS == nil || artifact.Arch == nil { + if artifact.dbcPackageVersion != 2 || !supportedArchiveFormat(stringValue(artifact.Format)) || artifact.OS == nil || artifact.Arch == nil { continue } target := resolution.CanonicalTarget(Target{ @@ -479,7 +479,7 @@ func provenancePresence(release *parsedRelease) (map[string]bool, error) { } result := make(map[string]bool, len(release.predicate.Artifacts)) for _, artifact := range release.predicate.Artifacts { - if !supportedArchiveFormat(stringValue(artifact.Format)) { + if artifact.dbcPackageVersion != 2 || !supportedArchiveFormat(stringValue(artifact.Format)) { continue } selector := artifactSelectorKey(&artifact) diff --git a/internal/packslip/resolver_native_test.go b/internal/packslip/resolver_native_test.go index bee9fac8..bc139787 100644 --- a/internal/packslip/resolver_native_test.go +++ b/internal/packslip/resolver_native_test.go @@ -210,9 +210,9 @@ func TestResolverUsesSignedListDigestAndBuildsResolvedRelease(t *testing.T) { 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: "iceberg", Version: "1.0.0"}) + resolved, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{DriverID: "driver", Version: "1.0.0"}) require.NoError(t, err) - require.Equal(t, "iceberg", resolved.DriverID) + 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) @@ -346,7 +346,7 @@ func TestDerivedConcreteLinuxTargetDefaultsToGNU(t *testing.T) { 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 := parseRelease(validRelease("1.0.0", linuxDefault), testProject) + release, err := parseDBCRelease(validRelease("1.0.0", linuxDefault), testProject) require.NoError(t, err) targets, err := deriveConcreteTargets(release) require.NoError(t, err) @@ -383,7 +383,7 @@ func TestResolverRetainsSignedListEvidenceWhenReleaseIsAbsentFromList(t *testing 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: "iceberg", Version: "1.0.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) @@ -395,7 +395,7 @@ 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: "iceberg", Version: "1.0.0", + DriverID: "driver", Version: "1.0.0", }) require.NoError(t, err) require.Equal(t, []resolution.Evidence{{ @@ -533,6 +533,26 @@ func TestResolverUsesVersionBearingTagAndKeepsProjectAsSourceIdentity(t *testing 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) + require.Equal(t, 2, resolved.Artifacts[0].PackageVersion) +} + +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) + require.Equal(t, 2, resolved.Artifacts[0].PackageVersion) +} + +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) { diff --git a/internal/packslip/types.go b/internal/packslip/types.go index 7312abd0..355b1d69 100644 --- a/internal/packslip/types.go +++ b/internal/packslip/types.go @@ -50,8 +50,10 @@ type PackslipSource struct { // Target is the canonical platform tuple used in a resolution snapshot. type Target = resolution.Target -// Request identifies one driver release whose installable artifacts are -// snapshotted for every concrete target advertised by the signed release. +// 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 diff --git a/internal/resolution/types.go b/internal/resolution/types.go index aebf856a..86695d19 100644 --- a/internal/resolution/types.go +++ b/internal/resolution/types.go @@ -236,8 +236,11 @@ type ResolvedRelease struct { // Artifact describes one downloadable archive for one concrete target. Hash and // Size are optional so older registry entries remain representable. type Artifact struct { - Target Target - Format string + Target Target + Format string + // PackageVersion is the dbc package contract declared by the signed source. + // Zero means unspecified for sources that predate an explicit marker. + PackageVersion int Location ArtifactLocation Hash string Size *int64 @@ -335,6 +338,9 @@ func ValidateResolvedRelease(release ResolvedRelease) error { seenTargets := make(map[Target]struct{}, len(release.Artifacts)) seenLocations := make(map[ArtifactLocation]Artifact, len(release.Artifacts)) for i, artifact := range release.Artifacts { + if artifact.PackageVersion != 0 && artifact.PackageVersion != 2 { + return fmt.Errorf("artifact %d has unsupported dbc package version %d", i, artifact.PackageVersion) + } if err := ValidateArtifactLocation(artifact.Location); err != nil { return fmt.Errorf("artifact %d has invalid location: %w", i, err) } diff --git a/internal/resolution/types_test.go b/internal/resolution/types_test.go index b545e932..096bde62 100644 --- a/internal/resolution/types_test.go +++ b/internal/resolution/types_test.go @@ -120,6 +120,9 @@ func TestValidateResolvedReleaseRequiresFinalizedArtifacts(t *testing.T) { {name: "missing hash", mutate: func(r *ResolvedRelease) { r.Artifacts[0].Hash = "" }, wantErr: "no finalized hash"}, {name: "missing size", mutate: func(r *ResolvedRelease) { r.Artifacts[0].Size = nil }, wantErr: "no finalized size"}, {name: "missing location", mutate: func(r *ResolvedRelease) { r.Artifacts[0].Location = ArtifactLocation{} }, wantErr: "invalid location"}, + {name: "unsupported package version 1", mutate: func(r *ResolvedRelease) { r.Artifacts[0].PackageVersion = 1 }, wantErr: "unsupported dbc package version 1"}, + {name: "negative package version", mutate: func(r *ResolvedRelease) { r.Artifacts[0].PackageVersion = -1 }, wantErr: "unsupported dbc package version -1"}, + {name: "future package version", mutate: func(r *ResolvedRelease) { r.Artifacts[0].PackageVersion = 3 }, wantErr: "unsupported dbc package version 3"}, {name: "duplicate target", mutate: func(r *ResolvedRelease) { duplicate := r.Artifacts[0] r.Artifacts = append(r.Artifacts, duplicate) diff --git a/internal/sourceresolution/resolver.go b/internal/sourceresolution/resolver.go index 9ca4e742..46cb40bf 100644 --- a/internal/sourceresolution/resolver.go +++ b/internal/sourceresolution/resolver.go @@ -48,9 +48,6 @@ func ResolvePackslip(ctx context.Context, resolver packslip.Resolver, project, d if resolver == nil { return resolution.ResolvedRelease{}, errors.New("packslip resolver is nil") } - if driverID == "" { - return resolution.ResolvedRelease{}, errors.New("packslip resolution requires a driver ID") - } 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) @@ -60,7 +57,10 @@ func ResolvePackslip(ctx context.Context, resolver packslip.Resolver, project, d if err != nil { return resolution.ResolvedRelease{}, fmt.Errorf("resolve packslip source: %w", err) } - if release.DriverID != driverID { + 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 { @@ -73,6 +73,11 @@ func ResolvePackslip(ctx context.Context, resolver packslip.Resolver, project, d 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.PackageVersion != 2 { + return resolution.ResolvedRelease{}, fmt.Errorf("packslip artifact %d has no supported dbc package_version declaration; refresh the lock or source metadata", i) + } + } return release, nil } diff --git a/internal/sourceresolution/resolver_test.go b/internal/sourceresolution/resolver_test.go index 7fca3856..81ffb788 100644 --- a/internal/sourceresolution/resolver_test.go +++ b/internal/sourceresolution/resolver_test.go @@ -49,6 +49,15 @@ func TestResolvePackslipUsesDeclarationAndValidatesRelease(t *testing.T) { 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 @@ -61,6 +70,7 @@ func TestResolvePackslipRejectsMismatchedMetadata(t *testing.T) { {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: "no finalized hash"}, {name: "artifact size", change: func(r *resolution.ResolvedRelease) { r.Artifacts[0].Size = nil }, want: "no finalized size"}, + {name: "missing package marker", change: func(r *resolution.ResolvedRelease) { r.Artifacts[0].PackageVersion = 0 }, want: "no supported dbc package_version"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -177,7 +187,7 @@ func resolvedPackslipRelease(project, version, driverID string) resolution.Resol Version: version, Source: resolution.SourceSpec{Type: "packslip", Reference: project}, Artifacts: []resolution.Artifact{{ - Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, Format: "tar.gz", + Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, Format: "tar.gz", PackageVersion: 2, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/driver.tar.gz"}, Hash: "sha256:" + strings.Repeat("a", 64), Size: &size, }}, From 3e89aecda3a4f7ff7a9e179d104d1ad3ece041df Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 06:11:19 +0000 Subject: [PATCH 070/115] fix: fail closed for stale Packslip locks --- cmd/dbc/lockfile_v2.go | 3 +++ cmd/dbc/lockfile_v2_test.go | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/cmd/dbc/lockfile_v2.go b/cmd/dbc/lockfile_v2.go index b926899f..97d108c2 100644 --- a/cmd/dbc/lockfile_v2.go +++ b/cmd/dbc/lockfile_v2.go @@ -412,6 +412,9 @@ func selectLockedArtifact(entry lockInfo, platform string, locked bool) (lockArt } if len(matches) == 1 { if entry.Source.Type == "packslip" && matches[0].PackageVersion != 2 { + if locked { + return lockArtifact{}, &LockedModeArtifactMissingError{DriverID: entry.Name, Platform: platform} + } return lockArtifact{}, &LockRefreshRequiredError{ DriverID: entry.Name, Platform: platform, Reason: "locked Packslip artifact is missing its signed dbc package_version declaration", diff --git a/cmd/dbc/lockfile_v2_test.go b/cmd/dbc/lockfile_v2_test.go index 004ce405..4173d769 100644 --- a/cmd/dbc/lockfile_v2_test.go +++ b/cmd/dbc/lockfile_v2_test.go @@ -222,6 +222,20 @@ func TestPackslipLockWithoutPackageVersionRequiresRefresh(t *testing.T) { require.ErrorAs(t, err, &refreshErr) require.Contains(t, err.Error(), "missing its signed dbc package_version declaration") require.ErrorIs(t, err, ErrLockRefreshRequired) + require.NotErrorIs(t, err, ErrLockedModeArtifactMissing) +} + +func TestPackslipLockWithoutPackageVersionFailsInLockedMode(t *testing.T) { + entry := testLockEntry() + entry.Artifacts[0].PackageVersion = 0 // Old prototype Packslip lock did not record this proof. + + _, err := selectLockedArtifact(entry, "linux_amd64_gnu_v1", true) + var lockedErr *LockedModeArtifactMissingError + require.ErrorAs(t, err, &lockedErr) + require.ErrorIs(t, err, ErrLockedModeArtifactMissing) + require.ErrorIs(t, err, ErrLockedArtifactMissing) + require.NotErrorIs(t, err, ErrLockRefreshRequired) + require.Contains(t, err.Error(), "locked mode") } func TestLockFileV2RejectsUnknownVersion(t *testing.T) { From 492a5b40b08fd3094df1e36ac9e4510d0471b867 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 06:13:28 +0000 Subject: [PATCH 071/115] feat: record dbc package format receipts --- config/package_archive.go | 41 +++++++++++++++++++++-- config/package_archive_test.go | 61 +++++++++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/config/package_archive.go b/config/package_archive.go index 5300ff7e..8493801f 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -52,6 +52,7 @@ const ( type ExpectedPackageMetadata struct { ID string Version string + PackageVersion int Platform string SourceType string SourceIdentity string @@ -68,6 +69,7 @@ type InstallReceipt struct { DriverID string `json:"driver_id"` DriverVersion string `json:"driver_version"` Platform string `json:"platform"` + PackageVersion int `json:"package_version"` ArchiveHash string `json:"archive_hash"` ArchiveSize int64 `json:"archive_size"` InstalledLibrary string `json:"installed_library,omitempty"` @@ -88,6 +90,7 @@ type InstallOptions struct { // driver library file. type PackageValidation struct { VerifiedLibraryHash string + PackageVersion int Registration DriverInfo RegistrationFingerprintAlgorithm string RegistrationFingerprintVersion int @@ -758,7 +761,7 @@ func ValidatePackage(runtimeID string, downloaded *os.File, expected ExpectedPac return PackageValidation{}, errors.New("could not read validated package receipt") } return PackageValidation{ - VerifiedLibraryHash: receipt.InstalledLibraryHash, Registration: manifest.DriverInfo, + VerifiedLibraryHash: receipt.InstalledLibraryHash, PackageVersion: manifest.PackageVersion, Registration: manifest.DriverInfo, RegistrationFingerprintAlgorithm: receipt.RegistrationFingerprintAlgorithm, RegistrationFingerprintVersion: receipt.RegistrationFingerprintVersion, RegistrationFingerprint: receipt.RegistrationFingerprint, @@ -822,6 +825,20 @@ func InstallReceiptMatchesRuntimeRegistration(receipt InstallReceipt, current Dr return false } +// InstallReceiptMatchesExpectedPackage checks the resolved package identity +// recorded by a receipt. A zero expected package version preserves the +// unspecified contract used by registry and local legacy sources; a nonzero +// expectation requires an exact receipt match. +func InstallReceiptMatchesExpectedPackage(receipt InstallReceipt, expected ExpectedPackageMetadata) bool { + return expected.ID != "" && expected.Version != "" && expected.Platform != "" && + expected.SourceType != "" && expected.SourceIdentity != "" && expected.ArchiveHash != "" && expected.ArchiveSize > 0 && + receipt.DriverID == expected.ID && receipt.DriverVersion == expected.Version && + receipt.Platform == expected.Platform && receipt.SourceType == expected.SourceType && + receipt.SourceIdentity == expected.SourceIdentity && receipt.ArchiveHash == expected.ArchiveHash && + receipt.ArchiveSize == expected.ArchiveSize && + (expected.PackageVersion == 0 || receipt.PackageVersion == expected.PackageVersion) +} + // PackageValidationMatchesRuntimeRegistration compares a current registration // with evidence returned by ValidatePackage. The candidate's shared identity // is private validation output, so callers cannot label an arbitrary path as @@ -967,6 +984,9 @@ func normalizePackageInstallMetadata(runtimeID string, expected ExpectedPackageM } } } + if err := validateExpectedPackageVersion(expected.PackageVersion); err != nil { + return ExpectedPackageMetadata{}, err + } if expected.SourceType == "" { expected.SourceType = "local" } @@ -1595,6 +1615,9 @@ func readPackageReceiptEvidence(location, runtimeID, directory string) (InstallR } func parseReceiptMetadata(receipt InstallReceipt) error { + if err := validateExpectedPackageVersion(receipt.PackageVersion); err != nil { + return fmt.Errorf("invalid receipt package version: %w", err) + } if _, err := semver.NewVersion(receipt.DriverVersion); err != nil { return fmt.Errorf("invalid receipt driver version: %w", err) } @@ -1624,6 +1647,9 @@ func validateExpectedPackage(expected ExpectedPackageMetadata) error { if _, err := semver.NewVersion(expected.Version); err != nil { return fmt.Errorf("invalid expected package version %q: %w", expected.Version, err) } + if err := validateExpectedPackageVersion(expected.PackageVersion); err != nil { + return err + } if err := validatePlatformIdentifier(expected.Platform); err != nil { return fmt.Errorf("invalid expected package platform: %w", err) } @@ -1636,6 +1662,13 @@ func validateExpectedPackage(expected ExpectedPackageMetadata) error { return nil } +func validateExpectedPackageVersion(version int) error { + if version != 0 && version != 2 { + return fmt.Errorf("unsupported dbc package version %d", version) + } + return nil +} + // InspectPackageMetadata decodes the archive's package metadata without // installing files. It is intended to classify legacy and versioned package // formats before choosing an install policy; the installer still validates @@ -1792,6 +1825,9 @@ func stagePackageArchive(location, runtimeID, finalDir string, downloaded *os.Fi 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) } + if expected.PackageVersion != 0 && expected.PackageVersion != manifest.PackageVersion { + return result, "", sharedIdentity, fmt.Errorf("dbc package version mismatch: archive declares %d, expected %d", manifest.PackageVersion, expected.PackageVersion) + } platform := expected.Platform if meta.v2 { if platform != "" && meta.platform != platform { @@ -1856,7 +1892,8 @@ func stagePackageArchive(location, runtimeID, finalDir string, downloaded *os.Fi receipt := InstallReceipt{ SourceType: expected.SourceType, SourceIdentity: expected.SourceIdentity, DriverID: runtimeID, DriverVersion: manifest.Version.String(), Platform: platform, - ArchiveHash: archiveHash, ArchiveSize: archiveSize, InstalledLibrary: installedLibrary, + PackageVersion: manifest.PackageVersion, + ArchiveHash: archiveHash, ArchiveSize: archiveSize, InstalledLibrary: installedLibrary, InstalledLibraryHash: installedHash, RegistrationFingerprintAlgorithm: registrationFingerprintAlgorithm, RegistrationFingerprintVersion: registrationFingerprintVersion, diff --git a/config/package_archive_test.go b/config/package_archive_test.go index 7a934289..f60ab7ce 100644 --- a/config/package_archive_test.go +++ b/config/package_archive_test.go @@ -101,7 +101,7 @@ func openPackageArchive(t *testing.T, data []byte) *os.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, + ID: id, Version: version, PackageVersion: 2, Platform: platform, SourceType: "packslip", SourceIdentity: source, ArchiveHash: "sha256:" + hex.EncodeToString(digest[:]), ArchiveSize: int64(len(archive)), } @@ -498,6 +498,45 @@ func TestInstallPackageArchiveChecksMetadataAndDigests(t *testing.T) { } } +func TestPackslipPackageVersionRejectsLegacyArchiveBeforeRuntimeMutation(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} + existingArchive := validV2Archive(t, []byte("existing")) + existingFile := openPackageArchive(t, existingArchive) + installed, err := config.InstallPackage(cfg, "example", existingFile, + expectedPackage("example", "1.2.3", config.PlatformTuple(), "existing", existingArchive), config.InstallOptions{}) + require.NoError(t, err) + require.NoError(t, existingFile.Close()) + beforeManifest, err := os.ReadFile(filepath.Join(root, "example.toml")) + require.NoError(t, err) + beforeLibrary, err := os.ReadFile(installed.Driver.Shared.Get(config.PlatformTuple())) + require.NoError(t, err) + + candidate := expectedPackage("example", "1.2.3", config.PlatformTuple(), "packslip-project", archive) + candidateFile := openPackageArchive(t, archive) + _, err = config.ValidatePackage("example", candidateFile, candidate, config.InstallOptions{}) + require.ErrorContains(t, err, "dbc package version mismatch: archive declares 0, expected 2") + _, err = candidateFile.Seek(0, 0) + require.NoError(t, err) + _, err = config.InstallPackage(cfg, "example", candidateFile, candidate, config.InstallOptions{}) + require.ErrorContains(t, err, "dbc package version mismatch") + require.NoError(t, candidateFile.Close()) + + afterManifest, err := os.ReadFile(filepath.Join(root, "example.toml")) + require.NoError(t, err) + afterLibrary, err := os.ReadFile(installed.Driver.Shared.Get(config.PlatformTuple())) + require.NoError(t, err) + assert.Equal(t, beforeManifest, afterManifest) + assert.Equal(t, beforeLibrary, afterLibrary) +} + func TestValidatePackageIsNonMutatingAndArchiveCanBeInstalledAfterward(t *testing.T) { root := t.TempDir() cfg := config.Config{Level: config.ConfigEnv, Location: root} @@ -629,6 +668,8 @@ func TestInstallPackageArchiveReceiptReplacementAndRuntimeManifest(t *testing.T) require.NoError(t, json.Unmarshal(firstReceiptBytes, &firstReceipt)) assert.Equal(t, "github.com/first/source", firstReceipt.SourceIdentity) assert.Equal(t, first.ArchiveHash, firstReceipt.ArchiveHash) + assert.Equal(t, 2, firstReceipt.PackageVersion) + assert.True(t, config.InstallReceiptMatchesExpectedPackage(firstReceipt, first), "the new package version is part of the receipt proof") assert.NotEqual(t, firstReceipt.ArchiveHash, firstReceipt.InstalledLibraryHash) installedDigest := sha256.Sum256([]byte("first library")) assert.Equal(t, "sha256:"+hex.EncodeToString(installedDigest[:]), firstReceipt.InstalledLibraryHash) @@ -669,6 +710,24 @@ func TestInstallPackageArchiveReceiptReplacementAndRuntimeManifest(t *testing.T) assert.Equal(t, filepath.Join(root, "example", "libexample.so"), loaded.Driver.Shared.Get(config.PlatformTuple())) } +func TestInstallReceiptPackageVersionProofPreservesUnspecifiedSources(t *testing.T) { + expected := config.ExpectedPackageMetadata{ + ID: "example", Version: "1.2.3", PackageVersion: 2, 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", PackageVersion: 2, Platform: config.PlatformTuple(), + SourceType: "packslip", SourceIdentity: "github.com/example/driver", + ArchiveHash: expected.ArchiveHash, ArchiveSize: expected.ArchiveSize, + } + assert.True(t, config.InstallReceiptMatchesExpectedPackage(receipt, expected)) + receipt.PackageVersion = 0 + assert.False(t, config.InstallReceiptMatchesExpectedPackage(receipt, expected), "old receipts cannot prove the Packslip package contract") + expected.PackageVersion = 0 + assert.True(t, config.InstallReceiptMatchesExpectedPackage(receipt, expected), "registry and local legacy sources retain unspecified package-version matching") +} + func TestFailedReplacementPreservesExistingPackage(t *testing.T) { root := t.TempDir() cfg := config.Config{Level: config.ConfigEnv, Location: root} From 10e92fe7bcce945b42e1a7703a79da139f14144e Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 06:31:21 +0000 Subject: [PATCH 072/115] fix: enforce package format receipt proofs --- cmd/dbc/sync.go | 42 +++++++++++++++++++++++++--------------- cmd/dbc/sync_test.go | 46 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 16 deletions(-) diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index b182f246..df85e2fc 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -338,6 +338,7 @@ type installItem struct { Driver dbc.Driver Package dbc.PkgInfo ArtifactFormat string + PackageVersion int HostRequirements resolution.HostRequirements Checksum string ArchiveHash string @@ -590,13 +591,15 @@ func installItemFromLockedArtifact(name string, entry lockInfo, artifact lockArt } copy := cloneLockInfo(entry) item := installItem{ - Driver: driver, - Package: pkg, - Checksum: entry.legacyChecksumFor(config.PlatformTuple()), - LockEntry: ©, + Driver: driver, + Package: pkg, + PackageVersion: artifact.PackageVersion, + Checksum: entry.legacyChecksumFor(config.PlatformTuple()), + LockEntry: ©, } if err := setSelectedArtifactMetadata(&item, resolution.Artifact{ Format: artifact.Format, + PackageVersion: artifact.PackageVersion, HostRequirements: requirements, }); err != nil { return installItem{}, err @@ -615,6 +618,7 @@ func setSelectedArtifactMetadata(item *installItem, artifact resolution.Artifact return err } item.ArtifactFormat = artifact.Format + item.PackageVersion = artifact.PackageVersion item.HostRequirements = cloneHostRequirements(artifact.HostRequirements) return nil } @@ -794,7 +798,7 @@ func (s syncModel) prepareInstallItems(ctx context.Context, items []installItem) if installed.Version != nil && item.Package.Version.String() == installed.Version.String() { installedCopy := installed sameVersionInstalled = &installedCopy - expected, _, err := expectedRegistryPackageMetadata(item.Package) + expected, _, err := expectedSyncPackageMetadata(*item) if err != nil { return prepared, err } @@ -841,7 +845,7 @@ func (s syncModel) prepareInstallItems(ctx context.Context, items []installItem) } } else { // The exact locked artifact is already proven by its managed receipt. - expected, _, err := expectedRegistryPackageMetadata(item.Package) + expected, _, err := expectedSyncPackageMetadata(*item) if err != nil { return prepared, err } @@ -890,7 +894,7 @@ func (s syncModel) downloadAndValidateItem(ctx context.Context, item *installIte if err := snapshotDownloadedArchive(item, archive); err != nil { return fmt.Errorf("failed to snapshot downloaded driver archive: %w", err) } - expected, hasMetadata, err := expectedRegistryPackageMetadata(item.Package) + expected, hasMetadata, err := expectedSyncPackageMetadata(*item) if err != nil { return err } @@ -902,6 +906,8 @@ func (s syncModel) downloadAndValidateItem(ctx context.Context, item *installIte if inspectErr != nil { return inspectErr } + item.PackageVersion = packageManifest.PackageVersion + expected.PackageVersion = item.PackageVersion if packageManifest.PackageVersion == 2 { return errors.New("registry package v2 requires archive hash and size metadata") } @@ -927,6 +933,9 @@ func (s syncModel) downloadAndValidateItem(ctx context.Context, item *installIte } return fmt.Errorf("failed to validate driver package: %w", err) } + item.PackageVersion = validation.PackageVersion + expected.PackageVersion = validation.PackageVersion + item.Expected = expected item.Validation = &validation item.ValidatedLibraryHash = strings.TrimPrefix(validation.VerifiedLibraryHash, "sha256:") if item.ValidatedLibraryHash == "" { @@ -951,7 +960,7 @@ func (s syncModel) itemCurrentMatches(item *installItem, current *config.DriverI } libraryPath := current.Driver.Shared.Get(config.PlatformTuple()) if managed && present { - if !valid || !installReceiptMatchesExpected(receipt, item.Expected) || + if !valid || !config.InstallReceiptMatchesExpectedPackage(receipt, item.Expected) || !config.VerifyInstallReceiptLibraryIntegrity(libraryPath, receipt) || !config.InstallReceiptMatchesRuntimeRegistration(receipt, *current, config.PlatformTuple()) { return false, nil @@ -1073,7 +1082,7 @@ func (s syncModel) validateEnsureResult(item *installItem, result config.EnsureP return fmt.Errorf("failed to resolve installed driver receipt location: %w", err) } libraryPath := result.Installed.Driver.Shared.Get(config.PlatformTuple()) - if !managed || !present || !valid || !installReceiptMatchesExpected(receipt, item.Expected) || + if !managed || !present || !valid || !config.InstallReceiptMatchesExpectedPackage(receipt, item.Expected) || receipt.InstalledLibraryHash != item.Validation.VerifiedLibraryHash || !config.InstallReceiptMatchesRuntimeRegistration(receipt, *result.Installed, config.PlatformTuple()) || !config.VerifyInstallReceiptLibraryIntegrity(libraryPath, receipt) { @@ -1089,13 +1098,13 @@ func (s syncModel) validateEnsureResult(item *installItem, result config.EnsureP return nil } -func installReceiptMatchesExpected(receipt config.InstallReceipt, expected config.ExpectedPackageMetadata) bool { - return expected.ID != "" && expected.Version != "" && expected.Platform != "" && - expected.SourceType != "" && expected.SourceIdentity != "" && expected.ArchiveHash != "" && expected.ArchiveSize > 0 && - receipt.DriverID == expected.ID && receipt.DriverVersion == expected.Version && - receipt.Platform == expected.Platform && receipt.SourceType == expected.SourceType && - receipt.SourceIdentity == expected.SourceIdentity && receipt.ArchiveHash == expected.ArchiveHash && - receipt.ArchiveSize == expected.ArchiveSize +func expectedSyncPackageMetadata(item installItem) (config.ExpectedPackageMetadata, bool, error) { + expected, hasMetadata, err := expectedRegistryPackageMetadata(item.Package) + if err != nil { + return config.ExpectedPackageMetadata{}, false, err + } + expected.PackageVersion = item.PackageVersion + return expected, hasMetadata, nil } func markAlreadyInstalled(item *installItem, installed config.DriverInfo, libraryHash string) { @@ -1380,6 +1389,7 @@ func lockEntryForItem(item installItem) (lockInfo, error) { Artifacts: []resolution.Artifact{{ Target: target, Format: item.ArtifactFormat, + PackageVersion: item.PackageVersion, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: item.Package.Path.String()}, Hash: hash, Size: &size, diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index 17a819df..fc43e8b1 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -1582,6 +1582,52 @@ func (suite *SubcommandTestSuite) TestSyncSameVersionRequiresMatchingManagedRece } } +func (suite *SubcommandTestSuite) TestSyncLockedPackageVersionMismatchDoesNotReuseReceiptOrMutateInstall() { + t := suite.T() + root := t.TempDir() + t.Setenv("ADBC_DRIVER_PATH", root) + driverListPath := filepath.Join(root, "dbc.toml") + lockPath := filepath.Join(root, "dbc.lock") + require.NoError(t, 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") + require.NoError(t, err) + beforeLibrary := before.Driver.Shared.Get(config.PlatformTuple()) + lock, err := loadLockFile(lockPath) + require.NoError(t, err) + entry := lock.lockinfo["test-driver-1"] + require.Len(t, entry.Artifacts, 1) + entry.Artifacts[0].PackageVersion = 2 + lock.Drivers = []lockInfo{entry} + require.NoError(t, writeLockFileAtomic(lockPath, lock)) + + downloadCalls, installCalls := 0, 0 + model := SyncCmd{Path: driverListPath, NoVerify: true}.GetModelCustom(baseModel{ + getDriverRegistry: func() ([]dbc.Driver, error) { + return nil, errors.New("exact locked replay must not discover registries") + }, + 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, options config.InstallOptions, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + installCalls++ + return config.EnsurePackage(ctx, cfg, driver, expected, options, callbacks) + } + + output := suite.runCmdErr(model) + require.Contains(t, output, "dbc package version mismatch") + require.Equal(t, 1, downloadCalls, "a receipt for another package format must not skip archive validation") + require.Zero(t, installCalls, "package format validation must fail before package installation") + after, err := config.GetDriver(cfg, "test-driver-1") + require.NoError(t, err) + assert.Equal(t, before.FilePath, after.FilePath) + assert.Equal(t, beforeLibrary, after.Driver.Shared.Get(config.PlatformTuple()), "failed validation must leave the installed generation untouched") +} + func writeSyncReceipt(t *testing.T, path string, receipt config.InstallReceipt) { t.Helper() data, err := json.Marshal(receipt) From 00cd6cfe0d75d67648c4bc11d1320b0db1be976c Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 06:43:46 +0000 Subject: [PATCH 073/115] refactor: centralize source identity --- cmd/dbc/add.go | 3 +- cmd/dbc/driver_list.go | 109 +++++++++------ cmd/dbc/driver_list_test.go | 66 +++++++++ cmd/dbc/lockfile.go | 20 +-- cmd/dbc/lockfile_v2.go | 2 +- cmd/dbc/sync.go | 38 ++---- cmd/dbc/sync_test.go | 30 +++++ internal/packslip/github.go | 8 +- internal/packslip/model.go | 37 ++--- internal/packslip/model_test.go | 6 +- internal/packslip/resolver_native.go | 7 +- internal/packslip/truststore_native.go | 5 +- internal/sourceidentity/sourceidentity.go | 126 ++++++++++++++++++ .../sourceidentity/sourceidentity_test.go | 82 ++++++++++++ internal/sourceresolution/resolver.go | 19 ++- registry_config.go | 49 +++---- registry_config_test.go | 58 +++++--- source.go | 37 ++--- source_test.go | 2 + 19 files changed, 499 insertions(+), 205 deletions(-) create mode 100644 internal/sourceidentity/sourceidentity.go create mode 100644 internal/sourceidentity/sourceidentity_test.go diff --git a/cmd/dbc/add.go b/cmd/dbc/add.go index 4a38c569..4b00617d 100644 --- a/cmd/dbc/add.go +++ b/cmd/dbc/add.go @@ -21,7 +21,6 @@ import ( "fmt" "os" "path/filepath" - "reflect" "time" tea "charm.land/bubbletea/v2" @@ -315,7 +314,7 @@ func (m addModel) Init() tea.Cmd { if original.present != present { return fmt.Errorf("driver %q entry presence changed while resolving drivers; please retry `dbc add`", spec.Name) } - if !reflect.DeepEqual(original.source, latest.Source) { + 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 { diff --git a/cmd/dbc/driver_list.go b/cmd/dbc/driver_list.go index 2a3c9a51..9dada1f0 100644 --- a/cmd/dbc/driver_list.go +++ b/cmd/dbc/driver_list.go @@ -18,13 +18,12 @@ 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" ) @@ -67,71 +66,101 @@ func (m DriversList) validateSources() error { // 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 + return false } - 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() + keyB, err := lockSourceIdentity(b) + return err == nil && keyA == keyB } // applyProjectRegistries rebuilds the process-wide dbc client with the diff --git a/cmd/dbc/driver_list_test.go b/cmd/dbc/driver_list_test.go index ce76ceb0..26f7c496 100644 --- a/cmd/dbc/driver_list_test.go +++ b/cmd/dbc/driver_list_test.go @@ -298,6 +298,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"}}} @@ -344,6 +354,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"}}} @@ -386,6 +408,50 @@ func TestRegistriesChanged(t *testing.T) { }) } +func TestDriverSourceIdentityComparison(t *testing.T) { + tests := []struct { + name string + a *dbc.DriverSource + b *dbc.DriverSource + want bool + }{ + {name: "both omitted sources match", want: true}, + {name: "omitted source remains distinct from explicit registry", b: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "https://registry.example.test"}}, + {name: "registry canonical equivalences match", a: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "HTTPS://REGISTRY.EXAMPLE.TEST/#one"}, b: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "https://registry.example.test"}, want: true}, + {name: "registry escaped path distinction is preserved", a: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "https://registry.example.test/a%2Fb"}, b: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "https://registry.example.test/a/b"}}, + {name: "registry force query distinction is preserved", a: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "https://registry.example.test?"}, b: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "https://registry.example.test"}}, + {name: "packslip owner and repo case normalize", a: &dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "GitHub.com/Example/Driver/Tools"}, b: &dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "github.com/example/driver/Tools"}, want: true}, + {name: "packslip tool path case is preserved", a: &dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "github.com/example/driver/Tools"}, b: &dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "github.com/example/driver/tools"}}, + {name: "path is exact string identity", a: &dbc.DriverSource{Type: dbc.DriverSourcePath, Path: "./packages/../driver.tgz"}, b: &dbc.DriverSource{Type: dbc.DriverSourcePath, Path: "./driver.tgz"}}, + {name: "invalid source does not fall back to raw comparison", a: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "not a URL"}, b: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "not a URL"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, sameDriverSourceIdentity(test.a, test.b)) + }) + } +} + +func TestLockSourceIdentityComparison(t *testing.T) { + tests := []struct { + name string + a lockSource + b lockSource + want bool + }{ + {name: "registry URL canonical equivalences match", a: lockSource{Type: "registry", URL: "HTTPS://REGISTRY.EXAMPLE.TEST/a/#one"}, b: lockSource{Type: "registry", URL: "https://registry.example.test/a"}, want: true}, + {name: "escaped separator remains distinct", a: lockSource{Type: "registry", URL: "https://registry.example.test/a%2Fb"}, b: lockSource{Type: "registry", URL: "https://registry.example.test/a/b"}}, + {name: "packslip owner and repo case normalize", a: lockSource{Type: "packslip", Project: "GitHub.com/Example/Driver/Tools"}, b: lockSource{Type: "packslip", Project: "github.com/example/driver/Tools"}, want: true}, + {name: "path aliases stay distinct", a: lockSource{Type: "path", Path: "./packages/../driver.tgz"}, b: lockSource{Type: "path", Path: "./driver.tgz"}}, + {name: "malformed identity has no raw fallback", a: lockSource{Type: "registry", URL: "not a URL"}, b: lockSource{Type: "registry", URL: "not a URL"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, sameLockSourceIdentity(test.a, test.b)) + }) + } +} + 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/lockfile.go b/cmd/dbc/lockfile.go index 9d54aa84..0c761ec9 100644 --- a/cmd/dbc/lockfile.go +++ b/cmd/dbc/lockfile.go @@ -18,12 +18,12 @@ import ( "crypto/sha256" "fmt" "io" - "net/url" "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" ) @@ -255,6 +255,9 @@ func validateLockSource(source lockSource) error { 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") } @@ -262,15 +265,15 @@ func validateLockSource(source lockSource) error { if source.URL == "" { return fmt.Errorf("registry source has no URL") } - if err := validateHTTPURL("registry source URL", source.URL); err != nil { + 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 source.Path == "" { - return fmt.Errorf("path source has no 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") @@ -281,15 +284,6 @@ func validateLockSource(source lockSource) error { return nil } -func validateHTTPURL(field, raw string) error { - parsed, err := url.Parse(raw) - if err != nil || !parsed.IsAbs() || - (!strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https")) || parsed.Hostname() == "" { - return fmt.Errorf("%s must be an absolute HTTP(S) URL with a host", field) - } - return nil -} - func validateLockArtifacts(artifacts []lockArtifact) error { seenTargets := make(map[resolution.Target]struct{}, len(artifacts)) seenLocations := make(map[resolution.ArtifactLocation]lockArtifact, len(artifacts)) diff --git a/cmd/dbc/lockfile_v2.go b/cmd/dbc/lockfile_v2.go index 97d108c2..a43d4c8b 100644 --- a/cmd/dbc/lockfile_v2.go +++ b/cmd/dbc/lockfile_v2.go @@ -311,7 +311,7 @@ func refreshLockEntry(existing, refreshed lockInfo) (lockInfo, error) { if existing.Name != refreshed.Name || existing.Version == nil || refreshed.Version == nil || !existing.Version.Equal(refreshed.Version) { return lockInfo{}, errors.New("metadata refresh must keep the locked driver version") } - if existing.Source != refreshed.Source { + if !sameLockSourceIdentity(existing.Source, refreshed.Source) { return lockInfo{}, errors.New("metadata refresh must keep the locked source identity") } if err := validateLockInfo(existing); err != nil { diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index df85e2fc..2e339843 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -39,6 +39,7 @@ import ( "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" ) type SyncCmd struct { @@ -497,13 +498,17 @@ func findDriverInDeclaredRegistry(name string, drivers []dbc.Driver, source *dbc return findDriver(name, drivers) } - declaredURL := normalizeRegistryURL(source.URL) + declaredKey, err := sourceidentity.Parse(sourceidentity.Registry, source.URL) + if err != nil { + return dbc.Driver{}, fmt.Errorf("driver %q has invalid declared registry source: %w", name, err) + } var matches []dbc.Driver for _, driver := range drivers { if driver.Registry == nil || driver.Registry.BaseURL == nil { continue } - if normalizeRegistryURL(driver.Registry.BaseURL.String()) == declaredURL { + key, err := sourceidentity.Parse(sourceidentity.Registry, driver.Registry.BaseURL.String()) + if err == nil && key == declaredKey { matches = append(matches, driver) } } @@ -534,31 +539,16 @@ func lockVersionSatisfiesSpec(entry lockInfo, spec driverSpec) bool { func driverSourceMatchesLock(source *dbc.DriverSource, locked lockSource) bool { if source == nil { + // An omitted source selects the configured default-registry policy. It + // does not declare a source identity key or pin an undeclared registry. return locked.Type == string(dbc.DriverSourceRegistry) } - switch source.Type { - case dbc.DriverSourceRegistry: - return locked.Type == string(dbc.DriverSourceRegistry) && - normalizeRegistryURL(locked.URL) == normalizeRegistryURL(source.URL) - case dbc.DriverSourcePackslip: - return locked.Type == string(dbc.DriverSourcePackslip) && - canonicalPackslipProject(locked.Project) == canonicalPackslipProject(source.Project) - case dbc.DriverSourcePath: - return locked.Type == string(dbc.DriverSourcePath) && locked.Path == source.Path - default: + declaredKey, err := driverSourceIdentity(source) + if err != nil { return false } -} - -func canonicalPackslipProject(project string) string { - parts := strings.Split(project, "/") - if len(parts) < 3 { - return project - } - for i := 0; i < 3; i++ { - parts[i] = strings.ToLower(parts[i]) - } - return strings.Join(parts, "/") + lockedKey, err := lockSourceIdentity(locked) + return err == nil && declaredKey == lockedKey } func installItemFromLockedArtifact(name string, entry lockInfo, artifact lockArtifact) (installItem, error) { @@ -712,7 +702,7 @@ func canReuseLockedEntry(item installItem) bool { return false } source, err := packageLockSource(item) - if err != nil || item.LockEntry.Source != source { + if err != nil || !sameLockSourceIdentity(item.LockEntry.Source, source) { return false } artifact, err := selectLockedArtifact(*item.LockEntry, config.PlatformTuple(), false) diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index fc43e8b1..a4ec2d0b 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -288,6 +288,14 @@ func TestLockVersionSatisfiesSpecRequiresMatchingSource(t *testing.T) { Version: version, Source: lockSource{Type: "packslip", Project: "github.com/example/driver"}, }, packslipSpec), "GitHub host, owner, and repo are canonicalized consistently") + packslipToolSpec := driverSpec{ + Version: packslipConstraint, + Source: &dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "github.com/EXAMPLE/DRIVER/Tools/Tool"}, + } + assert.False(t, lockVersionSatisfiesSpec(lockInfo{ + Version: version, + Source: lockSource{Type: "packslip", Project: "github.com/example/driver/TOOLS/Tool"}, + }, packslipToolSpec), "Packslip monorepo subpaths remain case-sensitive") registryConstraint, err := semver.NewConstraint("1.2.3") require.NoError(t, err) @@ -326,6 +334,23 @@ func TestLockVersionSatisfiesSpecRequiresMatchingSource(t *testing.T) { }, driverSpec{Version: registryConstraint, Source: explicitRegistry}), "registry path/query differences remain part of source identity: %s", differentURL) } + for _, differentURL := range []string{ + "https://registry.example.test?", + "https://registry.example.test:443", + "https://registry.example.test/a%2Fb", + "https://u:p@registry.example.test", + } { + assert.False(t, lockVersionSatisfiesSpec(lockInfo{ + Version: version, + Source: lockSource{Type: "registry", URL: differentURL}, + }, driverSpec{Version: registryConstraint, Source: explicitRegistry}), + "registry source identity keeps ForceQuery, escaped separators, userinfo, and explicit ports: %s", differentURL) + } + assert.True(t, lockVersionSatisfiesSpec(lockInfo{ + Version: version, + Source: lockSource{Type: "registry", URL: "https://registry.example.test#fragment"}, + }, driverSpec{Version: registryConstraint, Source: explicitRegistry}), + "fragments do not change the HTTP registry identity") pathSource := &dbc.DriverSource{Type: dbc.DriverSourcePath, Path: "./packages/driver.tgz"} assert.True(t, lockVersionSatisfiesSpec(lockInfo{ @@ -336,6 +361,11 @@ func TestLockVersionSatisfiesSpecRequiresMatchingSource(t *testing.T) { Version: version, Source: lockSource{Type: "path", Path: "./packages/other.tgz"}, }, driverSpec{Version: registryConstraint, Source: pathSource})) + assert.False(t, lockVersionSatisfiesSpec(lockInfo{ + Version: version, + Source: lockSource{Type: "path", Path: "./packages/../driver.tgz"}, + }, driverSpec{Version: registryConstraint, Source: &dbc.DriverSource{Type: dbc.DriverSourcePath, Path: "./driver.tgz"}}), + "path source identity does not resolve filesystem path aliases") } func TestExplicitRegistryURLNormalizationAllowsOfflineLockedReplay(t *testing.T) { diff --git a/internal/packslip/github.go b/internal/packslip/github.go index e21d7376..aa30f9e3 100644 --- a/internal/packslip/github.go +++ b/internal/packslip/github.go @@ -28,6 +28,8 @@ import ( "strconv" "strings" "time" + + "github.com/columnar-tech/dbc/internal/sourceidentity" ) const ( @@ -86,10 +88,11 @@ func parseBaseURL(value string) (*url.URL, error) { } func (d *GitHubPackslipDiscovery) ListReleases(ctx context.Context, source PackslipSource) ([]githubRelease, error) { - project, err := normalizeProject(source.Project) + 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{} @@ -132,10 +135,11 @@ func (d *GitHubPackslipDiscovery) ListReleases(ctx context.Context, source Packs } func (d *GitHubPackslipDiscovery) ReleaseListURL(source PackslipSource) (string, error) { - project, err := normalizeProject(source.Project) + 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"} diff --git a/internal/packslip/model.go b/internal/packslip/model.go index cf47019b..4a99b3dd 100644 --- a/internal/packslip/model.go +++ b/internal/packslip/model.go @@ -28,6 +28,7 @@ import ( "time" "github.com/columnar-tech/dbc/internal/resolution" + "github.com/columnar-tech/dbc/internal/sourceidentity" ) var ( @@ -179,15 +180,17 @@ func parseRelease(payload []byte, expectedProject string) (*parsedRelease, error if err := decodeWire(envelope.Predicate, &predicate); err != nil { return nil, fmt.Errorf("decode packslip release predicate: %w", err) } - canonicalProject, err := normalizeProject(predicate.Project) + 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 != "" { - expectedProject, err = normalizeProject(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) } @@ -363,14 +366,16 @@ func parseList(payload []byte, expectedProject string) (*parsedList, error) { if err := decodeWire(envelope.Predicate, &predicate); err != nil { return nil, fmt.Errorf("decode packslip release-list predicate: %w", err) } - canonicalProject, err := normalizeProject(predicate.Project) + 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) } - expectedProject, err = normalizeProject(expectedProject) + 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) } @@ -723,30 +728,6 @@ func peekBundleDigest(bundleJSON []byte) (string, string, []byte, error) { return "", "", nil, errors.New("Sigstore statement has no supported SHA-256 or SHA-512 subject for verification") } -func normalizeProject(value string) (string, error) { - if value != strings.TrimSpace(value) || strings.Contains(value, "://") || strings.HasSuffix(value, "/") { - return "", fmt.Errorf("project must be a canonical host path without URL scheme: %q", value) - } - parts := strings.Split(value, "/") - if len(parts) < 3 || !strings.EqualFold(parts[0], "github.com") { - return "", fmt.Errorf("only GitHub projects of the form github.com/owner/repo are supported: %q", value) - } - for i, part := range parts { - if part == "" || part == "." || part == ".." { - return "", fmt.Errorf("project contains an invalid path segment: %q", value) - } - for _, ch := range part { - if !(ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' || strings.ContainsRune("._-", ch)) { - return "", fmt.Errorf("project contains an invalid path segment: %q", value) - } - } - if i < 3 { - parts[i] = strings.ToLower(part) - } - } - return strings.Join(parts, "/"), nil -} - func projectParts(project string) (owner, repo string, tool []string) { parts := strings.Split(project, "/") return parts[1], parts[2], parts[3:] diff --git a/internal/packslip/model_test.go b/internal/packslip/model_test.go index b3daa42a..6b70f10f 100644 --- a/internal/packslip/model_test.go +++ b/internal/packslip/model_test.go @@ -19,6 +19,7 @@ import ( "strings" "testing" + "github.com/columnar-tech/dbc/internal/sourceidentity" "github.com/stretchr/testify/require" ) @@ -256,15 +257,16 @@ func optionalToken(value string) *string { } func TestNormalizeProjectAndTagVersion(t *testing.T) { - project, err := normalizeProject("GitHub.COM/Acme/driver/Tools/Tool") + 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 = normalizeProject("https://github.com/acme/driver") + _, err = sourceidentity.Parse(sourceidentity.Packslip, "https://github.com/acme/driver") require.Error(t, err) } diff --git a/internal/packslip/resolver_native.go b/internal/packslip/resolver_native.go index 9c2f5cd6..a9240f6a 100644 --- a/internal/packslip/resolver_native.go +++ b/internal/packslip/resolver_native.go @@ -25,6 +25,7 @@ import ( "github.com/columnar-tech/dbc/internal/packslipverify" "github.com/columnar-tech/dbc/internal/resolution" + "github.com/columnar-tech/dbc/internal/sourceidentity" ) type nativeResolver struct { @@ -79,10 +80,11 @@ func (r *nativeResolver) Resolve(ctx context.Context, source PackslipSource, req if err := ctx.Err(); err != nil { return resolution.ResolvedRelease{}, err } - project, err := normalizeProject(source.Project) + 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) @@ -449,10 +451,11 @@ func checkDeclaredIdentity(declared releaseIdentity, verified *packslipverify.Ve } func githubIdentityPolicy(project string) (packslipverify.IdentityPolicy, error) { - project, err := normalizeProject(project) + 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 diff --git a/internal/packslip/truststore_native.go b/internal/packslip/truststore_native.go index ab43ad58..9625db19 100644 --- a/internal/packslip/truststore_native.go +++ b/internal/packslip/truststore_native.go @@ -28,6 +28,7 @@ import ( "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 @@ -315,8 +316,8 @@ func (s *TrustStore) read() (trustDocument, error) { return trustDocument{}, fmt.Errorf("unsupported or incomplete packslip trust-store format") } for project, state := range doc.Projects { - canonical, err := normalizeProject(project) - if err != nil || canonical != project || state == nil { + 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 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/resolver.go b/internal/sourceresolution/resolver.go index 46cb40bf..a3c6d5a9 100644 --- a/internal/sourceresolution/resolver.go +++ b/internal/sourceresolution/resolver.go @@ -23,6 +23,7 @@ import ( "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. @@ -48,6 +49,10 @@ func ResolvePackslip(ctx context.Context, resolver packslip.Resolver, project, d 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) @@ -66,9 +71,9 @@ func ResolvePackslip(ctx context.Context, resolver packslip.Resolver, project, d if release.Version != version { return resolution.ResolvedRelease{}, fmt.Errorf("packslip release version %q does not match requested version %q", release.Version, version) } - wantIdentity := canonicalPackslipProject(project) - if release.Source.Type != "packslip" || release.Source.Reference != wantIdentity { - return resolution.ResolvedRelease{}, fmt.Errorf("packslip release source identity %q does not match declared project %q", release.Source.Reference, wantIdentity) + 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) @@ -81,14 +86,6 @@ func ResolvePackslip(ctx context.Context, resolver packslip.Resolver, project, d return release, nil } -func canonicalPackslipProject(project string) string { - parts := strings.Split(project, "/") - for i := 0; i < len(parts) && i < 3; i++ { - parts[i] = strings.ToLower(parts[i]) - } - return strings.Join(parts, "/") -} - // 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 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 index 84438811..b54dc830 100644 --- a/source.go +++ b/source.go @@ -17,7 +17,8 @@ package dbc import ( "errors" "fmt" - "strings" + + "github.com/columnar-tech/dbc/internal/sourceidentity" ) // DriverSourceType identifies the source declared for a driver in dbc.toml. @@ -40,14 +41,17 @@ type DriverSource struct { } // Validate checks that the source's type and source-specific field agree. -// Registry URL and path resolution remain the responsibility of their -// adapters; Packslip projects use the currently supported GitHub host path. +// 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") } @@ -55,15 +59,15 @@ func (s DriverSource) Validate() error { if s.Project == "" { return errors.New("packslip source has no project") } - if err := validatePackslipProject(s.Project); err != nil { + 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 s.Path == "" { - return errors.New("path source has no path") + 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") @@ -76,24 +80,3 @@ func (s DriverSource) Validate() error { } return nil } - -func validatePackslipProject(project string) error { - if project != strings.TrimSpace(project) || strings.Contains(project, "://") || strings.HasSuffix(project, "/") { - return fmt.Errorf("packslip project must be a GitHub host path without a URL scheme: %q", project) - } - parts := strings.Split(project, "/") - if len(parts) < 3 || !strings.EqualFold(parts[0], "github.com") { - return fmt.Errorf("packslip project must use github.com/owner/repo[/tool...]: %q", project) - } - for _, part := range parts { - if part == "" || part == "." || part == ".." { - return fmt.Errorf("packslip project contains an invalid path segment: %q", project) - } - for _, ch := range part { - if !(ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' || strings.ContainsRune("._-", ch)) { - return fmt.Errorf("packslip project contains an invalid path segment: %q", project) - } - } - } - return nil -} diff --git a/source_test.go b/source_test.go index 71f2cb88..d467e077 100644 --- a/source_test.go +++ b/source_test.go @@ -35,6 +35,8 @@ func TestDriverSourceValidation(t *testing.T) { {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"}, From f0bb04cf140a231a27decc8e048a76c2ba2a70ec Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 07:13:26 +0000 Subject: [PATCH 074/115] refactor: centralize source resolution planning --- cmd/dbc/lockfile_v2_test.go | 4 +- internal/resolution/types.go | 45 +- internal/resolution/types_test.go | 16 +- internal/sourceresolution/plan.go | 155 +++++++ internal/sourceresolution/requirement.go | 401 ++++++++++++++++++ internal/sourceresolution/requirement_test.go | 328 ++++++++++++++ internal/sourceresolution/resolver.go | 2 +- internal/sourceresolution/resolver_test.go | 6 +- 8 files changed, 929 insertions(+), 28 deletions(-) create mode 100644 internal/sourceresolution/plan.go create mode 100644 internal/sourceresolution/requirement.go create mode 100644 internal/sourceresolution/requirement_test.go diff --git a/cmd/dbc/lockfile_v2_test.go b/cmd/dbc/lockfile_v2_test.go index 4173d769..c442860a 100644 --- a/cmd/dbc/lockfile_v2_test.go +++ b/cmd/dbc/lockfile_v2_test.go @@ -561,7 +561,7 @@ func TestLockSnapshotRequiresFinalizedArtifactMetadata(t *testing.T) { release.Artifacts[0].Hash = "" _, err := lockInfoFromResolvedRelease("example", release) require.Error(t, err) - assert.Contains(t, err.Error(), "no finalized hash") + assert.Contains(t, err.Error(), "hash and size must either both be present or both be absent") release = testResolvedRelease() release.Artifacts = append(release.Artifacts, release.Artifacts[0]) @@ -649,7 +649,7 @@ func TestSyncAdapterRejectsFreshEntryWithoutArchiveMetadata(t *testing.T) { } _, err = lockEntryForItem(item) require.Error(t, err) - assert.Contains(t, err.Error(), "no finalized hash") + assert.Contains(t, err.Error(), "hash and size must either both be present or both be absent") } func TestSyncAdapterRejectsPathArtifactForRegistryReplay(t *testing.T) { diff --git a/internal/resolution/types.go b/internal/resolution/types.go index 86695d19..67785f45 100644 --- a/internal/resolution/types.go +++ b/internal/resolution/types.go @@ -191,13 +191,6 @@ func validTargetToken(value string) bool { return true } -// Requirement describes the driver release and target requested by a caller. -type Requirement struct { - DriverID string - VersionConstraint string - Target Target -} - // 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. @@ -233,13 +226,13 @@ type ResolvedRelease struct { Artifacts []Artifact } -// Artifact describes one downloadable archive for one concrete target. Hash and -// Size are optional so older registry entries remain representable. +// Artifact describes one downloadable archive for one concrete target. Hash +// and Size are either both absent before download or both present afterward. type Artifact struct { Target Target Format string - // PackageVersion is the dbc package contract declared by the signed source. - // Zero means unspecified for sources that predate an explicit marker. + // PackageVersion is the dbc package contract declared by the source. Zero + // means no explicit package-version marker is present. PackageVersion int Location ArtifactLocation Hash string @@ -324,10 +317,11 @@ func ValidateEvidence(evidence []Evidence) error { return nil } -// ValidateResolvedRelease checks the contract required before a release can -// become a lockfile snapshot. Registry candidates may omit hashes or sizes; -// only the snapshot boundary requires them to be finalized. -func ValidateResolvedRelease(release ResolvedRelease) error { +// ValidateResolvedReleaseCandidate checks a resolved release before it is +// downloaded or used to form an install candidate. Archive hashes and sizes +// may both be absent at this stage, but any supplied pair and all other +// metadata must be valid. +func ValidateResolvedReleaseCandidate(release ResolvedRelease) error { if err := ValidateEvidence(release.Evidence); err != nil { return fmt.Errorf("invalid evidence: %w", err) } @@ -362,15 +356,30 @@ func ValidateResolvedRelease(release ResolvedRelease) error { seenLocations[artifact.Location] = artifact } + if (artifact.Hash == "") != (artifact.Size == nil) { + return fmt.Errorf("artifact %d hash and size must either both be present or both be absent", 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 and size. +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) } if artifact.Size == nil { return fmt.Errorf("artifact %d has no finalized size", i) } - if err := ValidateArtifactMetadata(artifact.Hash, artifact.Size); err != nil { - return fmt.Errorf("artifact %d has invalid metadata: %w", i, err) - } } return nil } diff --git a/internal/resolution/types_test.go b/internal/resolution/types_test.go index 096bde62..d7eed723 100644 --- a/internal/resolution/types_test.go +++ b/internal/resolution/types_test.go @@ -117,8 +117,8 @@ func TestValidateResolvedReleaseRequiresFinalizedArtifacts(t *testing.T) { mutate func(*ResolvedRelease) wantErr string }{ - {name: "missing hash", mutate: func(r *ResolvedRelease) { r.Artifacts[0].Hash = "" }, wantErr: "no finalized hash"}, - {name: "missing size", mutate: func(r *ResolvedRelease) { r.Artifacts[0].Size = nil }, wantErr: "no finalized size"}, + {name: "missing hash", mutate: func(r *ResolvedRelease) { r.Artifacts[0].Hash = "" }, wantErr: "hash and size must either both be present or both be absent"}, + {name: "missing size", mutate: func(r *ResolvedRelease) { r.Artifacts[0].Size = nil }, wantErr: "hash and size must either both be present or both be absent"}, {name: "missing location", mutate: func(r *ResolvedRelease) { r.Artifacts[0].Location = ArtifactLocation{} }, wantErr: "invalid location"}, {name: "unsupported package version 1", mutate: func(r *ResolvedRelease) { r.Artifacts[0].PackageVersion = 1 }, wantErr: "unsupported dbc package version 1"}, {name: "negative package version", mutate: func(r *ResolvedRelease) { r.Artifacts[0].PackageVersion = -1 }, wantErr: "unsupported dbc package version -1"}, @@ -139,15 +139,23 @@ func TestValidateResolvedReleaseRequiresFinalizedArtifacts(t *testing.T) { } } -func TestValidateResolvedReleaseKeepsRegistryCandidateWithoutHashValid(t *testing.T) { +func TestValidateResolvedReleaseCandidateAllowsMissingArchiveHashAndSize(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"}, - Size: &size, }}} + assert.NoError(t, ValidateResolvedReleaseCandidate(candidate)) assert.ErrorContains(t, ValidateResolvedRelease(candidate), "no finalized hash") + candidate.Artifacts[0].Size = &size + assert.ErrorContains(t, ValidateResolvedReleaseCandidate(candidate), "hash and size must either both be present or both be absent") + candidate.Artifacts[0].Size = nil + candidate.Artifacts[0].Hash = "sha256:" + strings.Repeat("a", 64) + assert.ErrorContains(t, ValidateResolvedReleaseCandidate(candidate), "hash and size must either both be present or both be absent") + candidate.Artifacts[0].Size = &size + assert.NoError(t, ValidateResolvedReleaseCandidate(candidate)) + assert.NoError(t, ValidateResolvedRelease(candidate)) assert.NoError(t, ValidateArtifactMetadata("", nil)) } diff --git a/internal/sourceresolution/plan.go b/internal/sourceresolution/plan.go new file mode 100644 index 00000000..a85d1e64 --- /dev/null +++ b/internal/sourceresolution/plan.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 +// +// 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 := validatePackslipReleaseArtifacts(*existing); err != nil { + return Plan{outcome: PlanReject, reason: 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..e25f8c12 --- /dev/null +++ b/internal/sourceresolution/requirement.go @@ -0,0 +1,401 @@ +// 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} +} + +// 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 } + +// 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) + } + if err := validatePackslipReleaseArtifacts(release); err != nil { + return resolution.Artifact{}, 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 validatePackslipReleaseArtifacts(release resolution.ResolvedRelease) error { + if release.Source.Type != string(sourceidentity.Packslip) { + return nil + } + for i, artifact := range release.Artifacts { + if artifact.PackageVersion != 2 { + return fmt.Errorf("resolved Packslip artifact %d has unsupported dbc package_version %d", i, artifact.PackageVersion) + } + } + 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 parsed.Prerelease() != "" && version.prerelease == PrereleaseForbidden { + return false + } + if version.parsed != nil { + 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..8f5aa7e5 --- /dev/null +++ b/internal/sourceresolution/requirement_test.go @@ -0,0 +1,328 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package 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 TestRegistryPrereleasePolicyOverridesPrereleaseComparators(t *testing.T) { + constraint := ">=1.2.3-beta.1,<1.2.3" + for _, test := range []struct { + name string + policy PrereleasePolicy + wantMatch bool + }{ + {name: "forbidden", policy: PrereleaseForbidden, wantMatch: false}, + {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].PackageVersion = 3 }, want: "unsupported dbc package version"}, + } + 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(), "hash and size must either both be present or both be absent") + _, 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") + + missingMarker := cloneResolvedRelease(matching) + missingMarker.Artifacts[0].PackageVersion = 0 + _, err = requirement.ValidateResolverResult(missingMarker) + assert.ErrorContains(t, err, "package_version") + for _, locked := range []bool{false, true} { + plan := requirement.Plan(&missingMarker, locked) + assert.Equal(t, PlanReject, plan.Outcome()) + assert.ErrorContains(t, plan.Err(), "package_version") + _, hasRelease := plan.Release() + assert.False(t, hasRelease, "invalid Packslip artifacts are not refresh inputs") + _, hasArtifact := plan.Artifact() + assert.False(t, hasArtifact, "invalid Packslip artifacts are never executable") + } + newVersionRequirement := mustRequirement(t, "driver", selection, mustPackslipVersion(t, "1.2.4"), planTarget) + assert.Equal(t, PlanReject, newVersionRequirement.Plan(&missingMarker, false).Outcome(), + "an invalid Packslip snapshot cannot be healed by changing the requested version") +} + +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) + packageVersion := 0 + if sourceType == "packslip" { + packageVersion = 2 + } + return resolution.ResolvedRelease{ + DriverID: driverID, + Version: version, + Source: resolution.SourceSpec{Type: sourceType, Reference: reference}, + Artifacts: []resolution.Artifact{{ + Target: target, + Format: "tar.gz", + PackageVersion: packageVersion, + 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 index a3c6d5a9..36ee4d05 100644 --- a/internal/sourceresolution/resolver.go +++ b/internal/sourceresolution/resolver.go @@ -80,7 +80,7 @@ func ResolvePackslip(ctx context.Context, resolver packslip.Resolver, project, d } for i, artifact := range release.Artifacts { if artifact.PackageVersion != 2 { - return resolution.ResolvedRelease{}, fmt.Errorf("packslip artifact %d has no supported dbc package_version declaration; refresh the lock or source metadata", i) + return resolution.ResolvedRelease{}, fmt.Errorf("packslip artifact %d must declare dbc package_version 2", i) } } return release, nil diff --git a/internal/sourceresolution/resolver_test.go b/internal/sourceresolution/resolver_test.go index 81ffb788..00ef336a 100644 --- a/internal/sourceresolution/resolver_test.go +++ b/internal/sourceresolution/resolver_test.go @@ -68,9 +68,9 @@ func TestResolvePackslipRejectsMismatchedMetadata(t *testing.T) { {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: "no finalized hash"}, - {name: "artifact size", change: func(r *resolution.ResolvedRelease) { r.Artifacts[0].Size = nil }, want: "no finalized size"}, - {name: "missing package marker", change: func(r *resolution.ResolvedRelease) { r.Artifacts[0].PackageVersion = 0 }, want: "no supported dbc package_version"}, + {name: "artifact hash", change: func(r *resolution.ResolvedRelease) { r.Artifacts[0].Hash = "" }, want: "hash and size must either both be present or both be absent"}, + {name: "artifact size", change: func(r *resolution.ResolvedRelease) { r.Artifacts[0].Size = nil }, want: "hash and size must either both be present or both be absent"}, + {name: "missing package marker", change: func(r *resolution.ResolvedRelease) { r.Artifacts[0].PackageVersion = 0 }, want: "must declare dbc package_version 2"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { From 86e5c7a42b40502820db805d9e09c4c8e90098ad Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 07:37:24 +0000 Subject: [PATCH 075/115] fix: reject unproven Packslip locks --- cmd/dbc/lockfile_v2.go | 21 +++--- cmd/dbc/lockfile_v2_test.go | 129 +++++++++++++++++++++++++++--------- 2 files changed, 110 insertions(+), 40 deletions(-) diff --git a/cmd/dbc/lockfile_v2.go b/cmd/dbc/lockfile_v2.go index a43d4c8b..997f218d 100644 --- a/cmd/dbc/lockfile_v2.go +++ b/cmd/dbc/lockfile_v2.go @@ -233,7 +233,17 @@ func validateLockInfo(entry lockInfo) error { if len(entry.Artifacts) == 0 { return fmt.Errorf("driver %q has no locked artifacts", entry.Name) } - return validateLockArtifacts(entry.Artifacts) + if err := validateLockArtifacts(entry.Artifacts); err != nil { + return err + } + if entry.Source.Type == "packslip" { + for i, artifact := range entry.Artifacts { + if artifact.PackageVersion != 2 { + return fmt.Errorf("driver %q packslip artifact %d must declare dbc package_version = 2, got %d", entry.Name, i, artifact.PackageVersion) + } + } + } + return nil } func cloneInt64(value *int64) *int64 { @@ -411,15 +421,6 @@ func selectLockedArtifact(entry lockInfo, platform string, locked bool) (lockArt return lockArtifact{}, fmt.Errorf("%w for %s on %s", ErrArtifactAmbiguous, entry.Name, platform) } if len(matches) == 1 { - if entry.Source.Type == "packslip" && matches[0].PackageVersion != 2 { - if locked { - return lockArtifact{}, &LockedModeArtifactMissingError{DriverID: entry.Name, Platform: platform} - } - return lockArtifact{}, &LockRefreshRequiredError{ - DriverID: entry.Name, Platform: platform, - Reason: "locked Packslip artifact is missing its signed dbc package_version declaration", - } - } return matches[0], nil } if locked { diff --git a/cmd/dbc/lockfile_v2_test.go b/cmd/dbc/lockfile_v2_test.go index c442860a..1c36c6e7 100644 --- a/cmd/dbc/lockfile_v2_test.go +++ b/cmd/dbc/lockfile_v2_test.go @@ -210,32 +210,71 @@ func TestLockFileV2AllowsPartialArtifactSetAndReplayNeedsNoDiscovery(t *testing. assert.Contains(t, err.Error(), "locked mode") } -func TestPackslipLockWithoutPackageVersionRequiresRefresh(t *testing.T) { - entry := testLockEntry() - entry.Artifacts[0].PackageVersion = 0 // Old prototype Packslip lock did not record this proof. - 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) - _, err = selectLockedArtifact(loaded.lockinfo["example"], "linux_amd64_gnu_v1", false) - var refreshErr *LockRefreshRequiredError - require.ErrorAs(t, err, &refreshErr) - require.Contains(t, err.Error(), "missing its signed dbc package_version declaration") - require.ErrorIs(t, err, ErrLockRefreshRequired) - require.NotErrorIs(t, err, ErrLockedModeArtifactMissing) +func TestPackslipLockRequiresPackageVersionTwoForEveryArtifact(t *testing.T) { + for artifactIndex := range testLockEntry().Artifacts { + t.Run(strconv.Itoa(artifactIndex), func(t *testing.T) { + entry := testLockEntry() + entry.Artifacts[artifactIndex].PackageVersion = 0 + + err := validateLockInfo(entry) + require.ErrorContains(t, err, "packslip artifact") + require.ErrorContains(t, err, "package_version = 2") + }) + } } -func TestPackslipLockWithoutPackageVersionFailsInLockedMode(t *testing.T) { - entry := testLockEntry() - entry.Artifacts[0].PackageVersion = 0 // Old prototype Packslip lock did not record this proof. - - _, err := selectLockedArtifact(entry, "linux_amd64_gnu_v1", true) - var lockedErr *LockedModeArtifactMissingError - require.ErrorAs(t, err, &lockedErr) - require.ErrorIs(t, err, ErrLockedModeArtifactMissing) - require.ErrorIs(t, err, ErrLockedArtifactMissing) - require.NotErrorIs(t, err, ErrLockRefreshRequired) - require.Contains(t, err.Error(), "locked mode") +func TestPackslipLockWithoutPackageVersionIsRejectedDuringLoad(t *testing.T) { + tests := []struct { + name string + replacement string + }{ + {name: "missing", replacement: ""}, + {name: "zero", replacement: "package_version = 0\n"}, + {name: "unknown", replacement: "package_version = 3\n"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "dbc.lock") + require.NoError(t, writeLockFileAtomic(path, LockFile{Version: lockFileVersion, Drivers: []lockInfo{testLockEntry()}})) + data, err := os.ReadFile(path) + require.NoError(t, err) + data = []byte(strings.ReplaceAll(string(data), "package_version = 2\n", test.replacement)) + require.NoError(t, os.WriteFile(path, data, 0o600)) + + _, err = loadLockFile(path) + if test.name != "unknown" { + require.ErrorContains(t, err, "packslip artifact") + require.ErrorContains(t, err, "package_version = 2") + } else { + require.ErrorContains(t, err, "unsupported dbc package version 3") + } + }) + } +} + +func TestPackageVersionZeroRemainsAllowedForRegistryAndPathLocks(t *testing.T) { + sources := []lockSource{ + {Type: "registry", URL: "https://registry.example.test"}, + {Type: "path", Path: "./drivers/example"}, + } + for _, source := range sources { + t.Run(source.Type, func(t *testing.T) { + entry := testLockEntry() + entry.Source = source + for i := range entry.Artifacts { + entry.Artifacts[i].PackageVersion = 0 + } + + require.NoError(t, validateLockInfo(entry)) + 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) + for _, artifact := range loaded.lockinfo[entry.Name].Artifacts { + assert.Zero(t, artifact.PackageVersion) + } + }) + } } func TestLockFileV2RejectsUnknownVersion(t *testing.T) { @@ -402,27 +441,57 @@ func TestAtomicLockWriterKeepsOldFileWhenReplacementFails(t *testing.T) { func TestRefreshRejectsArtifactContradictionAndAllowsNewTarget(t *testing.T) { existing := testLockEntry() refreshed := existing + newArtifact := testLockArtifact("windows_amd64", "c", 22) + newArtifact.PackageVersion = 2 contradictory := existing.Artifacts[0] contradictory.Hash = "sha256:" + strings.Repeat("f", 64) contradictory.Size = int64Pointer(999) - refreshed.Artifacts = []lockArtifact{contradictory, testLockArtifact("windows_amd64", "c", 22)} + 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], testLockArtifact("windows_amd64", "c", 22)} + refreshed.Artifacts = []lockArtifact{existing.Artifacts[0], newArtifact} merged, err := refreshLockEntry(existing, refreshed) require.NoError(t, err) assert.Len(t, merged.Artifacts, 3) } -func TestRefreshBackfillsMissingPackslipPackageVersion(t *testing.T) { +func TestRefreshRejectsUnprovenPackslipLockInsteadOfBackfilling(t *testing.T) { existing := testLockEntry() existing.Artifacts[0].PackageVersion = 0 refreshed := testLockEntry() - merged, err := refreshLockEntry(existing, refreshed) - require.NoError(t, err) - require.Equal(t, 2, merged.Artifacts[0].PackageVersion) + + _, err := refreshLockEntry(existing, refreshed) + require.ErrorContains(t, err, "existing lock entry") + require.ErrorContains(t, err, "packslip artifact") + require.ErrorContains(t, err, "package_version = 2") +} + +func TestRefreshBackfillsMissingNonPackslipPackageVersion(t *testing.T) { + sources := []lockSource{ + {Type: "registry", URL: "https://registry.example.test"}, + {Type: "path", Path: "./drivers/example"}, + } + for _, source := range sources { + t.Run(source.Type, func(t *testing.T) { + existing := testLockEntry() + existing.Source = source + for i := range existing.Artifacts { + existing.Artifacts[i].PackageVersion = 0 + } + refreshed := cloneLockInfo(existing) + for i := range refreshed.Artifacts { + refreshed.Artifacts[i].PackageVersion = 2 + } + + merged, err := refreshLockEntry(existing, refreshed) + require.NoError(t, err) + for _, artifact := range merged.Artifacts { + require.Equal(t, 2, artifact.PackageVersion) + } + }) + } } func TestRefreshReplacesEvidenceWithLatestCanonicalSnapshot(t *testing.T) { From 6e4e9c4cb75936d2e9aece71d2f850938745347d Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 07:47:41 +0000 Subject: [PATCH 076/115] refactor: centralize release version identity --- cmd/dbc/lockfile_v2.go | 8 ++++-- cmd/dbc/lockfile_v2_test.go | 35 ++++++++++++++++++++++++ cmd/dbc/sync.go | 12 ++++---- cmd/dbc/sync_test.go | 5 ++++ internal/sourceresolution/requirement.go | 23 ++++++++++++++++ 5 files changed, 74 insertions(+), 9 deletions(-) diff --git a/cmd/dbc/lockfile_v2.go b/cmd/dbc/lockfile_v2.go index 997f218d..f3556fe7 100644 --- a/cmd/dbc/lockfile_v2.go +++ b/cmd/dbc/lockfile_v2.go @@ -24,6 +24,8 @@ import ( "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" ) @@ -318,7 +320,9 @@ func verifyLegacyLibraryProof(entry lockInfo, platform, installedLibraryHash str // 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 || existing.Version == nil || refreshed.Version == nil || !existing.Version.Equal(refreshed.Version) { + 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) { @@ -393,7 +397,7 @@ 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 existing.Version.Equal(upgraded.Version) { + 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 { diff --git a/cmd/dbc/lockfile_v2_test.go b/cmd/dbc/lockfile_v2_test.go index 1c36c6e7..13d342bb 100644 --- a/cmd/dbc/lockfile_v2_test.go +++ b/cmd/dbc/lockfile_v2_test.go @@ -625,6 +625,41 @@ func TestVersionUpgradeIsSeparateFromMetadataRefresh(t *testing.T) { 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 TestLockSnapshotRequiresFinalizedArtifactMetadata(t *testing.T) { release := testResolvedRelease() release.Artifacts[0].Hash = "" diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 2e339843..2cd39a58 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -40,6 +40,7 @@ import ( "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 SyncCmd struct { @@ -528,6 +529,9 @@ func lockVersionSatisfiesSpec(entry lockInfo, spec driverSpec) bool { } if spec.Version != nil { if spec.Source != nil && spec.Source.Type == dbc.DriverSourcePackslip { + // This is a requirement check: Packslip's validated exact request is + // represented by a constraint, but lock reuse must preserve its full + // canonical version string, including build metadata. return entry.Version.String() == spec.Version.String() } // An explicit constraint can name a prerelease directly. Let semver's @@ -722,13 +726,7 @@ func canReuseLockedEntry(item installItem) bool { } func packageVersionsMatch(sourceType string, locked, resolved *semver.Version) bool { - if locked == nil || resolved == nil { - return false - } - if sourceType == string(dbc.DriverSourcePackslip) { - return locked.String() == resolved.String() - } - return locked.Equal(resolved) + return sourceresolution.SameReleaseVersion(sourceidentity.Kind(sourceType), locked, resolved) } func packageLockSource(item installItem) (lockSource, error) { diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index a4ec2d0b..be4aa7bd 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -412,8 +412,13 @@ func TestPackageVersionIdentityDependsOnSource(t *testing.T) { assert.True(t, packageVersionsMatch("packslip", locked, resolvedSame)) assert.False(t, packageVersionsMatch("packslip", locked, resolvedDifferent), "Packslip artifact identity includes build metadata") + assert.True(t, packageVersionsMatch("path", locked, resolvedSame)) + assert.False(t, packageVersionsMatch("path", locked, resolvedDifferent), + "path artifact identity includes build metadata") assert.True(t, packageVersionsMatch("registry", locked, resolvedDifferent), "registry keeps the existing SemVer equality semantics") + assert.False(t, packageVersionsMatch("unknown", locked, resolvedSame), + "unsupported source types have no release version identity rule") } func TestAcquireSyncProjectLockDeadlineIsContentionButCancelIsNot(t *testing.T) { diff --git a/internal/sourceresolution/requirement.go b/internal/sourceresolution/requirement.go index e25f8c12..6e51ebfe 100644 --- a/internal/sourceresolution/requirement.go +++ b/internal/sourceresolution/requirement.go @@ -149,6 +149,29 @@ 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 From defab886bd256932ce56dbf4b3f397eba9f8a4e7 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 08:15:37 +0000 Subject: [PATCH 077/115] refactor: make sync execution source neutral --- cmd/dbc/helpers.go | 1 + cmd/dbc/lockfile_v2_test.go | 171 ++++++++---- cmd/dbc/main.go | 9 + cmd/dbc/sync.go | 516 +++++++++++++++++++++--------------- cmd/dbc/sync_test.go | 94 ++++--- 5 files changed, 480 insertions(+), 311 deletions(-) diff --git a/cmd/dbc/helpers.go b/cmd/dbc/helpers.go index 08147cbf..1330e25c 100644 --- a/cmd/dbc/helpers.go +++ b/cmd/dbc/helpers.go @@ -61,6 +61,7 @@ func defaultBaseModel() baseModel { return baseModel{ getDriverRegistry: getDriverRegistry, downloadPkg: downloadPkg, + downloadArtifact: downloadPackage, } } diff --git a/cmd/dbc/lockfile_v2_test.go b/cmd/dbc/lockfile_v2_test.go index 13d342bb..2960d793 100644 --- a/cmd/dbc/lockfile_v2_test.go +++ b/cmd/dbc/lockfile_v2_test.go @@ -689,17 +689,22 @@ func TestSyncAdapterReusesCompleteV2SnapshotWithoutRegistryHashes(t *testing.T) } item, err := installItemFromLockedArtifact("example", entry, entry.Artifacts[0]) require.NoError(t, err) - assert.Equal(t, registryURL.String(), item.Driver.Registry.BaseURL.String()) - assert.Equal(t, "https://assets.example.test/archive.tar.gz", item.Package.Path.String(), "artifact host is independent of source identity") - assert.Equal(t, entry.Artifacts[0].Hash, item.Package.ArtifactHash) - assert.Equal(t, *entry.Artifacts[0].Size, *item.Package.ArtifactSize) + 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)) - item.ArtifactFormat = "tgz" + selected.Format = "tgz" assert.False(t, canReuseLockedEntry(item), "a locked artifact with a different format is not the selected artifact proof") - item.ArtifactFormat = entry.Artifacts[0].Format - item.HostRequirements.Libs = []string{"libc.so.6"} + selected.Format = entry.Artifacts[0].Format + selected.PackageVersion = 2 + assert.False(t, canReuseLockedEntry(item), "a different package version declaration is not the selected artifact proof") + selected.PackageVersion = entry.Artifacts[0].PackageVersion + 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") - item.HostRequirements.Libs = nil + selected.HostRequirements.Libs = nil item.InstalledLibraryHash = strings.Repeat("f", 64) updated, err := lockEntryForItem(item) @@ -743,32 +748,32 @@ func TestSyncAdapterPreservesLockedURLSpellingWhenReusingSnapshot(t *testing.T) } func TestSyncAdapterRejectsFreshEntryWithoutArchiveMetadata(t *testing.T) { - registryURL, err := url.Parse("https://registry.example.test") - require.NoError(t, err) - packageURL, err := url.Parse("https://registry.example.test/archive.tar.gz") - require.NoError(t, err) - item := installItem{ - Driver: dbc.Driver{Path: "example", Registry: &dbc.Registry{BaseURL: registryURL}}, - Package: dbc.PkgInfo{Version: semver.MustParse("1.2.3"), PlatformTuple: "linux_amd64", Path: packageURL}, - } - _, err = lockEntryForItem(item) + 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(), "hash and size must either both be present or both be absent") + assert.Contains(t, err.Error(), "has no finalized hash") } -func TestSyncAdapterRejectsPathArtifactForRegistryReplay(t *testing.T) { +func TestSyncAdapterPreservesLockedPathArtifactLocation(t *testing.T) { entry := testRegistryLockEntryForPlatform(config.PlatformTuple()) entry.Artifacts[0].Location = resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: "./archive.tar.gz"} - _, err := installItemFromLockedArtifact("example", entry, entry.Artifacts[0]) - assert.ErrorContains(t, err, "not a remote URL artifact") + 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) - packageURL, err := url.Parse("https://registry.example.test/archive.tar.gz") - require.NoError(t, err) entry := lockInfo{ Name: "example", Version: semver.MustParse("1.2.3"), @@ -779,11 +784,9 @@ func TestSyncAdapterVerifiesLegacyLibraryProofBeforeReusingV2Entry(t *testing.T) Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://registry.example.test/archive.tar.gz"}, Hash: "sha256:" + strings.Repeat("a", 64), Size: int64Pointer(10), }}, } - item := installItem{ - Driver: dbc.Driver{Path: "example", Registry: &dbc.Registry{BaseURL: registryURL}}, - Package: dbc.PkgInfo{Version: entry.Version, PlatformTuple: platform, Path: packageURL}, - InstalledLibraryHash: strings.Repeat("0", 64), LockEntry: &entry, - } + 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") @@ -797,11 +800,16 @@ func TestDownloadedArchiveSnapshotUsesArchiveBytes(t *testing.T) { require.NoError(t, err) defer archive.Close() - item := installItem{Package: dbc.PkgInfo{}} + 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: "tar.gz", Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://registry.example.test/archive.tar.gz"}}}, + }, config.PlatformTuple(), nil) require.NoError(t, snapshotDownloadedArchive(&item, archive)) digest := sha256.Sum256(archiveBytes) - assert.Equal(t, "sha256:"+hex.EncodeToString(digest[:]), item.ArchiveHash) - assert.EqualValues(t, len(archiveBytes), item.ArchiveSize) + selected, err := item.selectedArtifact() + require.NoError(t, err) + assert.Equal(t, "sha256:"+hex.EncodeToString(digest[:]), selected.Hash) + assert.EqualValues(t, len(archiveBytes), *selected.Size) assert.Empty(t, item.InstalledLibraryHash, "archive bytes must not be recorded as an installed-library hash") } @@ -815,17 +823,18 @@ func TestLockedArchiveDownloadMustMatchExpectedHashAndSize(t *testing.T) { tests := []struct { name string - pkg dbc.PkgInfo + hash string + size *int64 want string }{ { name: "hash mismatch", - pkg: dbc.PkgInfo{ArtifactHash: "sha256:" + strings.Repeat("a", 64), ArtifactSize: &actualSize}, + hash: "sha256:" + strings.Repeat("a", 64), size: &actualSize, want: "does not match expected hash", }, { name: "size mismatch", - pkg: dbc.PkgInfo{ArtifactHash: actualHash, ArtifactSize: int64Pointer(actualSize + 1)}, + hash: actualHash, size: int64Pointer(actualSize + 1), want: "does not match expected size", }, } @@ -834,11 +843,17 @@ func TestLockedArchiveDownloadMustMatchExpectedHashAndSize(t *testing.T) { archive, err := os.Open(archivePath) require.NoError(t, err) defer archive.Close() - item := installItem{Package: tt.pkg} + 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: "tar.gz", + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://registry.example.test/archive.tar.gz"}, Hash: tt.hash, Size: cloneInt64(tt.size)}}, + }, config.PlatformTuple(), nil) err = snapshotDownloadedArchive(&item, archive) require.Error(t, err) assert.Contains(t, err.Error(), tt.want) - assert.Empty(t, item.ArchiveHash, "failed verification must not produce a lock snapshot") + selected, selectErr := item.selectedArtifact() + require.NoError(t, selectErr) + assert.Equal(t, tt.hash, selected.Hash, "failed verification must not produce a lock snapshot") }) } } @@ -870,10 +885,12 @@ func TestV2RegistryReplaySkipsDiscoveryAndUsesLockedURLAndMetadata(t *testing.T) require.True(t, ok, "expected lock replay install items, got %T", msg) require.Len(t, items, 1) assert.Zero(t, discoveryCalls, "registry discovery must not run for a complete v2 registry artifact") - assert.Equal(t, entry.Artifacts[0].Location.Value, items[0].Package.Path.String()) - assert.Equal(t, entry.Artifacts[0].Hash, items[0].Package.ArtifactHash) - assert.Equal(t, *entry.Artifacts[0].Size, *items[0].Package.ArtifactSize) - assert.Equal(t, entry.Source.URL, items[0].Driver.Registry.BaseURL.String()) + selected, err := items[0].selectedArtifact() + require.NoError(t, err) + assert.Equal(t, entry.Artifacts[0].Location, selected.Location) + assert.Equal(t, entry.Artifacts[0].Hash, selected.Hash) + assert.Equal(t, *entry.Artifacts[0].Size, *selected.Size) + assert.Equal(t, entry.Source.URL, items[0].Release.Source.Reference) // The normal completion path builds the entry from the locked PkgInfo and // rewrites v2 without changing its artifact snapshot. @@ -957,7 +974,7 @@ func TestV2RegistryReplayAllowsExplicitPrereleaseConstraintOffline(t *testing.T) items, ok := msg.([]installItem) require.True(t, ok, "expected explicit prerelease constraint to replay lock, got %T", msg) require.Len(t, items, 1) - assert.Equal(t, "1.2.3-beta.1", items[0].Package.Version.String()) + assert.Equal(t, "1.2.3-beta.1", items[0].Release.Version) assert.Zero(t, discoveryCalls, "an explicit matching prerelease constraint must not trigger registry discovery") } @@ -1044,11 +1061,13 @@ func TestResolvedTGZArtifactReplaysThroughPackageValidation(t *testing.T) { assert.Equal(t, "tgz", selected.Format) item, err := installItemFromLockedArtifact("test-driver-1", loaded.lockinfo["test-driver-1"], selected) require.NoError(t, err) - assert.Equal(t, "tgz", item.ArtifactFormat) + 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")} prepared, err := model.prepareInstallItems(context.Background(), []installItem{item}) require.NoError(t, err) defer closePreparedArchives(prepared.items) @@ -1059,6 +1078,41 @@ func TestResolvedTGZArtifactReplaysThroughPackageValidation(t *testing.T) { 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")} + prepared, err := model.prepareInstallItems(context.Background(), []installItem{item}) + require.NoError(t, err) + defer closePreparedArchives(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", @@ -1066,13 +1120,19 @@ func TestSelectedArtifactMetadataCopiesHostRequirements(t *testing.T) { Libs: []string{"libssl.so.3"}, Bins: []resolution.NamedRequirement{{Name: "git", Min: "2.40"}}, } - item := installItem{} - require.NoError(t, setSelectedArtifactMetadata(&item, resolution.Artifact{Format: "tgz", HostRequirements: requirements})) + 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" - assert.Equal(t, "tgz", item.ArtifactFormat) - assert.Equal(t, []string{"libssl.so.3"}, item.HostRequirements.Libs) - assert.Equal(t, []resolution.NamedRequirement{{Name: "git", Min: "2.40"}}, item.HostRequirements.Bins) + 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) { @@ -1132,16 +1192,11 @@ func TestUnsupportedHostRequirementsFailClosedBeforePreparationOrEnsure(t *testi loaded, err := config.GetDriver(cfg, "example") require.NoError(t, err) - registryURL, err := url.Parse("https://registry.example.test") - require.NoError(t, err) - packageURL, err := url.Parse("https://assets.example.test/example.tgz") - require.NoError(t, err) - item := installItem{ - Driver: dbc.Driver{Path: "example", Registry: &dbc.Registry{BaseURL: registryURL}}, - Package: dbc.PkgInfo{Version: semver.MustParse("1.2.3"), PlatformTuple: config.PlatformTuple(), Path: packageURL}, - ArtifactFormat: "tgz", - HostRequirements: tt.requirements, - } + 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.InstallOptions, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { diff --git a/cmd/dbc/main.go b/cmd/dbc/main.go index e0b1973f..fe24aa48 100644 --- a/cmd/dbc/main.go +++ b/cmd/dbc/main.go @@ -18,6 +18,7 @@ import ( "context" "errors" "fmt" + "io" "net/http" "os" "slices" @@ -202,6 +203,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: @@ -218,6 +226,7 @@ func getConfig(c config.ConfigLevel) config.Config { type baseModel struct { getDriverRegistry func() ([]dbc.Driver, error) downloadPkg func(p dbc.PkgInfo) (*os.File, error) + downloadArtifact func(context.Context, dbc.PkgInfo) (io.ReadCloser, error) status int err error diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 2cd39a58..dfb1dab7 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -337,23 +337,62 @@ func reloadConfigTarget(selected config.Config) config.Config { } type installItem struct { - Driver dbc.Driver - Package dbc.PkgInfo - ArtifactFormat string - PackageVersion int - HostRequirements resolution.HostRequirements - Checksum string - ArchiveHash string - ArchiveSize int64 + Release resolution.ResolvedRelease + ArtifactIndex int + Platform string InstalledLibraryHash string ValidatedLibraryHash string LockEntry *lockInfo - Archive *os.File + Archive *sourceresolution.OpenedArtifact Expected config.ExpectedPackageMetadata Validation *config.PackageValidation AlreadyInstalled *config.DriverInfo } +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 @@ -433,21 +472,15 @@ func (s syncModel) createInstallList(list DriversList) ([]installItem, error) { if err != nil { return nil, err } - - items = append(items, installItem{ - Driver: drv, - Package: pkg, - ArtifactFormat: "tar.gz", - HostRequirements: resolution.HostRequirements{}, - Checksum: info.legacyChecksumFor(config.PlatformTuple()), - LockEntry: func() *lockInfo { - if info.Version == nil { - return nil - } - copy := info - return © - }(), - }) + var priorLock *lockInfo + if info.Version != nil { + priorLock = &info + } + item, err := installItemFromRegistryPackage(drv, pkg, priorLock) + if err != nil { + return nil, err + } + items = append(items, item) } return items, nil } @@ -520,6 +553,36 @@ func findDriverInDeclaredRegistry(name string, drivers []dbc.Driver, source *dbc return driver, nil } +func installItemFromRegistryPackage(driver dbc.Driver, pkg dbc.PkgInfo, priorLock *lockInfo) (installItem, error) { + if driver.Path == "" || pkg.Version == nil || strings.TrimSpace(pkg.PlatformTuple) == "" { + return installItem{}, errors.New("registry package metadata is incomplete") + } + if driver.Registry == nil || driver.Registry.BaseURL == nil { + return installItem{}, fmt.Errorf("driver %q has no registry identity", driver.Path) + } + if pkg.Path == nil { + return installItem{}, fmt.Errorf("registry package metadata for %q has no archive URL", driver.Path) + } + target, err := resolution.TargetFromPlatformTuple(pkg.PlatformTuple) + if err != nil { + return installItem{}, fmt.Errorf("invalid registry package platform %q: %w", pkg.PlatformTuple, err) + } + size := cloneInt64(pkg.ArtifactSize) + release := resolution.ResolvedRelease{ + DriverID: driver.Path, + Version: pkg.Version.String(), + Source: resolution.SourceSpec{Type: "registry", Reference: driver.Registry.BaseURL.String()}, + Artifacts: []resolution.Artifact{{ + Target: target, Format: "tar.gz", Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: pkg.Path.String()}, + Hash: pkg.ArtifactHash, Size: size, + }}, + } + if err := resolution.ValidateResolvedReleaseCandidate(release); err != nil { + return installItem{}, fmt.Errorf("invalid registry package metadata: %w", err) + } + return newInstallItem(release, 0, pkg.PlatformTuple, priorLock) +} + func lockVersionSatisfiesSpec(entry lockInfo, spec driverSpec) bool { if !driverSourceMatchesLock(spec.Source, entry.Source) { return false @@ -556,9 +619,6 @@ func driverSourceMatchesLock(source *dbc.DriverSource, locked lockSource) bool { } func installItemFromLockedArtifact(name string, entry lockInfo, artifact lockArtifact) (installItem, error) { - if artifact.Location.Kind != resolution.ArtifactLocationURL || artifact.Location.Value == "" { - return installItem{}, fmt.Errorf("locked registry artifact for %s is not a remote URL artifact", name) - } if err := validateInstallableArtifactFormat(artifact.Format); err != nil { return installItem{}, fmt.Errorf("locked artifact for %s: %w", name, err) } @@ -566,55 +626,27 @@ func installItemFromLockedArtifact(name string, entry lockInfo, artifact lockArt if err := validateHostRequirements(name, requirements); err != nil { return installItem{}, err } - packageURL, err := url.Parse(artifact.Location.Value) - if err != nil || !packageURL.IsAbs() || packageURL.Hostname() == "" { - return installItem{}, fmt.Errorf("invalid locked artifact URL for %s: %q", name, artifact.Location.Value) - } - registryURL, err := url.Parse(entry.Source.URL) - if err != nil || !registryURL.IsAbs() || registryURL.Hostname() == "" { - return installItem{}, fmt.Errorf("invalid locked registry identity for %s: %q", name, entry.Source.URL) - } - driver := dbc.Driver{Path: name, Title: name, Registry: &dbc.Registry{BaseURL: registryURL}} - pkg := dbc.PkgInfo{ - Driver: driver, - Version: entry.Version, - PlatformTuple: config.PlatformTuple(), - Path: packageURL, - ArtifactHash: artifact.Hash, - ArtifactSize: cloneInt64(artifact.Size), - } - copy := cloneLockInfo(entry) - item := installItem{ - Driver: driver, - Package: pkg, - PackageVersion: artifact.PackageVersion, - Checksum: entry.legacyChecksumFor(config.PlatformTuple()), - LockEntry: ©, - } - if err := setSelectedArtifactMetadata(&item, resolution.Artifact{ - Format: artifact.Format, - PackageVersion: artifact.PackageVersion, - HostRequirements: 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) } - return item, nil -} - -// setSelectedArtifactMetadata copies source-neutral execution metadata from a -// resolved artifact onto an install item. Source integrations should call this -// after selecting the concrete target artifact. -func setSelectedArtifactMetadata(item *installItem, artifact resolution.Artifact) error { - if item == nil { - return errors.New("cannot set artifact metadata on a nil install item") + release := entry.resolvedRelease() + artifactIndex := -1 + for index := range release.Artifacts { + if release.Artifacts[index].Target == artifact.Target { + artifactIndex = index + break + } } - if err := validateInstallableArtifactFormat(artifact.Format); err != nil { - return err + if artifactIndex < 0 { + return installItem{}, fmt.Errorf("locked artifact for %s is not part of its release snapshot", name) } - item.ArtifactFormat = artifact.Format - item.PackageVersion = artifact.PackageVersion - item.HostRequirements = cloneHostRequirements(artifact.HostRequirements) - return nil + 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 validateInstallableArtifactFormat(format string) error { @@ -701,45 +733,64 @@ func (s syncModel) persistCandidateLock(lock LockFile) error { } func canReuseLockedEntry(item installItem) bool { - if item.LockEntry == nil || item.LockEntry.Version == nil || item.Package.Version == nil || - !packageVersionsMatch(item.LockEntry.Source.Type, item.LockEntry.Version, item.Package.Version) { - return false - } - source, err := packageLockSource(item) - if err != nil || !sameLockSourceIdentity(item.LockEntry.Source, source) { + selected, err := item.selectedArtifact() + if err != nil || item.LockEntry == nil || item.LockEntry.Version == nil { return false } - artifact, err := selectLockedArtifact(*item.LockEntry, config.PlatformTuple(), false) - if err != nil || artifact.Location.Kind != resolution.ArtifactLocationURL || artifact.Location.Value == "" || item.Package.Path == nil || item.Package.ArtifactSize == nil { + version, err := semver.NewVersion(item.Release.Version) + if err != nil || !packageVersionsMatch(item.LockEntry.Source.Type, item.LockEntry.Version, version) { return false } - if artifact.Format != item.ArtifactFormat || - !reflect.DeepEqual(canonicalHostRequirements(artifact.HostRequirements), canonicalHostRequirements(lockHostRequirementsFromResolution(item.HostRequirements))) { + source, err := lockSourceForResolvedRelease(item.Release) + if err != nil || !sameLockSourceIdentity(item.LockEntry.Source, source) { return false } - lockedURL, err := url.Parse(artifact.Location.Value) - if err != nil || lockedURL == nil { + locked, err := selectLockedArtifact(*item.LockEntry, item.Platform, false) + if err != nil { return false } - return item.Package.Path.String() == lockedURL.String() && - item.Package.ArtifactHash == artifact.Hash && *item.Package.ArtifactSize == *artifact.Size + return locked.Target == selected.Target && locked.Location == selected.Location && + locked.Hash == selected.Hash && sameLockSize(locked.Size, selected.Size) && + locked.Format == selected.Format && locked.PackageVersion == selected.PackageVersion && + reflect.DeepEqual(canonicalHostRequirements(locked.HostRequirements), canonicalHostRequirements(lockHostRequirementsFromResolution(selected.HostRequirements))) } func packageVersionsMatch(sourceType string, locked, resolved *semver.Version) bool { return sourceresolution.SameReleaseVersion(sourceidentity.Kind(sourceType), locked, resolved) } -func packageLockSource(item installItem) (lockSource, error) { - if item.Driver.Registry == nil || item.Driver.Registry.BaseURL == nil { - return lockSource{}, fmt.Errorf("driver %q has no registry identity", item.Driver.Path) +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) } - return lockSource{Type: "registry", URL: item.Driver.Registry.BaseURL.String()}, nil + if _, err := lockSourceIdentity(source); err != nil { + return lockSource{}, err + } + return source, nil } func snapshotDownloadedArchive(item *installItem, archive *os.File) error { if archive == nil { return fmt.Errorf("download returned no archive") } + selected, err := item.selectedArtifact() + if err != nil { + return err + } + if (selected.Hash == "") != (selected.Size == nil) { + return errors.New("artifact hash and size must either both be present or both be absent") + } + if err := resolution.ValidateArtifactMetadata(selected.Hash, selected.Size); err != nil { + return err + } info, err := archive.Stat() if err != nil { return fmt.Errorf("failed to stat downloaded archive: %w", err) @@ -753,20 +804,62 @@ func snapshotDownloadedArchive(item *installItem, archive *os.File) error { if err := resolution.ValidateArtifactMetadata(actualHash, &actualSize); err != nil { return err } - if item.Package.ArtifactHash != "" && item.Package.ArtifactHash != actualHash { - return fmt.Errorf("downloaded archive hash %s does not match expected hash %s", actualHash, item.Package.ArtifactHash) + if selected.Hash != "" && selected.Hash != actualHash { + return fmt.Errorf("downloaded archive hash %s does not match expected hash %s", actualHash, selected.Hash) } - if item.Package.ArtifactSize != nil && *item.Package.ArtifactSize != actualSize { - return fmt.Errorf("downloaded archive size %d does not match expected size %d", actualSize, *item.Package.ArtifactSize) + if selected.Size != nil && *selected.Size != actualSize { + return fmt.Errorf("downloaded archive size %d does not match expected size %d", actualSize, *selected.Size) } - item.ArchiveHash = actualHash - item.ArchiveSize = actualSize + selected.Hash = actualHash + selected.Size = cloneInt64(&actualSize) if _, err := archive.Seek(0, io.SeekStart); err != nil { return fmt.Errorf("failed to rewind downloaded archive: %w", err) } return nil } +func (s syncModel) openResolvedArtifact(ctx context.Context, item installItem) (*sourceresolution.OpenedArtifact, error) { + selected, err := item.selectedArtifact() + if err != nil { + return nil, err + } + basePath := s.LockFilePath + if basePath == "" { + basePath = s.Path + } + if basePath == "" { + return nil, errors.New("sync project path is not set") + } + baseDir, err := filepath.Abs(filepath.Dir(basePath)) + if err != nil { + return nil, fmt.Errorf("resolve project directory: %w", err) + } + fetchURL := func(ctx context.Context, 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) + } + pkg := dbc.PkgInfo{ + Driver: dbc.Driver{Path: item.Release.DriverID, Title: item.Release.DriverID}, + Version: version, + PlatformTuple: item.Platform, + Path: artifactURL, + ArtifactHash: selected.Hash, + ArtifactSize: cloneInt64(selected.Size), + } + if s.downloadArtifact != nil { + return s.downloadArtifact(ctx, pkg) + } + if s.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 s.downloadPkg(pkg) + } + return sourceresolution.OpenArtifact(ctx, fetchURL, selected.Location, baseDir) +} + func (s syncModel) prepareInstallItems(ctx context.Context, items []installItem) (preparedSyncMsg, error) { prepared := preparedSyncMsg{items: items, lock: LockFile{Version: lockFileVersion}} for i := range prepared.items { @@ -774,16 +867,20 @@ func (s syncModel) prepareInstallItems(ctx context.Context, items []installItem) return prepared, err } item := &prepared.items[i] - if err := validateInstallableArtifactFormat(item.ArtifactFormat); err != nil { - return prepared, fmt.Errorf("driver %s: %w", item.Driver.Path, err) + selected, err := item.selectedArtifact() + if err != nil { + return prepared, err } - if err := validateHostRequirements(item.Driver.Path, item.HostRequirements); err != nil { + if err := validateInstallableArtifactFormat(selected.Format); err != nil { + return prepared, fmt.Errorf("driver %s: %w", item.Release.DriverID, err) + } + if err := validateHostRequirements(item.Release.DriverID, selected.HostRequirements); err != nil { return prepared, err } var sameVersionInstalled *config.DriverInfo if s.cfg.Exists { - if installed, ok := s.cfg.Drivers[item.Driver.Path]; ok { - if installed.Version != nil && item.Package.Version.String() == installed.Version.String() { + if installed, ok := s.cfg.Drivers[item.Release.DriverID]; ok { + if installed.Version != nil && item.Release.Version == installed.Version.String() { installedCopy := installed sameVersionInstalled = &installedCopy expected, _, err := expectedSyncPackageMetadata(*item) @@ -796,7 +893,7 @@ func (s syncModel) prepareInstallItems(ctx context.Context, items []installItem) return prepared, err } if matches { - libraryHash, hashErr := checksum(installed.Driver.Shared.Get(config.PlatformTuple())) + libraryHash, hashErr := checksum(installed.Driver.Shared.Get(item.Platform)) if hashErr != nil { return prepared, fmt.Errorf("failed to checksum installed driver: %w", hashErr) } @@ -817,7 +914,7 @@ func (s syncModel) prepareInstallItems(ctx context.Context, items []installItem) return prepared, err } if matches { - libraryHash, hashErr := checksum(sameVersionInstalled.Driver.Shared.Get(config.PlatformTuple())) + libraryHash, hashErr := checksum(sameVersionInstalled.Driver.Shared.Get(item.Platform)) if hashErr != nil { return prepared, fmt.Errorf("failed to checksum installed driver: %w", hashErr) } @@ -827,7 +924,7 @@ func (s syncModel) prepareInstallItems(ctx context.Context, items []installItem) 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, config.PlatformTuple()) { + item.LockEntry != nil && item.LockEntry.Legacy != nil && samePlatformTarget(item.LockEntry.Legacy.Platform, item.Platform) { item.InstalledLibraryHash = item.LockEntry.Legacy.LibraryHash } } @@ -858,31 +955,35 @@ func (s syncModel) downloadAndValidateItem(ctx context.Context, item *installIte if err := ctx.Err(); err != nil { return err } - if err := validateInstallableArtifactFormat(item.ArtifactFormat); err != nil { - return fmt.Errorf("driver %s: %w", item.Driver.Path, 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 } - if err := validateHostRequirements(item.Driver.Path, item.HostRequirements); err != nil { + expected, hasMetadata, err := expectedSyncPackageMetadata(*item) + if err != nil { return err } if item.Archive == nil { - // downloadPkg has no context API. The worker retains the project lock and - // checks cancellation immediately after this synchronous call returns. - archive, err := s.downloadPkg(item.Package) - if archive != nil { - item.Archive = archive - } - if ctx.Err() != nil { - return ctx.Err() - } + archive, err := s.openResolvedArtifact(ctx, *item) if err != nil { - return fmt.Errorf("failed to download driver: %w", err) + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("failed to open driver artifact: %w", err) } + item.Archive = archive } - archive := item.Archive + archive := item.Archive.File if err := snapshotDownloadedArchive(item, archive); err != nil { return fmt.Errorf("failed to snapshot downloaded driver archive: %w", err) } - expected, hasMetadata, err := expectedSyncPackageMetadata(*item) + selected, err = item.selectedArtifact() if err != nil { return err } @@ -894,16 +995,14 @@ func (s syncModel) downloadAndValidateItem(ctx context.Context, item *installIte if inspectErr != nil { return inspectErr } - item.PackageVersion = packageManifest.PackageVersion - expected.PackageVersion = item.PackageVersion + selected.PackageVersion = packageManifest.PackageVersion + expected.PackageVersion = selected.PackageVersion if packageManifest.PackageVersion == 2 { - return errors.New("registry package v2 requires archive hash and size metadata") + return errors.New("package v2 requires archive hash and size metadata") } } - item.Package.ArtifactHash = item.ArchiveHash - item.Package.ArtifactSize = cloneInt64(&item.ArchiveSize) - expected.ArchiveHash = item.ArchiveHash - expected.ArchiveSize = item.ArchiveSize + expected.ArchiveHash = selected.Hash + expected.ArchiveSize = *selected.Size item.Expected = expected if _, err := archive.Seek(0, io.SeekStart); err != nil { return fmt.Errorf("failed to rewind downloaded driver archive: %w", err) @@ -914,21 +1013,21 @@ func (s syncModel) downloadAndValidateItem(ctx context.Context, item *installIte return dbc.VerifyPackageSignature(stagingDir, manifest) } } - validation, err := config.ValidatePackage(item.Driver.Path, archive, expected, config.InstallOptions{Verify: verify}) + validation, err := config.ValidatePackage(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 validate driver package: %w", err) } - item.PackageVersion = validation.PackageVersion + selected.PackageVersion = validation.PackageVersion expected.PackageVersion = validation.PackageVersion item.Expected = expected item.Validation = &validation item.ValidatedLibraryHash = strings.TrimPrefix(validation.VerifiedLibraryHash, "sha256:") if item.ValidatedLibraryHash == "" { - candidateLibrary := validation.Registration.Driver.Shared.Get(config.PlatformTuple()) - if item.LockEntry != nil && item.LockEntry.Legacy != nil && samePlatformTarget(item.LockEntry.Legacy.Platform, config.PlatformTuple()) { + 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) } @@ -946,24 +1045,24 @@ func (s syncModel) itemCurrentMatches(item *installItem, current *config.DriverI if err != nil { return false, fmt.Errorf("failed to resolve installed driver receipt location: %w", err) } - libraryPath := current.Driver.Shared.Get(config.PlatformTuple()) + libraryPath := current.Driver.Shared.Get(item.Platform) if managed && present { if !valid || !config.InstallReceiptMatchesExpectedPackage(receipt, item.Expected) || !config.VerifyInstallReceiptLibraryIntegrity(libraryPath, receipt) || - !config.InstallReceiptMatchesRuntimeRegistration(receipt, *current, config.PlatformTuple()) { + !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, config.PlatformTuple()) { + !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, config.PlatformTuple()) || - !config.PackageValidationMatchesRuntimeRegistration(*current, *item.Validation, config.PlatformTuple()) { + !samePlatformTarget(item.LockEntry.Legacy.Platform, item.Platform) || + !config.PackageValidationMatchesRuntimeRegistration(*current, *item.Validation, item.Platform) { return false, nil } proofHash := item.LockEntry.Legacy.LibraryHash @@ -977,7 +1076,7 @@ func (s syncModel) itemCurrentMatches(item *installItem, current *config.DriverI if item.Validation.VerifiedLibraryHash != "" { return item.ValidatedLibraryHash == proofHash, nil } - if err := verifyLegacyExternalLibrary(item.Validation.Registration.Driver.Shared.Get(config.PlatformTuple()), proofHash); err != nil { + if err := verifyLegacyExternalLibrary(item.Validation.Registration.Driver.Shared.Get(item.Platform), proofHash); err != nil { return false, nil } return true, nil @@ -987,10 +1086,14 @@ func (s syncModel) ensurePreparedPackage(ctx context.Context, item *installItem) if err := ctx.Err(); err != nil { return config.EnsurePackageResult{}, err } - if err := validateInstallableArtifactFormat(item.ArtifactFormat); err != nil { - return config.EnsurePackageResult{}, fmt.Errorf("driver %s: %w", item.Driver.Path, err) + selected, err := item.selectedArtifact() + if err != nil { + return config.EnsurePackageResult{}, err + } + if err := validateInstallableArtifactFormat(selected.Format); err != nil { + return config.EnsurePackageResult{}, fmt.Errorf("driver %s: %w", item.Release.DriverID, err) } - if err := validateHostRequirements(item.Driver.Path, item.HostRequirements); err != nil { + if err := validateHostRequirements(item.Release.DriverID, selected.HostRequirements); err != nil { return config.EnsurePackageResult{}, err } callbacks := config.EnsurePackageCallbacks{ @@ -1006,10 +1109,10 @@ func (s syncModel) ensurePreparedPackage(ctx context.Context, item *installItem) if err := ctx.Err(); err != nil { return nil, err } - if _, err := item.Archive.Seek(0, io.SeekStart); err != nil { + if _, err := item.Archive.File.Seek(0, io.SeekStart); err != nil { return nil, fmt.Errorf("failed to rewind prepared driver archive: %w", err) } - return item.Archive, nil + return item.Archive.File, nil }, ValidateResult: func(result config.EnsurePackageResult) error { return s.validateEnsureResult(item, result) @@ -1025,7 +1128,7 @@ func (s syncModel) ensurePreparedPackage(ctx context.Context, item *installItem) if ensure == nil { ensure = config.EnsurePackage } - result, err := ensure(ctx, s.cfg, item.Driver.Path, item.Expected, config.InstallOptions{Verify: verify}, callbacks) + result, err := ensure(ctx, s.cfg, item.Release.DriverID, item.Expected, config.InstallOptions{Verify: verify}, callbacks) if err != nil && isPackageVerificationFailure(err) { return result, fmt.Errorf("failed to verify signature: %w", packageVerificationError(err)) } @@ -1049,16 +1152,16 @@ func (s syncModel) validateEnsureResult(item *installItem, result config.EnsureP if item.Validation == nil || result.Manifest == nil { return errors.New("installed package is missing validated candidate evidence") } - if !config.PackageValidationMatchesRuntimeRegistration(*result.Installed, *item.Validation, config.PlatformTuple()) || - !config.PackageValidationMatchesRuntimeRegistration(result.Manifest.DriverInfo, *item.Validation, config.PlatformTuple()) { + 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(config.PlatformTuple()) - if item.LockEntry != nil && item.LockEntry.Legacy != nil && samePlatformTarget(item.LockEntry.Legacy.Platform, config.PlatformTuple()) { + 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) } @@ -1069,10 +1172,10 @@ func (s syncModel) validateEnsureResult(item *installItem, result config.EnsureP if err != nil { return fmt.Errorf("failed to resolve installed driver receipt location: %w", err) } - libraryPath := result.Installed.Driver.Shared.Get(config.PlatformTuple()) + 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, config.PlatformTuple()) || + !config.InstallReceiptMatchesRuntimeRegistration(receipt, *result.Installed, item.Platform) || !config.VerifyInstallReceiptLibraryIntegrity(libraryPath, receipt) { return errors.New("installed package receipt does not match the validated package") } @@ -1087,12 +1190,32 @@ func (s syncModel) validateEnsureResult(item *installItem, result config.EnsureP } func expectedSyncPackageMetadata(item installItem) (config.ExpectedPackageMetadata, bool, error) { - expected, hasMetadata, err := expectedRegistryPackageMetadata(item.Package) + selected, err := item.selectedArtifact() if err != nil { return config.ExpectedPackageMetadata{}, false, err } - expected.PackageVersion = item.PackageVersion - return expected, hasMetadata, nil + 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 hasHash != hasSize { + return config.ExpectedPackageMetadata{}, false, errors.New("package metadata must include both archive hash and size") + } + if selected.PackageVersion == 2 && !hasHash { + return config.ExpectedPackageMetadata{}, false, errors.New("package v2 requires archive hash and size metadata") + } + expected := config.ExpectedPackageMetadata{ + ID: item.Release.DriverID, Version: item.Release.Version, Platform: item.Platform, + SourceType: item.Release.Source.Type, SourceIdentity: item.Release.Source.Reference, + PackageVersion: selected.PackageVersion, + } + if hasHash { + expected.ArchiveHash = selected.Hash + expected.ArchiveSize = *selected.Size + } + return expected, hasHash, nil } func markAlreadyInstalled(item *installItem, installed config.DriverInfo, libraryHash string) { @@ -1223,7 +1346,7 @@ func (s syncModel) runSyncWorker(worker *syncWorker) syncWorkerResultMsg { return fail("sync_failed", ctx.Err()) } for _, item := range items { - if !worker.send(ctx, syncResolvingMsg{driver: item.Driver.Path}) { + if !worker.send(ctx, syncResolvingMsg{driver: item.Release.DriverID}) { return fail("sync_failed", ctx.Err()) } } @@ -1330,15 +1453,19 @@ func (s syncModel) fail(code string, err error) (syncModel, tea.Cmd) { } func lockEntryForItem(item installItem) (lockInfo, error) { - if err := validateInstallableArtifactFormat(item.ArtifactFormat); err != nil { - return lockInfo{}, fmt.Errorf("driver %s: %w", item.Driver.Path, err) + selected, err := item.selectedArtifact() + if err != nil { + return lockInfo{}, err + } + if err := validateInstallableArtifactFormat(selected.Format); err != nil { + return lockInfo{}, fmt.Errorf("driver %s: %w", item.Release.DriverID, err) } - if err := validateHostRequirements(item.Driver.Path, item.HostRequirements); err != nil { + if err := validateHostRequirements(item.Release.DriverID, selected.HostRequirements); err != nil { return lockInfo{}, err } legacyProofMatches := true - if item.LockEntry != nil && item.LockEntry.Legacy != nil && samePlatformTarget(item.LockEntry.Legacy.Platform, config.PlatformTuple()) { - if err := verifyLegacyLibraryProof(*item.LockEntry, config.PlatformTuple(), item.InstalledLibraryHash); err != nil { + 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 } @@ -1351,63 +1478,24 @@ func lockEntryForItem(item installItem) (lockInfo, error) { if canReuseLockedEntry(item) && legacyProofMatches { return *item.LockEntry, nil } - if item.Package.Version == nil || item.Package.Path == nil { - return lockInfo{}, fmt.Errorf("driver %q has incomplete resolved package metadata", item.Driver.Path) - } - source, err := packageLockSource(item) - if err != nil { - return lockInfo{}, err - } - target, err := resolution.TargetFromPlatformTuple(item.Package.PlatformTuple) - if err != nil { - return lockInfo{}, fmt.Errorf("invalid resolved package target %q: %w", item.Package.PlatformTuple, err) - } - hash := item.ArchiveHash - if hash == "" { - hash = item.Package.ArtifactHash - } - size := item.ArchiveSize - if item.ArchiveHash == "" && item.Package.ArtifactSize != nil { - size = *item.Package.ArtifactSize - } - release := resolution.ResolvedRelease{ - DriverID: item.Driver.Path, - Version: item.Package.Version.String(), - Source: resolution.SourceSpec{Type: source.Type, Reference: source.URL}, - Artifacts: []resolution.Artifact{{ - Target: target, - Format: item.ArtifactFormat, - PackageVersion: item.PackageVersion, - Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: item.Package.Path.String()}, - Hash: hash, - Size: &size, - HostRequirements: cloneHostRequirements(item.HostRequirements), - }}, - } - candidate, err := lockInfoFromResolvedRelease(item.Driver.Path, release) + candidate, err := lockInfoFromResolvedRelease(item.Release.DriverID, item.Release) if err != nil { return lockInfo{}, err } if item.LockEntry == nil || item.LockEntry.Version == nil || - !packageVersionsMatch(candidate.Source.Type, item.LockEntry.Version, item.Package.Version) { - return candidate, nil - } - if item.LockEntry.Version == nil { + !packageVersionsMatch(candidate.Source.Type, item.LockEntry.Version, candidate.Version) { return candidate, nil } if len(item.LockEntry.Artifacts) == 0 { - if item.LockEntry.Version == nil { - return candidate, nil - } if item.LockEntry.Legacy != nil { if !legacyProofMatches { return candidate, nil } var verified *VerifiedLegacyLibrary - if samePlatformTarget(item.LockEntry.Legacy.Platform, config.PlatformTuple()) { - verified = &VerifiedLegacyLibrary{Platform: config.PlatformTuple(), LibraryHash: item.InstalledLibraryHash} + if samePlatformTarget(item.LockEntry.Legacy.Platform, item.Platform) { + verified = &VerifiedLegacyLibrary{Platform: item.Platform, LibraryHash: item.InstalledLibraryHash} } - return migrateV1Entry(*item.LockEntry, release, config.PlatformTuple(), verified) + return migrateV1Entry(*item.LockEntry, item.Release, item.Platform, verified) } return candidate, nil } @@ -1419,23 +1507,19 @@ func lockEntryForItem(item installItem) (lockInfo, error) { } func hasValidatedReplacementEvidence(item installItem) bool { - if item.AlreadyInstalled != nil || item.Archive == nil || item.ArchiveHash == "" || item.ArchiveSize <= 0 || item.ValidatedLibraryHash == "" { + selected, err := item.selectedArtifact() + if err != nil || item.AlreadyInstalled != nil || item.Archive == nil || selected.Hash == "" || selected.Size == nil || *selected.Size <= 0 || item.ValidatedLibraryHash == "" { return false } if validateLegacyLibraryHash(item.ValidatedLibraryHash) != nil || - item.Expected.ID != item.Driver.Path || item.Package.Version == nil || item.Expected.Version != item.Package.Version.String() || - item.Expected.Platform == "" || item.Expected.Platform != item.Package.PlatformTuple || - item.Expected.SourceType == "" || item.Expected.SourceIdentity == "" || - item.Expected.ArchiveHash != item.ArchiveHash || item.Expected.ArchiveSize != item.ArchiveSize || - item.Package.ArtifactHash != item.ArchiveHash || item.Package.ArtifactSize == nil || *item.Package.ArtifactSize != item.ArchiveSize { - return false - } - if item.Driver.Registry == nil || item.Driver.Registry.BaseURL == nil || - item.Expected.SourceType != "registry" || item.Expected.SourceIdentity != item.Driver.Registry.BaseURL.String() { + 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.ArchiveSize != *selected.Size { return false } - archiveInfo, err := item.Archive.Stat() - return err == nil && archiveInfo.Size() == item.ArchiveSize + archiveInfo, err := item.Archive.File.Stat() + return err == nil && archiveInfo.Size() == *selected.Size } func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -1523,7 +1607,7 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // this progress event. Retain the direct-update path for isolated model // tests and embedders that inject the event without a worker. if s.worker == nil { - chksum, err := checksum(msg.info.Driver.Shared.Get(config.PlatformTuple())) + chksum, err := checksum(msg.info.Driver.Shared.Get(msg.item.Platform)) if err != nil { return s.fail("checksum_failed", err) } @@ -1619,7 +1703,7 @@ func (s syncModel) View() tea.View { cellsAvail := max(0, s.width-lipgloss.Width(spin+prog+driverCount)) driverIndex := min(s.index, len(s.installItems)-1) - driverName := s.installItems[driverIndex].Driver.Path + 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)) diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index be4aa7bd..faf9f19a 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -38,10 +38,18 @@ import ( "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/stretchr/testify/assert" "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 +} + func TestSyncProgressPercentTracksCompletedItems(t *testing.T) { if got := syncProgressPercent(0, 2); got != 0.5 { t.Fatalf("first completed item progress = %v, want 0.5", got) @@ -61,8 +69,10 @@ func TestFreshRegistryInstallItemDefaultsToTarGZWithoutHostRequirements(t *testi items, err := model.createInstallList(DriversList{Drivers: map[string]driverSpec{"test-driver-1": {}}}) require.NoError(t, err) require.Len(t, items, 1) - assert.Equal(t, "tar.gz", items[0].ArtifactFormat) - assert.Empty(t, items[0].HostRequirements) + selected, err := items[0].selectedArtifact() + require.NoError(t, err) + assert.Equal(t, "tar.gz", selected.Format) + assert.Empty(t, selected.HostRequirements) } func TestSyncRejectsNonRegistrySourcesBeforeRegistryLookup(t *testing.T) { @@ -150,9 +160,7 @@ func TestRegistrySourceChangeDiscardsOldLockEntryBeforeFallback(t *testing.T) { 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") - resolvedSource, err := packageLockSource(items[0]) - require.NoError(t, err) - assert.Equal(t, registryBURL.String(), resolvedSource.URL) + assert.Equal(t, registryBURL.String(), items[0].Release.Source.Reference) } func registryScopedTestDriver(t *testing.T, driverID, registryURL string) dbc.Driver { @@ -190,12 +198,8 @@ func TestCreateInstallListSelectsDriverFromDeclaredRegistry(t *testing.T) { }}) require.NoError(t, err) require.Len(t, items, 1) - assert.Equal(t, "https://registry-b.example.test", items[0].Driver.Registry.BaseURL.String()) - - resolvedSource, err := packageLockSource(items[0]) - require.NoError(t, err) - assert.Equal(t, "https://registry-b.example.test", resolvedSource.URL) - expected, _, err := expectedRegistryPackageMetadata(items[0].Package) + 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) } @@ -244,7 +248,7 @@ func TestCreateInstallListPreservesDefaultRegistryPrecedence(t *testing.T) { }}) require.NoError(t, err) require.Len(t, items, 1) - assert.Equal(t, "https://registry-a.example.test", items[0].Driver.Registry.BaseURL.String()) + assert.Equal(t, "https://registry-a.example.test", items[0].Release.Source.Reference) } func TestPackslipLockVersionRequiresExactBuildMetadata(t *testing.T) { @@ -396,10 +400,8 @@ func TestExplicitRegistryURLNormalizationAllowsOfflineLockedReplay(t *testing.T) require.Len(t, items, 1) require.NotNil(t, items[0].LockEntry) assert.Nil(t, model.driverIndex, "locked replay must not need registry discovery") - resolvedSource, err := packageLockSource(items[0]) - require.NoError(t, err) - assert.Equal(t, "https://registry.example.test", resolvedSource.URL) - expected, _, err := expectedRegistryPackageMetadata(items[0].Package) + 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) } @@ -906,7 +908,7 @@ func (suite *SubcommandTestSuite) TestSyncInstallFailureKeepsCompleteCandidateLo 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 downloadedPaths []string + var preparedPaths []string var downloadedDrivers []string model := SyncCmd{Path: path, NoVerify: true}.GetModelCustom(baseModel{ getDriverRegistry: getTestDriverRegistry, @@ -921,21 +923,24 @@ func (suite *SubcommandTestSuite) TestSyncInstallFailureKeepsCompleteCandidateLo return nil, fmt.Errorf("unexpected driver %q", pkg.Driver.Path) } copyPath := suite.copyArchiveForSyncTest(source) - downloadedPaths = append(downloadedPaths, copyPath) downloadedDrivers = append(downloadedDrivers, pkg.Driver.Path) return os.Open(copyPath) }, }).(syncModel) + model.worker.hooks.duringPrepare = func(_ context.Context, _ int, item installItem) error { + preparedPaths = append(preparedPaths, item.Archive.File.Name()) + return nil + } model.writeCandidateLock = func(path string, lock LockFile) error { if err := writeLockFileAtomic(path, lock); err != nil { return err } - if len(downloadedPaths) != 2 { - return fmt.Errorf("expected two prepared archives, got %d", len(downloadedPaths)) + if len(preparedPaths) != 2 { + return fmt.Errorf("expected two prepared archive snapshots, got %d", len(preparedPaths)) } // Corrupt the final item only after the complete candidate is durable, // forcing execution to fail after one install while preserving the lock. - return os.WriteFile(downloadedPaths[len(downloadedPaths)-1], []byte("broken after prepare"), 0600) + return os.WriteFile(preparedPaths[len(preparedPaths)-1], []byte("broken after prepare"), 0600) } suite.runCmdErr(model) @@ -1434,7 +1439,7 @@ func (suite *SubcommandTestSuite) TestSyncPartialRegistryDownloadsEachArchiveOnc return os.Open(v2Path) }, }) - suite.Contains(suite.runCmdErr(v2Model), "registry package v2 requires archive hash and size metadata") + suite.Contains(suite.runCmdErr(v2Model), "package v2 requires archive hash and size metadata") suite.Equal(1, v2Downloads) } @@ -1757,10 +1762,13 @@ func (suite *SubcommandTestSuite) TestSyncManifestOnlyInstallFailureConvergesFro if err != nil { return nil, err } - preparedArchivePath = archive.Name() return archive, nil }, }).(syncModel) + first.worker.hooks.duringPrepare = func(_ context.Context, _ int, item installItem) error { + preparedArchivePath = item.Archive.File.Name() + return nil + } first.worker.hooks.beforeCandidateSave = func(context.Context) error { return os.WriteFile(preparedArchivePath, []byte("corrupted after validation"), 0o600) } @@ -2159,7 +2167,7 @@ func (suite *SubcommandTestSuite) TestSyncCancellationWaitsForWorkerCleanup() { var releaseOnce sync.Once unblock := func() { releaseOnce.Do(func() { close(release) }) } defer unblock() - var archive *os.File + var downloadedArchive, preparedArchive *os.File model := SyncCmd{Path: path, NoVerify: true, Json: stage != "prepare", JsonStreamProgress: stage == "prepare"}.GetModelCustom(baseModel{ getDriverRegistry: func() ([]dbc.Driver, error) { if stage == "registry" { @@ -2169,14 +2177,18 @@ func (suite *SubcommandTestSuite) TestSyncCancellationWaitsForWorkerCleanup() { return getTestDriverRegistry() }, downloadPkg: func(pkg dbc.PkgInfo) (*os.File, error) { - archive, err = downloadTestPkg(pkg) + downloadedArchive, err = downloadTestPkg(pkg) if stage == "download" { close(entered) <-release } - return archive, err + return downloadedArchive, err }, }).(syncModel) + model.worker.hooks.duringPrepare = func(_ context.Context, _ int, item installItem) error { + preparedArchive = item.Archive.File + return nil + } block := func(context.Context) error { close(entered) <-release @@ -2185,7 +2197,7 @@ func (suite *SubcommandTestSuite) TestSyncCancellationWaitsForWorkerCleanup() { switch stage { case "prepare": model.worker.hooks.duringPrepare = func(ctx context.Context, _ int, item installItem) error { - archive = item.Archive + preparedArchive = item.Archive.File return block(ctx) } case "candidate-save": @@ -2198,7 +2210,7 @@ func (suite *SubcommandTestSuite) TestSyncCancellationWaitsForWorkerCleanup() { if err != nil { return nil, err } - archive = file + preparedArchive = file close(entered) <-release return nil, ctx.Err() @@ -2222,11 +2234,15 @@ func (suite *SubcommandTestSuite) TestSyncCancellationWaitsForWorkerCleanup() { default: } var statErr error - if archive != nil { - _, statErr = archive.Stat() - suite.NoError(statErr, "prepared archive must stay open until the worker exits") - } else { - suite.Equal("registry", stage, "only registry discovery runs before an archive is opened") + switch stage { + case "registry": + suite.Nil(downloadedArchive) + case "download": + _, statErr = downloadedArchive.Stat() + suite.NoError(statErr, "source response stays open until the interrupted snapshot completes") + default: + _, statErr = preparedArchive.Stat() + suite.NoError(statErr, "prepared snapshot stays open until the worker exits") } lockCtx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) _, lockErr := fslock.AcquireContext(lockCtx, projectLockPath) @@ -2260,8 +2276,12 @@ func (suite *SubcommandTestSuite) TestSyncCancellationWaitsForWorkerCleanup() { } suite.Equal(1, errorEnvelopes, "cancel should emit one terminal JSON error envelope") waitSyncTestSignal(suite.T(), model.worker.done) - if archive != nil { - _, statErr = archive.Stat() + if preparedArchive != nil { + _, statErr = preparedArchive.Stat() + suite.ErrorIs(statErr, os.ErrClosed) + } + if stage == "download" { + _, statErr = downloadedArchive.Stat() suite.ErrorIs(statErr, os.ErrClosed) } projectLock, lockErr := fslock.Acquire(projectLockPath, time.Second) @@ -2550,14 +2570,14 @@ func (suite *SubcommandTestSuite) TestSyncTerminalErrorsUseSingleOutputContract( _, wantErr = checksum(missingPath) info := config.DriverInfo{ID: "example", Version: semver.MustParse("1.0.0")} info.Driver.Shared.Set(config.PlatformTuple(), missingPath) - message = installedDrvMsg{info: info} + message = installedDrvMsg{info: info, item: installItem{Platform: config.PlatformTuple()}} code = "checksum_failed" case "checksum-mismatch": libraryPath := filepath.Join(suite.T().TempDir(), "driver.so") suite.Require().NoError(os.WriteFile(libraryPath, []byte("installed library"), 0600)) info := config.DriverInfo{ID: "example", Version: semver.MustParse("1.0.0")} info.Driver.Shared.Set(config.PlatformTuple(), libraryPath) - item := installItem{InstalledLibraryHash: strings.Repeat("0", 64)} + item := installItem{Platform: config.PlatformTuple(), InstalledLibraryHash: strings.Repeat("0", 64)} message = installedDrvMsg{info: info, item: item} wantErr = errors.New("installed library checksum does not match validated package") code = "checksum_failed" From cbbde4d6a6195a4695fe51bd671a55ccee9b5ce5 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 08:51:05 +0000 Subject: [PATCH 078/115] refactor: route registry sync through source plans --- cmd/dbc/sync.go | 427 ++++++++++------ cmd/dbc/sync_test.go | 477 +++++++++++++----- internal/sourceresolution/requirement.go | 13 +- internal/sourceresolution/requirement_test.go | 4 +- 4 files changed, 638 insertions(+), 283 deletions(-) diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index dfb1dab7..eec804fb 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -349,6 +349,14 @@ type installItem struct { AlreadyInstalled *config.DriverInfo } +type plannedSyncItem struct { + Name string + 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") @@ -400,222 +408,343 @@ type preparedSyncMsg struct { type candidateLockSavedMsg struct{ lock LockFile } -func (s syncModel) createInstallList(list DriversList) ([]installItem, error) { - // Load the lock file if it exists +func (s syncModel) planSyncItems(list DriversList) ([]plannedSyncItem, bool, error) { lf, err := loadLockFile(s.LockFilePath) if err != nil && !errors.Is(err, fs.ErrNotExist) { - return nil, err + return nil, false, err } - // construct our list of driver+version to install - var items []installItem + planned := make([]plannedSyncItem, 0, len(list.Drivers)) + needsRegistry := false for name, spec := range list.Drivers { - if err := requireRegistrySyncSource(name, spec); err != nil { - return nil, err - } - var info lockInfo - if lf.lockinfo != nil { - info = lf.lockinfo[name] - } - if lf.Version == lockFileVersion && info.Version != nil && !driverSourceMatchesLock(spec.Source, info.Source) { - // A lock entry for another source must not influence fallback - // resolution or be merged into the new source's candidate snapshot. - info = lockInfo{} - } - if lf.Version == lockFileVersion && info.Version != nil { - if info.Source.Type != "registry" { - return nil, fmt.Errorf("locked source type %q for driver %q is not supported by sync yet; source integration will follow", info.Source.Type, name) + 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 = © } - if lockVersionSatisfiesSpec(info, spec) { - artifact, err := selectLockedArtifact(info, config.PlatformTuple(), false) - if err == nil { - item, err := installItemFromLockedArtifact(name, info, artifact) - if err != nil { - return nil, err - } - items = append(items, item) - continue - } - var refreshErr *LockRefreshRequiredError - if !errors.As(err, &refreshErr) { - return nil, err - } + } 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 = © } } - // Locate the driver in its declared registry when the project pins one. - // Legacy/default registry entries retain the existing registry ordering. - drv, err := findDriverInDeclaredRegistry(name, s.driverIndex, spec.Source) - if err != nil { - return nil, wrapWithRegistryContext(err, s.registryErrors) - } + switch plan.Outcome() { + case sourceresolution.PlanReplay: + case sourceresolution.PlanResolve, sourceresolution.PlanRefreshRequired: + 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, Requirement: requirement, Plan: plan, + LockEntry: existing, LegacyLock: legacy, + }) + } + return planned, needsRegistry, nil +} - 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") - } +func requirementForDriverSpec(name string, spec driverSpec) (sourceresolution.Requirement, error) { + var selection sourceresolution.SourceSelection + if spec.Source == nil { + selection = sourceresolution.DefaultRegistrySelection() + } else { + if spec.Source.Type != dbc.DriverSourceRegistry { + return sourceresolution.Requirement{}, fmt.Errorf("source type %q for driver %q is not supported by sync yet; source integration will follow", spec.Source.Type, name) } - + key, err := driverSourceIdentity(spec.Source) if err != nil { - return nil, err - } - var priorLock *lockInfo - if info.Version != nil { - priorLock = &info + return sourceresolution.Requirement{}, fmt.Errorf("driver %q has invalid declared registry source: %w", name, err) } - item, err := installItemFromRegistryPackage(drv, pkg, priorLock) + selection, err = sourceresolution.ExplicitSourceSelection(key) if err != nil { - return nil, err + return sourceresolution.Requirement{}, fmt.Errorf("driver %q has invalid declared registry source: %w", name, err) } - items = append(items, item) } - return items, nil + + 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) + 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) registryDiscoveryNeeded(list DriversList) (bool, error) { - for name, spec := range list.Drivers { - if err := requireRegistrySyncSource(name, spec); err != nil { - return false, err +func (s syncModel) createInstallList(planned []plannedSyncItem) ([]installItem, error) { + items := make([]installItem, 0, len(planned)) + 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: + item, err := s.resolveRegistryPlan(entry) + 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()) } } - lf, err := loadLockFile(s.LockFilePath) - if errors.Is(err, fs.ErrNotExist) { - return true, nil + return items, 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 false, err + return installItem{}, wrapWithRegistryContext(err, s.registryErrors) } - for name, spec := range list.Drivers { - entry, ok := lf.lockinfo[name] - if lf.Version != lockFileVersion || !ok || entry.Version == nil { - return true, nil - } - if !driverSourceMatchesLock(spec.Source, entry.Source) { - return true, nil + + 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) } - if !lockVersionSatisfiesSpec(entry, spec) { - return true, nil + 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 := resolvedReleaseFromRegistryPackage(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 _, err := selectLockedArtifact(entry, config.PlatformTuple(), false); err != nil { - var refreshErr *LockRefreshRequiredError - if errors.As(err, &refreshErr) { - return true, nil + if expectedRefreshSource == nil || resolvedSource != *expectedRefreshSource { + expected := "unknown" + if expectedRefreshSource != nil { + expected = expectedRefreshSource.Reference } - return false, err + 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) } } - return false, nil -} - -func requireRegistrySyncSource(name string, spec driverSpec) error { - if spec.Source == nil || spec.Source.Type == dbc.DriverSourceRegistry { - return nil + item, err := installItemFromResolverResult(planned.Requirement, release, priorLock) + if err != nil { + return installItem{}, err } - return fmt.Errorf("source type %q for driver %q is not supported by sync yet; source integration will follow", spec.Source.Type, name) + 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 findDriverInDeclaredRegistry(name string, drivers []dbc.Driver, source *dbc.DriverSource) (dbc.Driver, error) { - if source == nil || source.Type != dbc.DriverSourceRegistry { - return findDriver(name, drivers) +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) } - - declaredKey, err := sourceidentity.Parse(sourceidentity.Registry, source.URL) - if err != nil { - return dbc.Driver{}, fmt.Errorf("driver %q has invalid declared registry source: %w", name, err) + 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 } - key, err := sourceidentity.Parse(sourceidentity.Registry, driver.Registry.BaseURL.String()) - if err == nil && key == declaredKey { + candidate, err := sourceidentity.Parse(sourceidentity.Registry, driver.Registry.BaseURL.String()) + if err == nil && candidate == key { matches = append(matches, driver) } } - driver, err := findDriver(name, matches) + driver, err := findDriver(requirement.DriverID(), matches) if err != nil { - return dbc.Driver{}, fmt.Errorf("driver %q was not found in declared registry %q", name, source.URL) + 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 installItemFromRegistryPackage(driver dbc.Driver, pkg dbc.PkgInfo, priorLock *lockInfo) (installItem, error) { +func resolvedReleaseFromRegistryPackage(driver dbc.Driver, pkg dbc.PkgInfo) (resolution.ResolvedRelease, error) { if driver.Path == "" || pkg.Version == nil || strings.TrimSpace(pkg.PlatformTuple) == "" { - return installItem{}, errors.New("registry package metadata is incomplete") + return resolution.ResolvedRelease{}, errors.New("registry package metadata is incomplete") } if driver.Registry == nil || driver.Registry.BaseURL == nil { - return installItem{}, fmt.Errorf("driver %q has no registry identity", driver.Path) + 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 installItem{}, fmt.Errorf("registry package metadata for %q has no archive URL", driver.Path) + 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 installItem{}, fmt.Errorf("invalid registry package platform %q: %w", pkg.PlatformTuple, err) + return resolution.ResolvedRelease{}, fmt.Errorf("invalid registry package platform %q: %w", pkg.PlatformTuple, err) } size := cloneInt64(pkg.ArtifactSize) release := resolution.ResolvedRelease{ DriverID: driver.Path, Version: pkg.Version.String(), - Source: resolution.SourceSpec{Type: "registry", Reference: driver.Registry.BaseURL.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, Size: size, }}, } if err := resolution.ValidateResolvedReleaseCandidate(release); err != nil { - return installItem{}, fmt.Errorf("invalid registry package metadata: %w", err) + return resolution.ResolvedRelease{}, fmt.Errorf("invalid registry package metadata: %w", err) } - return newInstallItem(release, 0, pkg.PlatformTuple, priorLock) + return release, nil } -func lockVersionSatisfiesSpec(entry lockInfo, spec driverSpec) bool { - if !driverSourceMatchesLock(spec.Source, entry.Source) { - return false - } - if entry.Version == nil { - return false +func installItemFromResolverResult(requirement sourceresolution.Requirement, release resolution.ResolvedRelease, priorLock *lockInfo) (installItem, error) { + selected, err := requirement.ValidateResolverResult(release) + if err != nil { + return installItem{}, fmt.Errorf("registry resolver returned an invalid release: %w", err) } - if spec.Version != nil { - if spec.Source != nil && spec.Source.Type == dbc.DriverSourcePackslip { - // This is a requirement check: Packslip's validated exact request is - // represented by a constraint, but lock reuse must preserve its full - // canonical version string, including build metadata. - return entry.Version.String() == spec.Version.String() + for i := range release.Artifacts { + if release.Artifacts[i].Target == selected.Target { + return newInstallItem(release, i, config.PlatformTuple(), priorLock) } - // An explicit constraint can name a prerelease directly. Let semver's - // constraint evaluation decide whether that locked version is allowed. - return spec.Version.Check(entry.Version) - } - return entry.Version.Prerelease() == "" || spec.Prerelease == "allow" -} - -func driverSourceMatchesLock(source *dbc.DriverSource, locked lockSource) bool { - if source == nil { - // An omitted source selects the configured default-registry policy. It - // does not declare a source identity key or pin an undeclared registry. - return locked.Type == string(dbc.DriverSourceRegistry) - } - declaredKey, err := driverSourceIdentity(source) - if err != nil { - return false } - lockedKey, err := lockSourceIdentity(locked) - return err == nil && declaredKey == lockedKey + 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) { @@ -1317,7 +1446,7 @@ func (s syncModel) runSyncWorker(worker *syncWorker) syncWorkerResultMsg { return fail("sync_failed", err) } - needsRegistry, err := s.registryDiscoveryNeeded(s.list) + planned, needsRegistry, err := s.planSyncItems(s.list) if err != nil { return fail("sync_failed", fmt.Errorf("failed to inspect lock file: %w", err)) } @@ -1335,7 +1464,7 @@ func (s syncModel) runSyncWorker(worker *syncWorker) syncWorkerResultMsg { s.driverIndex = drivers } - items, err := s.createInstallList(s.list) + items, err := s.createInstallList(planned) if err != nil { return fail("sync_failed", err) } @@ -1543,13 +1672,13 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err := applyProjectRegistries(s.list); err != nil { return s, errCmd("%v", err) } - needsRegistry, err := s.registryDiscoveryNeeded(s.list) + planned, needsRegistry, err := s.planSyncItems(s.list) if err != nil { return s, errCmd("failed to inspect lock file: %w", err) } if !needsRegistry { return s, func() tea.Msg { - returnItems, err := s.createInstallList(s.list) + returnItems, err := s.createInstallList(planned) if err != nil { return err } diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index faf9f19a..41b84ea9 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -39,6 +39,7 @@ import ( "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/sourceresolution" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -66,7 +67,10 @@ func TestFreshRegistryInstallItemDefaultsToTarGZWithoutHostRequirements(t *testi LockFilePath: filepath.Join(t.TempDir(), "dbc.lock"), driverIndex: drivers, } - items, err := model.createInstallList(DriversList{Drivers: map[string]driverSpec{"test-driver-1": {}}}) + 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() @@ -132,6 +136,12 @@ func TestRegistrySourceChangeDiscardsOldLockEntryBeforeFallback(t *testing.T) { 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") @@ -152,17 +162,338 @@ func TestRegistrySourceChangeDiscardsOldLockEntryBeforeFallback(t *testing.T) { }, }} model := syncModel{LockFilePath: lockPath, driverIndex: drivers} - needsRegistry, err := model.registryDiscoveryNeeded(list) + 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") - items, err := model.createInstallList(list) + 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 TestRegistryResolverResultsAreValidatedBeforeInstallItemCreation(t *testing.T) { + constraint, err := semver.NewConstraint("=1.1.0") + require.NoError(t, err) + requirement, err := requirementForDriverSpec("test-driver-1", driverSpec{ + Version: constraint, + Source: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: testRegistry.BaseURL.String()}, + }) + require.NoError(t, err) + base := resolution.ResolvedRelease{ + DriverID: "test-driver-1", Version: "1.1.0", + Source: resolution.SourceSpec{Type: "registry", Reference: testRegistry.BaseURL.String()}, + Artifacts: []resolution.Artifact{{ + Target: testTarget(config.PlatformTuple()), Format: "tar.gz", + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://assets.example.test/driver.tar.gz"}, + }}, + } + for _, test := range []struct { + name string + change func(*resolution.ResolvedRelease) + want string + }{ + {name: "driver", change: func(release *resolution.ResolvedRelease) { release.DriverID = "other" }, want: "driver ID"}, + {name: "source", change: func(release *resolution.ResolvedRelease) { release.Source.Reference = "https://other.example.test" }, want: "source"}, + {name: "version", change: func(release *resolution.ResolvedRelease) { release.Version = "9.9.9" }, want: "version"}, + } { + t.Run(test.name, func(t *testing.T) { + release := cloneResolvedReleaseForSync(base) + test.change(&release) + item, err := installItemFromResolverResult(requirement, release, nil) + require.ErrorContains(t, err, test.want) + assert.Empty(t, item.Release.DriverID, "invalid resolver output cannot create an install candidate") + }) + } +} + +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 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() @@ -193,10 +524,13 @@ func TestCreateInstallListSelectsDriverFromDeclaredRegistry(t *testing.T) { Type: dbc.DriverSourceRegistry, URL: "HTTPS://REGISTRY-B.EXAMPLE.TEST/", } - items, err := model.createInstallList(DriversList{Drivers: map[string]driverSpec{ + 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]) @@ -226,9 +560,12 @@ func TestCreateInstallListDoesNotFallbackOutsideDeclaredRegistry(t *testing.T) { registryBWithoutDriver, }, } - items, err := model.createInstallList(DriversList{Drivers: map[string]driverSpec{ + 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) @@ -243,135 +580,17 @@ func TestCreateInstallListPreservesDefaultRegistryPrecedence(t *testing.T) { LockFilePath: filepath.Join(t.TempDir(), "dbc.lock"), driverIndex: []dbc.Driver{registryA, registryB}, } - items, err := model.createInstallList(DriversList{Drivers: map[string]driverSpec{ + 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 TestPackslipLockVersionRequiresExactBuildMetadata(t *testing.T) { - requested, err := semver.NewConstraint("1.2.3+foo") - require.NoError(t, err) - entry := lockInfo{ - Version: semver.MustParse("1.2.3+bar"), - Source: lockSource{Type: "packslip", Project: "github.com/example/driver"}, - } - packslipSpec := driverSpec{ - Version: requested, - Source: &dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "github.com/example/driver"}, - } - assert.False(t, lockVersionSatisfiesSpec(entry, packslipSpec), "Packslip lock reuse must compare the complete exact version string") - entry.Version = semver.MustParse("1.2.3+foo") - assert.True(t, lockVersionSatisfiesSpec(entry, packslipSpec)) - - registryConstraint, err := semver.NewConstraint(">=1.2.3") - require.NoError(t, err) - registryEntry := lockInfo{ - Version: semver.MustParse("1.2.3+bar"), - Source: lockSource{Type: "registry", URL: "https://registry.example.test"}, - } - assert.True(t, lockVersionSatisfiesSpec(registryEntry, driverSpec{Version: registryConstraint}), - "registry constraints retain existing SemVer precedence behavior") -} - -func TestLockVersionSatisfiesSpecRequiresMatchingSource(t *testing.T) { - version := semver.MustParse("1.2.3") - packslipConstraint, err := semver.NewConstraint("1.2.3") - require.NoError(t, err) - packslipSpec := driverSpec{ - Version: packslipConstraint, - Source: &dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "github.com/Example/Driver"}, - } - assert.False(t, lockVersionSatisfiesSpec(lockInfo{ - Version: version, - Source: lockSource{Type: "registry", URL: "https://registry.example.test"}, - }, packslipSpec), "a same-version registry lock cannot satisfy a Packslip declaration") - assert.True(t, lockVersionSatisfiesSpec(lockInfo{ - Version: version, - Source: lockSource{Type: "packslip", Project: "github.com/example/driver"}, - }, packslipSpec), "GitHub host, owner, and repo are canonicalized consistently") - packslipToolSpec := driverSpec{ - Version: packslipConstraint, - Source: &dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "github.com/EXAMPLE/DRIVER/Tools/Tool"}, - } - assert.False(t, lockVersionSatisfiesSpec(lockInfo{ - Version: version, - Source: lockSource{Type: "packslip", Project: "github.com/example/driver/TOOLS/Tool"}, - }, packslipToolSpec), "Packslip monorepo subpaths remain case-sensitive") - - registryConstraint, err := semver.NewConstraint("1.2.3") - require.NoError(t, err) - defaultRegistrySpec := driverSpec{Version: registryConstraint} - assert.True(t, lockVersionSatisfiesSpec(lockInfo{ - Version: version, - Source: lockSource{Type: "registry", URL: "https://custom.example.test"}, - }, defaultRegistrySpec), "legacy/default registry declarations do not pin an undeclared URL") - assert.False(t, lockVersionSatisfiesSpec(lockInfo{ - Version: version, - Source: lockSource{Type: "packslip", Project: "github.com/example/driver"}, - }, defaultRegistrySpec)) - - explicitRegistry := &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "https://registry.example.test"} - assert.True(t, lockVersionSatisfiesSpec(lockInfo{ - Version: version, - Source: lockSource{Type: "registry", URL: "https://registry.example.test/"}, - }, driverSpec{Version: registryConstraint, Source: explicitRegistry})) - caseInsensitiveRegistry := &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "HTTPS://REGISTRY.EXAMPLE.TEST/"} - assert.True(t, lockVersionSatisfiesSpec(lockInfo{ - Version: version, - Source: lockSource{Type: "registry", URL: explicitRegistry.URL}, - }, driverSpec{Version: registryConstraint, Source: caseInsensitiveRegistry}), - "scheme/host case and trailing slash use registry URL normalization") - assert.False(t, lockVersionSatisfiesSpec(lockInfo{ - Version: version, - Source: lockSource{Type: "registry", URL: "https://other.example.test"}, - }, driverSpec{Version: registryConstraint, Source: explicitRegistry})) - for _, differentURL := range []string{ - "https://registry.example.test/tenant", - "https://registry.example.test?tenant=b", - } { - assert.False(t, lockVersionSatisfiesSpec(lockInfo{ - Version: version, - Source: lockSource{Type: "registry", URL: differentURL}, - }, driverSpec{Version: registryConstraint, Source: explicitRegistry}), - "registry path/query differences remain part of source identity: %s", differentURL) - } - for _, differentURL := range []string{ - "https://registry.example.test?", - "https://registry.example.test:443", - "https://registry.example.test/a%2Fb", - "https://u:p@registry.example.test", - } { - assert.False(t, lockVersionSatisfiesSpec(lockInfo{ - Version: version, - Source: lockSource{Type: "registry", URL: differentURL}, - }, driverSpec{Version: registryConstraint, Source: explicitRegistry}), - "registry source identity keeps ForceQuery, escaped separators, userinfo, and explicit ports: %s", differentURL) - } - assert.True(t, lockVersionSatisfiesSpec(lockInfo{ - Version: version, - Source: lockSource{Type: "registry", URL: "https://registry.example.test#fragment"}, - }, driverSpec{Version: registryConstraint, Source: explicitRegistry}), - "fragments do not change the HTTP registry identity") - - pathSource := &dbc.DriverSource{Type: dbc.DriverSourcePath, Path: "./packages/driver.tgz"} - assert.True(t, lockVersionSatisfiesSpec(lockInfo{ - Version: version, - Source: lockSource{Type: "path", Path: pathSource.Path}, - }, driverSpec{Version: registryConstraint, Source: pathSource})) - assert.False(t, lockVersionSatisfiesSpec(lockInfo{ - Version: version, - Source: lockSource{Type: "path", Path: "./packages/other.tgz"}, - }, driverSpec{Version: registryConstraint, Source: pathSource})) - assert.False(t, lockVersionSatisfiesSpec(lockInfo{ - Version: version, - Source: lockSource{Type: "path", Path: "./packages/../driver.tgz"}, - }, driverSpec{Version: registryConstraint, Source: &dbc.DriverSource{Type: dbc.DriverSourcePath, Path: "./driver.tgz"}}), - "path source identity does not resolve filesystem path aliases") -} - func TestExplicitRegistryURLNormalizationAllowsOfflineLockedReplay(t *testing.T) { lockPath := filepath.Join(t.TempDir(), "dbc.lock") entry := testRegistryLockEntryForPlatform(config.PlatformTuple()) @@ -391,11 +610,11 @@ func TestExplicitRegistryURLNormalizationAllowsOfflineLockedReplay(t *testing.T) }, }} model := syncModel{LockFilePath: lockPath} - needsRegistry, err := model.registryDiscoveryNeeded(list) + 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(list) + items, err := model.createInstallList(planned) require.NoError(t, err) require.Len(t, items, 1) require.NotNil(t, items[0].LockEntry) diff --git a/internal/sourceresolution/requirement.go b/internal/sourceresolution/requirement.go index 6e51ebfe..58c68c70 100644 --- a/internal/sourceresolution/requirement.go +++ b/internal/sourceresolution/requirement.go @@ -305,6 +305,13 @@ func (requirement Requirement) Version() VersionRequirement { return requirement // 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 @@ -396,10 +403,10 @@ func (version VersionRequirement) matches(value string) bool { } switch version.mode { case RegistryConstraint: - if parsed.Prerelease() != "" && version.prerelease == PrereleaseForbidden { - return false - } 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 diff --git a/internal/sourceresolution/requirement_test.go b/internal/sourceresolution/requirement_test.go index 8f5aa7e5..9bd128ef 100644 --- a/internal/sourceresolution/requirement_test.go +++ b/internal/sourceresolution/requirement_test.go @@ -90,14 +90,14 @@ func TestRegistryVersionRequirementAppliesConstraintAndPrereleasePolicy(t *testi require.Error(t, err) } -func TestRegistryPrereleasePolicyOverridesPrereleaseComparators(t *testing.T) { +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: "forbidden", policy: PrereleaseForbidden, wantMatch: false}, + {name: "default policy", policy: PrereleaseForbidden, wantMatch: true}, {name: "allowed", policy: PrereleaseAllowed, wantMatch: true}, } { t.Run(test.name, func(t *testing.T) { From 0e594bfd6156cdc0caaa7c27ab7f2e7ece25a69d Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 09:23:08 +0000 Subject: [PATCH 079/115] feat: integrate Packslip and path sync sources --- cmd/dbc/driver_list.go | 17 +- cmd/dbc/driver_list_test.go | 42 ++ cmd/dbc/helpers.go | 4 + cmd/dbc/main.go | 8 +- cmd/dbc/sync.go | 181 ++++++-- cmd/dbc/sync_test.go | 482 +++++++++++++++++++-- internal/sourceresolution/resolver.go | 11 +- internal/sourceresolution/resolver_test.go | 1 + 8 files changed, 663 insertions(+), 83 deletions(-) diff --git a/cmd/dbc/driver_list.go b/cmd/dbc/driver_list.go index 9dada1f0..77b7bdbb 100644 --- a/cmd/dbc/driver_list.go +++ b/cmd/dbc/driver_list.go @@ -44,13 +44,20 @@ func (m DriversList) validateSources() error { if err := spec.Source.Validate(); err != nil { return fmt.Errorf("driver %q source: %w", id, err) } - if spec.Source.Type == dbc.DriverSourcePackslip { - if spec.Version == nil { + 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) } - versionText := spec.Version.String() - if _, err := semver.StrictNewVersion(versionText); err != nil { - return fmt.Errorf("driver %q packslip source requires an exact SemVer 2.0.0 version, got %q", id, versionText) + 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) + } } } } diff --git a/cmd/dbc/driver_list_test.go b/cmd/dbc/driver_list_test.go index 26f7c496..09e50413 100644 --- a/cmd/dbc/driver_list_test.go +++ b/cmd/dbc/driver_list_test.go @@ -209,6 +209,48 @@ func TestPackslipSourceRequiresExactStrictSemVer(t *testing.T) { } } +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) + } + }) + } +} + func must[T any](v T, err error) T { if err != nil { panic(err) diff --git a/cmd/dbc/helpers.go b/cmd/dbc/helpers.go index 1330e25c..eb8f564c 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" ) @@ -62,6 +63,9 @@ func defaultBaseModel() baseModel { getDriverRegistry: getDriverRegistry, downloadPkg: downloadPkg, downloadArtifact: downloadPackage, + newPackslipResolver: func() (packslip.Resolver, error) { + return packslip.NewResolver(packslip.Config{}) + }, } } diff --git a/cmd/dbc/main.go b/cmd/dbc/main.go index fe24aa48..2c36321a 100644 --- a/cmd/dbc/main.go +++ b/cmd/dbc/main.go @@ -33,6 +33,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" ) @@ -224,9 +225,10 @@ func getConfig(c config.ConfigLevel) config.Config { } type baseModel struct { - getDriverRegistry func() ([]dbc.Driver, error) - downloadPkg func(p dbc.PkgInfo) (*os.File, error) - downloadArtifact func(context.Context, dbc.PkgInfo) (io.ReadCloser, error) + getDriverRegistry func() ([]dbc.Driver, error) + downloadPkg func(p dbc.PkgInfo) (*os.File, error) + downloadArtifact func(context.Context, dbc.PkgInfo) (io.ReadCloser, error) + newPackslipResolver func() (packslip.Resolver, error) status int err error diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index eec804fb..6ddef865 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -38,6 +38,7 @@ import ( "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/packslip" "github.com/columnar-tech/dbc/internal/resolution" "github.com/columnar-tech/dbc/internal/sourceidentity" "github.com/columnar-tech/dbc/internal/sourceresolution" @@ -351,6 +352,7 @@ type installItem struct { type plannedSyncItem struct { Name string + Spec driverSpec Requirement sourceresolution.Requirement Plan sourceresolution.Plan LockEntry *lockInfo @@ -449,7 +451,11 @@ func (s syncModel) planSyncItems(list DriversList) ([]plannedSyncItem, bool, err switch plan.Outcome() { case sourceresolution.PlanReplay: case sourceresolution.PlanResolve, sourceresolution.PlanRefreshRequired: - needsRegistry = true + 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: @@ -458,7 +464,7 @@ func (s syncModel) planSyncItems(list DriversList) ([]plannedSyncItem, bool, err return nil, false, fmt.Errorf("cannot plan sync for driver %q: invalid plan outcome %d", name, plan.Outcome()) } planned = append(planned, plannedSyncItem{ - Name: name, Requirement: requirement, Plan: plan, + Name: name, Spec: spec, Requirement: requirement, Plan: plan, LockEntry: existing, LegacyLock: legacy, }) } @@ -467,31 +473,54 @@ func (s syncModel) planSyncItems(list DriversList) ([]plannedSyncItem, bool, err func requirementForDriverSpec(name string, spec driverSpec) (sourceresolution.Requirement, error) { var selection sourceresolution.SourceSelection + sourceKind := sourceidentity.Registry if spec.Source == nil { selection = sourceresolution.DefaultRegistrySelection() } else { - if spec.Source.Type != dbc.DriverSourceRegistry { - return sourceresolution.Requirement{}, fmt.Errorf("source type %q for driver %q is not supported by sync yet; source integration will follow", spec.Source.Type, name) - } key, err := driverSourceIdentity(spec.Source) if err != nil { - return sourceresolution.Requirement{}, fmt.Errorf("driver %q has invalid declared registry source: %w", name, err) + 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 registry source: %w", name, err) + return sourceresolution.Requirement{}, fmt.Errorf("driver %q has invalid declared source: %w", name, err) } + sourceKind = key.Kind } - 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 + 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 { + 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) } - version, err := sourceresolution.RegistryVersionRequirement(constraint, policy) if err != nil { return sourceresolution.Requirement{}, fmt.Errorf("driver %q has invalid version requirement: %w", name, err) } @@ -503,7 +532,12 @@ func requirementForDriverSpec(name string, spec driverSpec) (sourceresolution.Re } 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: @@ -513,7 +547,33 @@ func (s syncModel) createInstallList(planned []plannedSyncItem) ([]installItem, } items = append(items, item) case sourceresolution.PlanResolve, sourceresolution.PlanRefreshRequired: - item, err := s.resolveRegistryPlan(entry) + 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 } @@ -529,6 +589,80 @@ func (s syncModel) createInstallList(planned []plannedSyncItem) ([]installItem, 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 + 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) + } + 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 + } + 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 { @@ -737,7 +871,7 @@ func resolvedReleaseFromRegistryPackage(driver dbc.Driver, pkg dbc.PkgInfo) (res func installItemFromResolverResult(requirement sourceresolution.Requirement, release resolution.ResolvedRelease, priorLock *lockInfo) (installItem, error) { selected, err := requirement.ValidateResolverResult(release) if err != nil { - return installItem{}, fmt.Errorf("registry resolver returned an invalid release: %w", err) + return installItem{}, fmt.Errorf("source resolver returned an invalid release: %w", err) } for i := range release.Artifacts { if release.Artifacts[i].Target == selected.Target { @@ -952,16 +1086,9 @@ func (s syncModel) openResolvedArtifact(ctx context.Context, item installItem) ( if err != nil { return nil, err } - basePath := s.LockFilePath - if basePath == "" { - basePath = s.Path - } - if basePath == "" { - return nil, errors.New("sync project path is not set") - } - baseDir, err := filepath.Abs(filepath.Dir(basePath)) + baseDir, err := s.projectBaseDir() if err != nil { - return nil, fmt.Errorf("resolve project directory: %w", err) + return nil, err } fetchURL := func(ctx context.Context, artifactURL *url.URL) (io.ReadCloser, error) { version, err := semver.NewVersion(item.Release.Version) @@ -1464,7 +1591,7 @@ func (s syncModel) runSyncWorker(worker *syncWorker) syncWorkerResultMsg { s.driverIndex = drivers } - items, err := s.createInstallList(planned) + items, err := s.createInstallListContext(ctx, planned) if err != nil { return fail("sync_failed", err) } diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index 41b84ea9..decf38f9 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -38,6 +38,7 @@ import ( "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/packslip" "github.com/columnar-tech/dbc/internal/resolution" "github.com/columnar-tech/dbc/internal/sourceresolution" "github.com/stretchr/testify/assert" @@ -51,6 +52,74 @@ func mustTestInstallItem(t *testing.T, release resolution.ResolvedRelease, platf 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 makeSyncPackageV2Archive(t *testing.T, path, id, version, platform string) ([]byte, string) { + t.Helper() + metadata := []byte(fmt.Sprintf(`package_version = 2 +id = %q +name = "Test Driver" +version = %q +platform = %q + +[Driver] +entrypoint = "TestDriverInit" + +[Files] +driver = "driver.so" +`, id, version, platform)) + var archive bytes.Buffer + gzipWriter := gzip.NewWriter(&archive) + tarWriter := tar.NewWriter(gzipWriter) + for _, entry := range []struct { + name string + data []byte + }{{name: "dbc-package.toml", 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 := resolution.Target{OS: "macos", Arch: "arm64"} + 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", PackageVersion: 2, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: url}, Hash: hash, Size: &size}, + {Target: secondary, Format: "tar.gz", PackageVersion: 2, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://assets.example.test/macos.tar.gz"}, Hash: "sha256:" + strings.Repeat("b", 64), Size: &otherSize}, + }, + } +} + func TestSyncProgressPercentTracksCompletedItems(t *testing.T) { if got := syncProgressPercent(0, 2); got != 0.5 { t.Fatalf("first completed item progress = %v, want 0.5", got) @@ -79,55 +148,258 @@ func TestFreshRegistryInstallItemDefaultsToTarGZWithoutHostRequirements(t *testi assert.Empty(t, selected.HostRequirements) } -func TestSyncRejectsNonRegistrySourcesBeforeRegistryLookup(t *testing.T) { - sources := []struct { - name string - toml string - }{ - { - name: "packslip", - toml: "[drivers.test-driver-1]\nversion = '1.2.3'\n" + - "[drivers.test-driver-1.source]\ntype = 'packslip'\nproject = 'github.com/example/test-driver'\n", +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 := makeSyncPackageV2Archive(t, archivePath, driverID, version, config.PlatformTuple()) + 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 }, + downloadArtifact: func(_ context.Context, pkg dbc.PkgInfo) (io.ReadCloser, error) { + require.Equal(t, location, pkg.Path.String()) + return os.Open(archivePath) + }, }, - { - name: "path", - toml: "[drivers.test-driver-1]\nversion = '1.2.3'\n" + - "[drivers.test-driver-1.source]\ntype = 'path'\npath = '../packages/test-driver.tar.gz'\n", + Path: projectPath, LockFilePath: filepath.Join(dir, "dbc.lock"), NoVerify: true, + } + + 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 closePreparedArchives(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) + assert.Equal(t, 2, locked.Artifacts[0].PackageVersion) + assert.Equal(t, release.Artifacts[1].Location, locked.Artifacts[1].Location) + assert.Equal(t, "tgz", locked.Artifacts[0].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") + }, + downloadArtifact: func(_ context.Context, pkg dbc.PkgInfo) (io.ReadCloser, error) { + downloadCalls++ + assert.Equal(t, location, pkg.Path.String()) + assert.Equal(t, archiveHash, pkg.ArtifactHash) + assert.Equal(t, archiveSize, *pkg.ArtifactSize) + return os.Open(archivePath) + }, }, + Path: projectPath, LockFilePath: model.LockFilePath, NoVerify: true, } - for _, source := range sources { - for _, withRegistryLock := range []bool{false, true} { - name := source.name + "/without-lock" - if withRegistryLock { - name = source.name + "/registry-lock" - } - t.Run(name, func(t *testing.T) { - dir := t.TempDir() - projectPath := filepath.Join(dir, "dbc.toml") - lockPath := filepath.Join(dir, "dbc.lock") - require.NoError(t, os.WriteFile(projectPath, []byte(source.toml), 0o600)) - if withRegistryLock { - entry := testRegistryLockEntryForPlatform(config.PlatformTuple()) - entry.Name = "test-driver-1" - require.NoError(t, writeLockFileAtomic(lockPath, LockFile{Version: lockFileVersion, Drivers: []lockInfo{entry}})) - } + 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) + replayed, err := replay.prepareInstallItems(context.Background(), replayItems) + require.NoError(t, err) + defer closePreparedArchives(replayed.items) + assert.Equal(t, 1, downloadCalls) + assert.Equal(t, prepared.lock.Drivers[0].Artifacts, replayed.lock.Drivers[0].Artifacts) +} - var registryCalls atomic.Int32 - model := SyncCmd{Path: projectPath}.GetModelCustom(baseModel{ - getDriverRegistry: func() ([]dbc.Driver, error) { - registryCalls.Add(1) - return getTestDriverRegistry() - }, - downloadPkg: downloadTestPkg, - }).(syncModel) - defer model.worker.cancel() +func TestPackslipResolverMismatchIsRejectedBeforeCandidateLock(t *testing.T) { + changes := []struct { + name string + change func(*resolution.ResolvedRelease) + want string + }{ + {name: "driver ID", change: func(release *resolution.ResolvedRelease) { release.DriverID = "other-driver" }, want: "requested driver"}, + {name: "source identity", change: func(release *resolution.ResolvedRelease) { release.Source.Reference = "github.com/example/other" }, want: "source identity"}, + {name: "version", change: func(release *resolution.ResolvedRelease) { release.Version = "1.2.4" }, want: "requested version"}, + {name: "package marker", change: func(release *resolution.ResolvedRelease) { release.Artifacts[0].PackageVersion = 0 }, want: "package_version 2"}, + } + for _, test := range changes { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + base := makeSyncPackslipRelease("test-driver-1", "1.2.3", "https://assets.example.test/driver.tgz", "sha256:"+strings.Repeat("a", 64), 10) + test.change(&base) + resolver := &syncPackslipResolverStub{release: base} + version, err := semver.NewConstraint("1.2.3") + require.NoError(t, err) + source := dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "github.com/example/driver"} + model := syncModel{ + baseModel: baseModel{newPackslipResolver: func() (packslip.Resolver, error) { return resolver, nil }}, + LockFilePath: filepath.Join(dir, "dbc.lock"), + } + planned, _, err := model.planSyncItems(DriversList{Drivers: map[string]driverSpec{ + "test-driver-1": {Version: version, Source: &source}, + }}) + require.NoError(t, err) + _, err = model.createInstallListContext(context.Background(), planned) + require.ErrorContains(t, err, test.want) + assert.Equal(t, 1, resolver.calls) + assert.NoFileExists(t, model.LockFilePath) + }) + } +} - result := model.runSyncWorker(model.worker) - require.ErrorContains(t, result.err, "source type \""+source.name+"\"") - assert.Zero(t, registryCalls.Load(), "unsupported source declarations must fail before registry discovery") - }) - } +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, } + 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 closePreparedArchives(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.Equal(t, 0, locked.Artifacts[0].PackageVersion, "legacy MANIFEST path packages remain supported") + assert.NotEmpty(t, locked.Artifacts[0].Hash) +} + +func TestPathPackageV2MarkerSurvivesResolutionAndLockSnapshot(t *testing.T) { + dir := t.TempDir() + archivePath := filepath.Join(dir, "driver.tar.gz") + makeSyncPackageV2Archive(t, archivePath, "test-driver-1", "1.2.3", config.PlatformTuple()) + 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} + 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, 2, items[0].Release.Artifacts[0].PackageVersion) + assert.Equal(t, "1.2.3", items[0].Release.Version) + prepared, err := model.prepareInstallItems(context.Background(), items) + require.NoError(t, err) + defer closePreparedArchives(prepared.items) + require.Len(t, prepared.lock.Drivers, 1) + assert.Equal(t, 2, prepared.lock.Drivers[0].Artifacts[0].PackageVersion) + expected, _, err := expectedSyncPackageMetadata(prepared.items[0]) + require.NoError(t, err) + assert.Equal(t, 2, expected.PackageVersion) +} + +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} + 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 closePreparedArchives(prepared.items) + require.ErrorContains(t, err, "does not match expected hash") + assert.NoFileExists(t, model.LockFilePath) +} + +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" + entry.Artifacts[0].PackageVersion = 2 + 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) { @@ -2035,6 +2307,130 @@ func (suite *SubcommandTestSuite) TestSyncManifestOnlyInstallFailureConvergesFro suite.Equal("DriverInit", installed.Driver.Entrypoint) } +func (suite *SubcommandTestSuite) TestSyncPackslipInstallFailureConvergesFromCandidateLock() { + root := suite.T().TempDir() + suite.T().Setenv("ADBC_DRIVER_PATH", root) + projectPath := filepath.Join(root, "dbc.toml") + archivePath := filepath.Join(root, "driver.tgz") + lockPath := filepath.Join(root, "dbc.lock") + const version = "1.2.3+build.5" + archiveBytes, archiveHash := makeSyncPackageV2Archive(suite.T(), archivePath, "test-driver-1", version, config.PlatformTuple()) + archiveSize := int64(len(archiveBytes)) + location := "https://assets.example.test/driver.tgz" + release := makeSyncPackslipRelease("test-driver-1", version, location, archiveHash, archiveSize) + resolver := &syncPackslipResolverStub{release: release} + suite.Require().NoError(os.WriteFile(projectPath, []byte("[drivers.test-driver-1]\nversion = '"+version+"'\n[drivers.test-driver-1.source]\ntype = 'packslip'\nproject = 'github.com/example/driver'\n"), 0o600)) + + var registryCalls, resolverCreations int + first := SyncCmd{Path: projectPath, NoVerify: true}.GetModelCustom(baseModel{ + getDriverRegistry: func() ([]dbc.Driver, error) { + registryCalls++ + return nil, errors.New("Packslip sync must not discover registries") + }, + newPackslipResolver: func() (packslip.Resolver, error) { + resolverCreations++ + return resolver, nil + }, + downloadArtifact: func(_ context.Context, pkg dbc.PkgInfo) (io.ReadCloser, error) { + suite.Equal(location, pkg.Path.String()) + return os.Open(archivePath) + }, + }).(syncModel) + first.worker.hooks.ensurePackage = func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.InstallOptions, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + return config.EnsurePackageResult{}, errors.New("injected install failure") + } + suite.Contains(suite.runCmdErr(first), "injected install failure") + suite.Equal(0, registryCalls) + suite.Equal(1, resolverCreations) + candidateLock, err := os.ReadFile(lockPath) + suite.Require().NoError(err) + locked, err := loadLockFile(lockPath) + suite.Require().NoError(err) + suite.Equal("packslip", locked.lockinfo["test-driver-1"].Source.Type) + suite.Equal(version, locked.lockinfo["test-driver-1"].Version.String()) + _, err = config.GetDriver(config.Config{Level: config.ConfigEnv, Location: root}, "test-driver-1") + suite.Error(err, "an install failure after candidate save must not mutate runtime registration") + + var replayRegistryCalls, replayResolverCreations, replayDownloads int + second := SyncCmd{Path: projectPath, NoVerify: true}.GetModelCustom(baseModel{ + getDriverRegistry: func() ([]dbc.Driver, error) { + replayRegistryCalls++ + return nil, errors.New("candidate lock replay must not discover registries") + }, + newPackslipResolver: func() (packslip.Resolver, error) { + replayResolverCreations++ + return nil, errors.New("candidate lock replay must not create a resolver") + }, + downloadArtifact: func(_ context.Context, pkg dbc.PkgInfo) (io.ReadCloser, error) { + replayDownloads++ + suite.Equal(location, pkg.Path.String()) + suite.Equal(archiveHash, pkg.ArtifactHash) + suite.Equal(archiveSize, *pkg.ArtifactSize) + return os.Open(archivePath) + }, + }) + suite.runCmd(second) + suite.Zero(replayRegistryCalls) + suite.Zero(replayResolverCreations) + suite.Equal(1, replayDownloads, "replay opens the locked artifact without source discovery") + convergedLock, err := os.ReadFile(lockPath) + suite.Require().NoError(err) + suite.Equal(candidateLock, convergedLock) + installed, err := config.GetDriver(config.Config{Level: config.ConfigEnv, Location: root}, "test-driver-1") + suite.Require().NoError(err) + suite.Equal(version, installed.Version.String()) +} + +func (suite *SubcommandTestSuite) TestSyncPathInstallFailureConvergesFromCandidateLock() { + root := suite.T().TempDir() + suite.T().Setenv("ADBC_DRIVER_PATH", root) + projectPath := filepath.Join(root, "dbc.toml") + archivePath := filepath.Join(root, "packages", "driver.tar.gz") + lockPath := filepath.Join(root, "dbc.lock") + suite.Require().NoError(os.MkdirAll(filepath.Dir(archivePath), 0o700)) + archiveBytes, err := os.ReadFile(filepath.Join("testdata", "test-driver-1.tar.gz")) + suite.Require().NoError(err) + suite.Require().NoError(os.WriteFile(archivePath, archiveBytes, 0o600)) + declaredPath := "./packages/driver.tar.gz" + suite.Require().NoError(os.WriteFile(projectPath, []byte("[drivers.test-driver-1.source]\ntype = 'path'\npath = '"+declaredPath+"'\n"), 0o600)) + + var registryCalls int + newModel := func() syncModel { + return SyncCmd{Path: projectPath, NoVerify: true}.GetModelCustom(baseModel{ + getDriverRegistry: func() ([]dbc.Driver, error) { + registryCalls++ + return nil, errors.New("path sync must not discover registries") + }, + }).(syncModel) + } + first := newModel() + first.worker.hooks.ensurePackage = func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.InstallOptions, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + return config.EnsurePackageResult{}, errors.New("injected install failure") + } + suite.Contains(suite.runCmdErr(first), "injected install failure") + suite.Zero(registryCalls) + candidateLock, err := os.ReadFile(lockPath) + suite.Require().NoError(err) + locked, err := loadLockFile(lockPath) + suite.Require().NoError(err) + suite.Equal("path", locked.lockinfo["test-driver-1"].Source.Type) + suite.Equal(declaredPath, locked.lockinfo["test-driver-1"].Source.Path) + suite.Equal(declaredPath, locked.lockinfo["test-driver-1"].Artifacts[0].Location.Value) + suite.Equal("1.0.0", locked.lockinfo["test-driver-1"].Version.String()) + _, err = config.GetDriver(config.Config{Level: config.ConfigEnv, Location: root}, "test-driver-1") + suite.Error(err, "an install failure after candidate save must not mutate runtime registration") + + second := newModel() + suite.runCmd(second) + suite.Zero(registryCalls) + convergedLock, err := os.ReadFile(lockPath) + suite.Require().NoError(err) + suite.Equal(candidateLock, convergedLock) + installed, err := config.GetDriver(config.Config{Level: config.ConfigEnv, Location: root}, "test-driver-1") + suite.Require().NoError(err) + suite.Equal("1.0.0", installed.Version.String()) +} + func (suite *SubcommandTestSuite) TestSyncReceiptRepairUsesSelectedMultiPathRegistrationRoot() { tests := []struct { name string diff --git a/internal/sourceresolution/resolver.go b/internal/sourceresolution/resolver.go index 36ee4d05..efa6346c 100644 --- a/internal/sourceresolution/resolver.go +++ b/internal/sourceresolution/resolver.go @@ -163,11 +163,12 @@ func ResolvePath(ctx context.Context, declaredPath string, request Request) (res Version: resolvedVersion, Source: resolution.SourceSpec{Type: "path", Reference: declaredPath}, Artifacts: []resolution.Artifact{{ - Target: target, - Format: format, - Location: location, - Hash: archiveHash, - Size: &size, + Target: target, + Format: format, + PackageVersion: manifest.PackageVersion, + Location: location, + Hash: archiveHash, + Size: &size, }}, } if err := resolution.ValidateResolvedRelease(release); err != nil { diff --git a/internal/sourceresolution/resolver_test.go b/internal/sourceresolution/resolver_test.go index 00ef336a..6cd707e5 100644 --- a/internal/sourceresolution/resolver_test.go +++ b/internal/sourceresolution/resolver_test.go @@ -117,6 +117,7 @@ func TestResolvePathUsesProjectBaseAndPreservesDeclaration(t *testing.T) { 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, 2, artifact.PackageVersion) 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) From e81f9572b38ed80ae27b1cfe17d6e814cc748ee1 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 09:33:34 +0000 Subject: [PATCH 080/115] fix: refresh metadata-derived path versions --- cmd/dbc/sync.go | 12 +++++- cmd/dbc/sync_test.go | 96 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 6ddef865..7900596b 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -622,12 +622,16 @@ func (s syncModel) resolvePathPlan(ctx context.Context, planned plannedSyncItem) 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) } - version = release.Version + priorVersion = release.Version + if planned.Requirement.Version().Mode() != sourceresolution.PathMetadataDerived { + version = release.Version + } priorLock = planned.LockEntry } baseDir, err := s.projectBaseDir() @@ -645,6 +649,12 @@ func (s syncModel) resolvePathPlan(ctx context.Context, planned plannedSyncItem) 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) } diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index decf38f9..314136ab 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -375,6 +375,102 @@ func TestPathArchiveMutationAfterResolutionFailsBeforeCandidateLock(t *testing.T 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 := makeSyncPackageV2Archive(t, archivePath, "test-driver-1", "1.2.3", config.PlatformTuple()) + 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} + 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 closePreparedArchives(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)) + makeSyncPackageV2Archive(t, archivePath, "test-driver-1", "1.2.3", config.PlatformTuple()) + 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", PackageVersion: 2, + 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" From e81229fd0cfa745da534d92e31af90a092130706 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 09:56:10 +0000 Subject: [PATCH 081/115] feat: update project drivers by source --- cmd/dbc/add.go | 200 ++++++++++++++++------ cmd/dbc/add_test.go | 367 ++++++++++++++++++++++++++++++++++++++++- cmd/dbc/remove_test.go | 40 +++++ 3 files changed, 553 insertions(+), 54 deletions(-) diff --git a/cmd/dbc/add.go b/cmd/dbc/add.go index 4b00617d..eca11073 100644 --- a/cmd/dbc/add.go +++ b/cmd/dbc/add.go @@ -16,11 +16,13 @@ package main import ( "bytes" + "context" "encoding/json" "errors" "fmt" "os" "path/filepath" + "strings" "time" tea "charm.land/bubbletea/v2" @@ -29,6 +31,9 @@ import ( "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" ) @@ -63,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"` @@ -114,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 @@ -125,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 { @@ -135,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. @@ -176,54 +214,127 @@ func (m addModel) Init() tea.Cmd { f.Close() readLock.Release() + if m.list.Drivers == nil { + m.list.Drivers = make(map[string]driverSpec) + } + needsRegistry := false for _, spec := range specs { - if existing, ok := m.list.Drivers[spec.Name]; ok && existing.Source != nil && existing.Source.Type != dbc.DriverSourceRegistry { - return fmt.Errorf("cannot update driver %q with %s source using `dbc add` yet", spec.Name, existing.Source.Type) + current := m.list.Drivers[spec.Name] + if current.Source == nil || current.Source.Type == dbc.DriverSourceRegistry { + needsRegistry = true } } - - if err := applyProjectRegistries(m.list); err != nil { - 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) - } - // Store registry errors to use later if driver is not found - // We continue processing if we have some drivers - var registryErrors error = registryErr - - if m.list.Drivers == nil { - m.list.Drivers = make(map[string]driverSpec) + 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("error getting driver: %w", err) + return fmt.Errorf("driver %q Packslip source requires an exact SemVer 2.0.0 version: %w", spec.Name, err) } - } else { - if !m.Pre && !drv.HasNonPrerelease() { + updatedVersion, err = exactSourceVersionConstraint(versionText) + if err != nil { + return fmt.Errorf("driver %q Packslip source has an invalid exact version: %w", spec.Name, err) + } + 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: + 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()) + 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 { @@ -233,9 +344,8 @@ func (m addModel) Init() tea.Cmd { } } - current, ok := m.list.Drivers[spec.Name] updated := current - updated.Version = spec.Vers + updated.Version = updatedVersion updated.Prerelease = "" if m.Pre { updated.Prerelease = "allow" @@ -290,20 +400,18 @@ func (m addModel) Init() tea.Cmd { 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) } diff --git a/cmd/dbc/add_test.go b/cmd/dbc/add_test.go index 958f0696..fd5d26a7 100644 --- a/cmd/dbc/add_test.go +++ b/cmd/dbc/add_test.go @@ -18,20 +18,30 @@ 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/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 @@ -208,30 +218,371 @@ func TestAddUpdatingDriverPreservesSource(t *testing.T) { assert.Equal(t, "allow", got.Prerelease) } -func TestAddRejectsPackslipVersionUpdateWithoutMutatingProject(t *testing.T) { +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/test-driver'\n" + "project = 'github.com/example/driver'\n" require.NoError(t, os.WriteFile(path, []byte(initial), 0o644)) + release := makeSyncPackslipRelease( + "test-driver-1", "1.2.4+build.5", "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.0.0"}, - }.GetModelCustom(testBaseModel()).(interface { + Driver: []string{"test-driver-1=1.2.4+build.5"}, + }.GetModelCustom(base).(interface { Init() tea.Cmd Update(tea.Msg) (tea.Model, tea.Cmd) })) - err, ok := msg.(error) - require.True(t, ok, "registry-centric add must reject an existing Packslip source") - assert.ErrorContains(t, err, "cannot update driver \"test-driver-1\" with packslip source") + _, 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+build.5"}, resolver.request) data, readErr := os.ReadFile(path) require.NoError(t, readErr) - assert.Equal(t, initial, string(data), "rejected add must leave dbc.toml byte-for-byte unchanged") + 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+build.5", 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 TestAddRejectsInvalidPackslipResolverResultWithoutMutation(t *testing.T) { + tests := []struct { + name string + mutate func(*resolution.ResolvedRelease) + want string + }{ + { + name: "wrong driver ID", + mutate: func(release *resolution.ResolvedRelease) { + release.DriverID = "some-other-driver" + }, + want: `driver ID "some-other-driver" does not match requested driver "test-driver-1"`, + }, + { + name: "wrong version", + mutate: func(release *resolution.ResolvedRelease) { + release.Version = "1.2.5" + }, + want: `packslip release version "1.2.5" does not match requested version "1.2.4"`, + }, + { + name: "wrong source", + mutate: func(release *resolution.ResolvedRelease) { + release.Source.Reference = "github.com/example/other" + }, + want: `source identity "github.com/example/other" does not match declared project "github.com/example/driver"`, + }, + { + name: "missing dbc package declaration", + mutate: func(release *resolution.ResolvedRelease) { + release.Artifacts[0].PackageVersion = 1 + }, + want: "unsupported dbc package version 1", + }, + } + 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]\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.4", "https://assets.example.test/driver.tgz", + "sha256:"+strings.Repeat("a", 64), 10) + tt.mutate(&release) + 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, tt.want) + 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 range", source: "packslip", input: "test-driver-1>=1.2.3"}, + {name: "packslip missing", source: "packslip", input: "test-driver-1"}, + {name: "packslip coercible", source: "packslip", input: "test-driver-1=v1.2.3"}, + {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) { 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) From 149b7a40695ad398645995bf3219477c969b4add Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 10:11:01 +0000 Subject: [PATCH 082/115] refactor: share resolved package execution --- cmd/dbc/package_executor.go | 566 ++++++++++++++++++++++++++++++++++++ cmd/dbc/sync.go | 485 ++---------------------------- cmd/dbc/sync_test.go | 22 ++ 3 files changed, 606 insertions(+), 467 deletions(-) create mode 100644 cmd/dbc/package_executor.go diff --git a/cmd/dbc/package_executor.go b/cmd/dbc/package_executor.go new file mode 100644 index 00000000..d414fe5a --- /dev/null +++ b/cmd/dbc/package_executor.go @@ -0,0 +1,566 @@ +// 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) + ensurePackage func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.InstallOptions, 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), + ensurePackage func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.InstallOptions, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error), +) *packageExecutor { + if ensurePackage == nil { + ensurePackage = config.EnsurePackage + } + return &packageExecutor{ + cfg: cfg, baseDir: baseDir, noVerify: noVerify, + downloadArtifact: downloadArtifact, downloadPkg: downloadPkg, + 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.InstallOptions, 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, 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 hasHash != hasSize { + return config.ExpectedPackageMetadata{}, false, errors.New("package metadata must include both archive hash and size") + } + if selected.PackageVersion == 2 && !hasHash { + return config.ExpectedPackageMetadata{}, false, errors.New("package v2 requires archive hash and size metadata") + } + expected := config.ExpectedPackageMetadata{ + ID: item.Release.DriverID, Version: item.Release.Version, Platform: item.Platform, + SourceType: item.Release.Source.Type, SourceIdentity: item.Release.Source.Reference, + PackageVersion: selected.PackageVersion, + } + if hasHash { + expected.ArchiveHash = selected.Hash + expected.ArchiveSize = *selected.Size + } + return expected, hasHash, nil +} + +func snapshotDownloadedArchive(item *installItem, archive *os.File) error { + if archive == nil { + return fmt.Errorf("download returned no archive") + } + selected, err := item.selectedArtifact() + if err != nil { + return err + } + if (selected.Hash == "") != (selected.Size == nil) { + return errors.New("artifact hash and size must either both be present or both be absent") + } + if err := resolution.ValidateArtifactMetadata(selected.Hash, selected.Size); err != nil { + return err + } + info, err := archive.Stat() + if err != nil { + return fmt.Errorf("failed to stat downloaded archive: %w", err) + } + actualSize := info.Size() + actualHash, err := checksumFile(archive, archive.Name()) + if err != nil { + return err + } + actualHash = "sha256:" + actualHash + if err := resolution.ValidateArtifactMetadata(actualHash, &actualSize); err != nil { + return err + } + if selected.Hash != "" && selected.Hash != actualHash { + return fmt.Errorf("downloaded archive hash %s does not match expected hash %s", actualHash, selected.Hash) + } + if selected.Size != nil && *selected.Size != actualSize { + return fmt.Errorf("downloaded archive size %d does not match expected size %d", actualSize, *selected.Size) + } + selected.Hash = actualHash + selected.Size = cloneInt64(&actualSize) + if _, err := archive.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("failed to rewind downloaded archive: %w", err) + } + return nil +} + +func (e *packageExecutor) openResolvedArtifact(ctx context.Context, item installItem) (*sourceresolution.OpenedArtifact, error) { + selected, err := item.selectedArtifact() + if err != nil { + return nil, err + } + fetchURL := func(ctx context.Context, 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) + } + pkg := dbc.PkgInfo{ + Driver: dbc.Driver{Path: item.Release.DriverID, Title: item.Release.DriverID}, + 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) + } + return sourceresolution.OpenArtifact(ctx, fetchURL, selected.Location, e.baseDir) +} + +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.downloadAndValidateItem(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) downloadAndValidateItem(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 + } + expected, hasMetadata, 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 + if err := snapshotDownloadedArchive(item, archive); err != nil { + return fmt.Errorf("failed to snapshot downloaded driver archive: %w", err) + } + selected, err = item.selectedArtifact() + if err != nil { + return err + } + if !hasMetadata { + if _, err := archive.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("failed to rewind downloaded driver archive: %w", err) + } + packageManifest, inspectErr := config.InspectPackageMetadata(archive) + if inspectErr != nil { + return inspectErr + } + selected.PackageVersion = packageManifest.PackageVersion + expected.PackageVersion = selected.PackageVersion + if packageManifest.PackageVersion == 2 { + return errors.New("package v2 requires archive hash and size metadata") + } + } + expected.ArchiveHash = selected.Hash + expected.ArchiveSize = *selected.Size + item.Expected = expected + if _, err := archive.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("failed to rewind downloaded driver archive: %w", err) + } + var verify func(string, config.Manifest) error + if !e.noVerify { + verify = func(stagingDir string, manifest config.Manifest) error { + return dbc.VerifyPackageSignature(stagingDir, manifest) + } + } + validation, err := config.ValidatePackage(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 validate driver package: %w", err) + } + selected.PackageVersion = validation.PackageVersion + expected.PackageVersion = validation.PackageVersion + item.Expected = expected + item.Validation = &validation + 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) + } + } + } + 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 { + 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 err := ctx.Err(); err != nil { + return config.EnsurePackageResult{}, err + } + selected, err := item.selectedArtifact() + if err != nil { + return config.EnsurePackageResult{}, err + } + if err := validateInstallableArtifactFormat(selected.Format); err != nil { + return config.EnsurePackageResult{}, fmt.Errorf("driver %s: %w", item.Release.DriverID, err) + } + if err := validateHostRequirements(item.Release.DriverID, selected.HostRequirements); err != nil { + return config.EnsurePackageResult{}, err + } + callbacks := config.EnsurePackageCallbacks{ + CurrentMatches: func(current *config.DriverInfo) (bool, error) { + return e.itemCurrentMatches(item, current) + }, + Archive: func(ctx context.Context) (*os.File, error) { + if item.Archive == nil || item.Validation == nil { + if err := e.downloadAndValidateItem(ctx, item); err != nil { + return nil, err + } + } + if err := ctx.Err(); err != nil { + return nil, err + } + if _, err := item.Archive.File.Seek(0, io.SeekStart); err != nil { + return nil, fmt.Errorf("failed to rewind prepared driver archive: %w", err) + } + return item.Archive.File, nil + }, + ValidateResult: func(result config.EnsurePackageResult) error { + return e.validateEnsureResult(item, result) + }, + } + var verify func(string, config.Manifest) error + if !e.noVerify { + verify = func(stagingDir string, manifest config.Manifest) error { + return dbc.VerifyPackageSignature(stagingDir, manifest) + } + } + result, err := e.ensurePackage(ctx, e.cfg, item.Release.DriverID, item.Expected, config.InstallOptions{Verify: verify}, 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") + } + actualHash, err := checksum(libraryPath) + if err != nil { + return syncChecksumError{err: err} + } + if actualHash != item.ValidatedLibraryHash { + return syncChecksumError{err: errors.New("installed library checksum does not match 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/sync.go b/cmd/dbc/sync.go index 7900596b..24ccb6d9 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -20,11 +20,9 @@ import ( "fmt" "io" "io/fs" - "net/url" "os" "path/filepath" "reflect" - "sort" "strings" "sync" "time" @@ -922,15 +920,6 @@ func installItemFromLockedArtifact(name string, entry lockInfo, artifact lockArt return newInstallItem(release, artifactIndex, config.PlatformTuple(), &entry) } -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 cloneHostRequirements(requirements resolution.HostRequirements) resolution.HostRequirements { return resolution.HostRequirements{ OSMin: requirements.OSMin, @@ -940,46 +929,6 @@ func cloneHostRequirements(requirements resolution.HostRequirements) resolution. } } -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, "; ")) -} - type installedDrvMsg struct { removed *config.DriverInfo info config.DriverInfo @@ -1050,159 +999,26 @@ func lockSourceForResolvedRelease(release resolution.ResolvedRelease) (lockSourc return source, nil } -func snapshotDownloadedArchive(item *installItem, archive *os.File) error { - if archive == nil { - return fmt.Errorf("download returned no archive") - } - selected, err := item.selectedArtifact() - if err != nil { - return err - } - if (selected.Hash == "") != (selected.Size == nil) { - return errors.New("artifact hash and size must either both be present or both be absent") - } - if err := resolution.ValidateArtifactMetadata(selected.Hash, selected.Size); err != nil { - return err - } - info, err := archive.Stat() - if err != nil { - return fmt.Errorf("failed to stat downloaded archive: %w", err) - } - actualSize := info.Size() - actualHash, err := checksumFile(archive, archive.Name()) - if err != nil { - return err - } - actualHash = "sha256:" + actualHash - if err := resolution.ValidateArtifactMetadata(actualHash, &actualSize); err != nil { - return err - } - if selected.Hash != "" && selected.Hash != actualHash { - return fmt.Errorf("downloaded archive hash %s does not match expected hash %s", actualHash, selected.Hash) - } - if selected.Size != nil && *selected.Size != actualSize { - return fmt.Errorf("downloaded archive size %d does not match expected size %d", actualSize, *selected.Size) +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 } - selected.Hash = actualHash - selected.Size = cloneInt64(&actualSize) - if _, err := archive.Seek(0, io.SeekStart); err != nil { - return fmt.Errorf("failed to rewind downloaded archive: %w", err) + if len(prepared.items) == 0 { + return prepared, nil } - return nil -} - -func (s syncModel) openResolvedArtifact(ctx context.Context, item installItem) (*sourceresolution.OpenedArtifact, error) { - selected, err := item.selectedArtifact() + executor, err := s.newPackageExecutor() if err != nil { - return nil, err + return prepared, err } - baseDir, err := s.projectBaseDir() - if err != nil { - return nil, err - } - fetchURL := func(ctx context.Context, 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) - } - pkg := dbc.PkgInfo{ - Driver: dbc.Driver{Path: item.Release.DriverID, Title: item.Release.DriverID}, - Version: version, - PlatformTuple: item.Platform, - Path: artifactURL, - ArtifactHash: selected.Hash, - ArtifactSize: cloneInt64(selected.Size), - } - if s.downloadArtifact != nil { - return s.downloadArtifact(ctx, pkg) - } - if s.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 s.downloadPkg(pkg) - } - return sourceresolution.OpenArtifact(ctx, fetchURL, selected.Location, baseDir) -} - -func (s syncModel) prepareInstallItems(ctx context.Context, items []installItem) (preparedSyncMsg, error) { - prepared := preparedSyncMsg{items: items, lock: LockFile{Version: lockFileVersion}} for i := range prepared.items { if err := ctx.Err(); err != nil { return prepared, err } item := &prepared.items[i] - selected, err := item.selectedArtifact() - if err != nil { - return prepared, err - } - if err := validateInstallableArtifactFormat(selected.Format); err != nil { - return prepared, fmt.Errorf("driver %s: %w", item.Release.DriverID, err) - } - if err := validateHostRequirements(item.Release.DriverID, selected.HostRequirements); err != nil { + if err := executor.prepareItem(ctx, item); err != nil { return prepared, err } - var sameVersionInstalled *config.DriverInfo - if s.cfg.Exists { - if installed, ok := s.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 prepared, err - } - item.Expected = expected - matches, err := s.itemCurrentMatches(item, &installedCopy) - if err != nil { - return prepared, err - } - if matches { - libraryHash, hashErr := checksum(installed.Driver.Shared.Get(item.Platform)) - if hashErr != nil { - return prepared, fmt.Errorf("failed to checksum installed driver: %w", hashErr) - } - markAlreadyInstalled(item, installed, libraryHash) - } - } - } - } - - needsArchive := item.AlreadyInstalled == nil || !canReuseLockedEntry(*item) - if needsArchive { - if err := s.downloadAndValidateItem(ctx, item); err != nil { - return prepared, err - } - if sameVersionInstalled != nil { - matches, err := s.itemCurrentMatches(item, sameVersionInstalled) - if err != nil { - return prepared, err - } - if matches { - libraryHash, hashErr := checksum(sameVersionInstalled.Driver.Shared.Get(item.Platform)) - if hashErr != nil { - return prepared, 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 prepared, err - } - item.Expected = expected - } - entry, err := lockEntryForItem(*item) if err != nil { return prepared, fmt.Errorf("failed to update lock entry: %w", err) @@ -1218,300 +1034,35 @@ func (s syncModel) prepareInstallItems(ctx context.Context, items []installItem) } func (s syncModel) downloadAndValidateItem(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 - } - expected, hasMetadata, err := expectedSyncPackageMetadata(*item) - if err != nil { - return err - } - if item.Archive == nil { - archive, err := s.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 - if err := snapshotDownloadedArchive(item, archive); err != nil { - return fmt.Errorf("failed to snapshot downloaded driver archive: %w", err) - } - selected, err = item.selectedArtifact() + executor, err := s.newPackageExecutor() if err != nil { return err } - if !hasMetadata { - if _, err := archive.Seek(0, io.SeekStart); err != nil { - return fmt.Errorf("failed to rewind downloaded driver archive: %w", err) - } - packageManifest, inspectErr := config.InspectPackageMetadata(archive) - if inspectErr != nil { - return inspectErr - } - selected.PackageVersion = packageManifest.PackageVersion - expected.PackageVersion = selected.PackageVersion - if packageManifest.PackageVersion == 2 { - return errors.New("package v2 requires archive hash and size metadata") - } - } - expected.ArchiveHash = selected.Hash - expected.ArchiveSize = *selected.Size - item.Expected = expected - if _, err := archive.Seek(0, io.SeekStart); err != nil { - return fmt.Errorf("failed to rewind downloaded driver archive: %w", err) - } - var verify func(string, config.Manifest) error - if !s.NoVerify { - verify = func(stagingDir string, manifest config.Manifest) error { - return dbc.VerifyPackageSignature(stagingDir, manifest) - } - } - validation, err := config.ValidatePackage(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 validate driver package: %w", err) - } - selected.PackageVersion = validation.PackageVersion - expected.PackageVersion = validation.PackageVersion - item.Expected = expected - item.Validation = &validation - 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) - } - } - } - return nil + return executor.downloadAndValidateItem(ctx, item) } func (s syncModel) 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(s.cfg, *current) + executor, err := s.newPackageExecutor() 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 { - 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 + return false, err } - 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 + return executor.itemCurrentMatches(item, current) } func (s syncModel) ensurePreparedPackage(ctx context.Context, item *installItem) (config.EnsurePackageResult, error) { - if err := ctx.Err(); err != nil { - return config.EnsurePackageResult{}, err - } - selected, err := item.selectedArtifact() + executor, err := s.newPackageExecutor() if err != nil { return config.EnsurePackageResult{}, err } - if err := validateInstallableArtifactFormat(selected.Format); err != nil { - return config.EnsurePackageResult{}, fmt.Errorf("driver %s: %w", item.Release.DriverID, err) - } - if err := validateHostRequirements(item.Release.DriverID, selected.HostRequirements); err != nil { - return config.EnsurePackageResult{}, err - } - callbacks := config.EnsurePackageCallbacks{ - CurrentMatches: func(current *config.DriverInfo) (bool, error) { - return s.itemCurrentMatches(item, current) - }, - Archive: func(ctx context.Context) (*os.File, error) { - if item.Archive == nil || item.Validation == nil { - if err := s.downloadAndValidateItem(ctx, item); err != nil { - return nil, err - } - } - if err := ctx.Err(); err != nil { - return nil, err - } - if _, err := item.Archive.File.Seek(0, io.SeekStart); err != nil { - return nil, fmt.Errorf("failed to rewind prepared driver archive: %w", err) - } - return item.Archive.File, nil - }, - ValidateResult: func(result config.EnsurePackageResult) error { - return s.validateEnsureResult(item, result) - }, - } - var verify func(string, config.Manifest) error - if !s.NoVerify { - verify = func(stagingDir string, manifest config.Manifest) error { - return dbc.VerifyPackageSignature(stagingDir, manifest) - } - } - ensure := s.worker.hooks.ensurePackage - if ensure == nil { - ensure = config.EnsurePackage - } - result, err := ensure(ctx, s.cfg, item.Release.DriverID, item.Expected, config.InstallOptions{Verify: verify}, callbacks) - if err != nil && isPackageVerificationFailure(err) { - return result, fmt.Errorf("failed to verify signature: %w", packageVerificationError(err)) - } - return result, err + return executor.ensurePreparedPackage(ctx, item) } func (s syncModel) 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 := s.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(s.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") - } - actualHash, err := checksum(libraryPath) - if err != nil { - return syncChecksumError{err: err} - } - if actualHash != item.ValidatedLibraryHash { - return syncChecksumError{err: errors.New("installed library checksum does not match validated package")} - } - return nil -} - -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 hasHash != hasSize { - return config.ExpectedPackageMetadata{}, false, errors.New("package metadata must include both archive hash and size") - } - if selected.PackageVersion == 2 && !hasHash { - return config.ExpectedPackageMetadata{}, false, errors.New("package v2 requires archive hash and size metadata") - } - expected := config.ExpectedPackageMetadata{ - ID: item.Release.DriverID, Version: item.Release.Version, Platform: item.Platform, - SourceType: item.Release.Source.Type, SourceIdentity: item.Release.Source.Reference, - PackageVersion: selected.PackageVersion, - } - if hasHash { - expected.ArchiveHash = selected.Hash - expected.ArchiveSize = *selected.Size - } - return expected, hasHash, 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) + executor, err := s.newPackageExecutor() if err != nil { return err } - if actualHash != expectedHash { - return fmt.Errorf("candidate external library checksum mismatch: got %s, expected %s", actualHash, expectedHash) - } - return nil + return executor.validateEnsureResult(item, result) } type syncChecksumError struct{ err error } diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index 314136ab..fa5a1a36 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -129,6 +129,28 @@ func TestSyncProgressPercentTracksCompletedItems(t *testing.T) { } } +func TestPackageExecutorEnsureHookDoesNotRequireSyncWorker(t *testing.T) { + item := mustTestInstallItem(t, 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", + Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: "./example.tgz"}, + }}, + }, config.PlatformTuple(), nil) + wantErr := errors.New("ensure hook invoked") + calls := 0 + executor := newPackageExecutor(config.Config{}, t.TempDir(), true, nil, nil, + func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.InstallOptions, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + calls++ + return config.EnsurePackageResult{}, wantErr + }) + _, err := executor.ensurePreparedPackage(context.Background(), &item) + require.ErrorIs(t, err, wantErr) + assert.Equal(t, 1, calls) +} + func TestFreshRegistryInstallItemDefaultsToTarGZWithoutHostRequirements(t *testing.T) { drivers, err := getTestDriverRegistry() require.NoError(t, err) From 62766e18999372fade947d10062e21739c5d7cd4 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 10:25:40 +0000 Subject: [PATCH 083/115] fix: isolate Packslip artifact transport --- cmd/dbc/helpers.go | 7 +- cmd/dbc/main.go | 10 +- cmd/dbc/package_executor.go | 71 +++++++++---- cmd/dbc/packslip_fetch.go | 109 ++++++++++++++++++++ cmd/dbc/packslip_fetch_test.go | 176 +++++++++++++++++++++++++++++++++ cmd/dbc/sync_test.go | 26 ++--- 6 files changed, 358 insertions(+), 41 deletions(-) create mode 100644 cmd/dbc/packslip_fetch.go create mode 100644 cmd/dbc/packslip_fetch_test.go diff --git a/cmd/dbc/helpers.go b/cmd/dbc/helpers.go index eb8f564c..f2774a93 100644 --- a/cmd/dbc/helpers.go +++ b/cmd/dbc/helpers.go @@ -60,9 +60,10 @@ func wrapWithRegistryContext(err, registryErr error) error { func defaultBaseModel() baseModel { return baseModel{ - getDriverRegistry: getDriverRegistry, - downloadPkg: downloadPkg, - downloadArtifact: downloadPackage, + getDriverRegistry: getDriverRegistry, + downloadPkg: downloadPkg, + downloadArtifact: downloadPackage, + fetchPackslipArtifact: fetchPackslipArtifact, newPackslipResolver: func() (packslip.Resolver, error) { return packslip.NewResolver(packslip.Config{}) }, diff --git a/cmd/dbc/main.go b/cmd/dbc/main.go index 2c36321a..0456d716 100644 --- a/cmd/dbc/main.go +++ b/cmd/dbc/main.go @@ -20,6 +20,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "slices" "strings" @@ -225,10 +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) - downloadArtifact func(context.Context, dbc.PkgInfo) (io.ReadCloser, error) - newPackslipResolver func() (packslip.Resolver, 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 diff --git a/cmd/dbc/package_executor.go b/cmd/dbc/package_executor.go index d414fe5a..3080365f 100644 --- a/cmd/dbc/package_executor.go +++ b/cmd/dbc/package_executor.go @@ -41,20 +41,26 @@ type packageExecutor struct { 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.InstallOptions, 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.InstallOptions, 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, } } @@ -72,7 +78,7 @@ func (s syncModel) newPackageExecutor() (*packageExecutor, error) { if s.worker != nil { ensurePackage = s.worker.hooks.ensurePackage } - return newPackageExecutor(s.cfg, baseDir, s.NoVerify, s.downloadArtifact, s.downloadPkg, ensurePackage), nil + return newPackageExecutor(s.cfg, baseDir, s.NoVerify, s.downloadArtifact, s.downloadPkg, s.fetchPackslipArtifact, ensurePackage), nil } func validateInstallableArtifactFormat(format string) error { @@ -199,30 +205,53 @@ func (e *packageExecutor) openResolvedArtifact(ctx context.Context, item install if err != nil { return nil, err } - fetchURL := func(ctx context.Context, 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) + 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) } - pkg := dbc.PkgInfo{ - Driver: dbc.Driver{Path: item.Release.DriverID, Title: item.Release.DriverID}, - Version: version, - PlatformTuple: item.Platform, - Path: artifactURL, - ArtifactHash: selected.Hash, - ArtifactSize: cloneInt64(selected.Size), - } - if e.downloadArtifact != nil { - return e.downloadArtifact(ctx, pkg) + 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") } - if e.downloadPkg == nil { - return nil, errors.New("no artifact downloader 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) } - // Existing test adapters and custom models expose the legacy file-based - // hook. Production sync uses downloadArtifact, which calls Client.Download. - return e.downloadPkg(pkg) + 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) + } + pkg := dbc.PkgInfo{ + Driver: dbc.Driver{Path: item.Release.DriverID, Title: item.Release.DriverID}, + 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") } - return sourceresolution.OpenArtifact(ctx, fetchURL, selected.Location, e.baseDir) + // 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 { diff --git a/cmd/dbc/packslip_fetch.go b/cmd/dbc/packslip_fetch.go new file mode 100644 index 00000000..c371e521 --- /dev/null +++ b/cmd/dbc/packslip_fetch.go @@ -0,0 +1,109 @@ +// 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 { + return previousRedirectCheck(request, via) + } + 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..111da328 --- /dev/null +++ b/cmd/dbc/packslip_fetch_test.go @@ -0,0 +1,176 @@ +// 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", PackageVersion: 2, + 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) +} diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index fa5a1a36..d89736f2 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -141,7 +141,7 @@ func TestPackageExecutorEnsureHookDoesNotRequireSyncWorker(t *testing.T) { }, config.PlatformTuple(), nil) wantErr := errors.New("ensure hook invoked") calls := 0 - executor := newPackageExecutor(config.Config{}, t.TempDir(), true, nil, nil, + executor := newPackageExecutor(config.Config{}, t.TempDir(), true, nil, nil, nil, func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.InstallOptions, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { calls++ return config.EnsurePackageResult{}, wantErr @@ -210,8 +210,8 @@ func TestFreshPackslipResolutionSnapshotsAndReplaysWithoutDiscovery(t *testing.T model := syncModel{ baseModel: baseModel{ newPackslipResolver: func() (packslip.Resolver, error) { return resolver, nil }, - downloadArtifact: func(_ context.Context, pkg dbc.PkgInfo) (io.ReadCloser, error) { - require.Equal(t, location, pkg.Path.String()) + fetchPackslipArtifact: func(_ context.Context, artifactURL *url.URL) (io.ReadCloser, error) { + require.Equal(t, location, artifactURL.String()) return os.Open(archivePath) }, }, @@ -254,11 +254,9 @@ func TestFreshPackslipResolutionSnapshotsAndReplaysWithoutDiscovery(t *testing.T resolverConstructions++ return nil, errors.New("replay must not construct a resolver") }, - downloadArtifact: func(_ context.Context, pkg dbc.PkgInfo) (io.ReadCloser, error) { + fetchPackslipArtifact: func(_ context.Context, artifactURL *url.URL) (io.ReadCloser, error) { downloadCalls++ - assert.Equal(t, location, pkg.Path.String()) - assert.Equal(t, archiveHash, pkg.ArtifactHash) - assert.Equal(t, archiveSize, *pkg.ArtifactSize) + assert.Equal(t, location, artifactURL.String()) return os.Open(archivePath) }, }, @@ -272,6 +270,10 @@ func TestFreshPackslipResolutionSnapshotsAndReplaysWithoutDiscovery(t *testing.T 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 closePreparedArchives(replayed.items) @@ -2449,8 +2451,8 @@ func (suite *SubcommandTestSuite) TestSyncPackslipInstallFailureConvergesFromCan resolverCreations++ return resolver, nil }, - downloadArtifact: func(_ context.Context, pkg dbc.PkgInfo) (io.ReadCloser, error) { - suite.Equal(location, pkg.Path.String()) + fetchPackslipArtifact: func(_ context.Context, artifactURL *url.URL) (io.ReadCloser, error) { + suite.Equal(location, artifactURL.String()) return os.Open(archivePath) }, }).(syncModel) @@ -2479,11 +2481,9 @@ func (suite *SubcommandTestSuite) TestSyncPackslipInstallFailureConvergesFromCan replayResolverCreations++ return nil, errors.New("candidate lock replay must not create a resolver") }, - downloadArtifact: func(_ context.Context, pkg dbc.PkgInfo) (io.ReadCloser, error) { + fetchPackslipArtifact: func(_ context.Context, artifactURL *url.URL) (io.ReadCloser, error) { replayDownloads++ - suite.Equal(location, pkg.Path.String()) - suite.Equal(archiveHash, pkg.ArtifactHash) - suite.Equal(archiveSize, *pkg.ArtifactSize) + suite.Equal(location, artifactURL.String()) return os.Open(archivePath) }, }) From c90bbb6b0bfc306a2e52af2bb5e0e19bc282b354 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 10:39:16 +0000 Subject: [PATCH 084/115] fix: revalidate Packslip artifact redirects --- cmd/dbc/packslip_fetch.go | 7 ++++++- cmd/dbc/packslip_fetch_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/cmd/dbc/packslip_fetch.go b/cmd/dbc/packslip_fetch.go index c371e521..299e757d 100644 --- a/cmd/dbc/packslip_fetch.go +++ b/cmd/dbc/packslip_fetch.go @@ -75,7 +75,12 @@ func fetchPackslipArtifactWithClient(ctx context.Context, client *http.Client, a return fmt.Errorf("reject Packslip artifact redirect: %w", err) } if previousRedirectCheck != nil { - return previousRedirectCheck(request, via) + 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 } diff --git a/cmd/dbc/packslip_fetch_test.go b/cmd/dbc/packslip_fetch_test.go index 111da328..32a8c2c0 100644 --- a/cmd/dbc/packslip_fetch_test.go +++ b/cmd/dbc/packslip_fetch_test.go @@ -174,3 +174,32 @@ func TestPackslipArtifactFetchRejectsHTTPSDowngradeRedirect(t *testing.T) { 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) +} From edf7261d2e6562dc9d754f052e42317ccf19a991 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 11:15:08 +0000 Subject: [PATCH 085/115] feat: install resolved sources directly --- cmd/dbc/install.go | 352 ++++++++----------- cmd/dbc/install_source.go | 230 ++++++++++++ cmd/dbc/install_test.go | 541 +++++++++++++++++------------ cmd/dbc/main_test.go | 11 +- cmd/dbc/package_executor.go | 6 +- internal/jsonschema/schema.go | 8 + internal/jsonschema/schema_test.go | 6 +- 7 files changed, 719 insertions(+), 435 deletions(-) create mode 100644 cmd/dbc/install_source.go diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index 8c8abd7c..cde84c5e 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -15,6 +15,7 @@ package main import ( + "context" "encoding/json" "errors" "fmt" @@ -34,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, " ~^<>=!") @@ -62,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)"` @@ -74,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, @@ -98,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), @@ -110,15 +109,18 @@ func (c InstallCmd) GetModel() tea.Model { return c.GetModelCustom(defaultBaseModel()) } -type writeDriverManifestMsg struct { - DriverInfo config.DriverInfo -} +type directInstallResolvedMsg struct{ item installItem } -type installVerificationFailedMsg struct { - err error +type directInstallPreparedMsg struct { + item installItem + executor *packageExecutor } -type localInstallMsg struct{} +type directInstallFinishedMsg struct { + item installItem + result config.EnsurePackageResult + err error +} // alreadyInstalledChecksumMsg carries the checksum computed for an already-installed driver. type alreadyInstalledChecksumMsg string @@ -205,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 @@ -218,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} }) } @@ -256,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 + } + if err := executor.prepareItem(context.Background(), &item); err != nil { + if closeErr := closeDirectInstallArchive(&item); closeErr != nil { + err = errors.Join(err, fmt.Errorf("close prepared package archive: %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 { + installCfg, err := installConfigForEnsure(executor.cfg) + if err != nil { + if closeErr := closeDirectInstallArchive(&item); closeErr != nil { + err = errors.Join(err, fmt.Errorf("close prepared package archive: %w", closeErr)) + } + return directInstallFinishedMsg{item: item, err: err} + } + executor.cfg = installCfg + result, err := executor.ensurePreparedPackage(context.Background(), &item) + if closeErr := closeDirectInstallArchive(&item); closeErr != nil { + err = errors.Join(err, fmt.Errorf("close prepared package archive: %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" @@ -283,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 @@ -313,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) @@ -407,148 +480,6 @@ 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 (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 downloaded == nil { - return errors.New("downloaded package archive is nil") - } - defer downloaded.Close() - - var ( - expected config.ExpectedPackageMetadata - runtimeID string - verify func(string, config.Manifest) error - err error - ) - if !m.isLocal { - var hasMetadata bool - expected, hasMetadata, err = expectedRegistryPackageMetadata(m.DriverPackage) - if err != nil { - return err - } - if !hasMetadata { - packageManifest, inspectErr := config.InspectPackageMetadata(downloaded) - if inspectErr != nil { - return inspectErr - } - if packageManifest.PackageVersion == 2 { - return errors.New("registry package v2 requires archive hash and size metadata") - } - } - runtimeID = expected.ID - } else { - packageManifest, inspectErr := config.InspectPackageMetadata(downloaded) - if inspectErr != nil { - return inspectErr - } - runtimeID = m.Driver - if packageManifest.PackageVersion == 2 { - runtimeID = packageManifest.ID - } - if runtimeID == "" { - return errors.New("local package has no runtime driver id") - } - version := "" - if packageManifest.Version != nil { - version = packageManifest.Version.String() - } - sourcePath, absErr := filepath.Abs(downloaded.Name()) - if absErr != nil { - return fmt.Errorf("could not resolve local package path: %w", absErr) - } - expected = config.ExpectedPackageMetadata{ - ID: runtimeID, Version: version, Platform: config.PlatformTuple(), - SourceType: "local", SourceIdentity: sourcePath, - } - } - if !m.NoVerify { - verify = func(stagingDir string, manifest config.Manifest) error { - return dbc.VerifyPackageSignature(stagingDir, manifest) - } - } - manifest, err := config.InstallPackage(m.cfg, runtimeID, downloaded, expected, config.InstallOptions{ - Verify: verify, - }) - if err != nil { - if isPackageVerificationFailure(err) { - return installVerificationFailedMsg{err: packageVerificationError(err)} - } - return packageVerificationError(err) - } - return manifest - } -} - -func expectedRegistryPackageMetadata(pkg dbc.PkgInfo) (config.ExpectedPackageMetadata, bool, error) { - hasHash := pkg.ArtifactHash != "" - hasSize := pkg.ArtifactSize != nil - if hasHash != hasSize { - return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata must include both archive hash and size") - } - if pkg.Version == nil { - return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata is missing its version") - } - if strings.TrimSpace(pkg.PlatformTuple) == "" { - return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata is missing its platform") - } - if pkg.Driver.Registry == nil || pkg.Driver.Registry.BaseURL == nil { - return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata is missing its source identity") - } - if strings.TrimSpace(pkg.Driver.Path) == "" { - return config.ExpectedPackageMetadata{}, false, 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 - expected.ArchiveSize = *pkg.ArtifactSize - } - return expected, hasHash, nil -} - func packageVerificationError(err error) error { const prefix = "package verification failed: " if strings.HasPrefix(err.Error(), prefix) { @@ -604,53 +535,70 @@ 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 + if msg.result.Installed == nil { + return m.fail(errors.New("package ensure returned no installed driver registration")) + } + 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)) + } + 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 localDrv + return m, tea.Quit } - case dbc.PkgInfo: - m.DriverPackage = msg - di, err := config.GetDriver(m.cfg, m.Driver) - if err == nil { - m.conflictingInfo = di + if msg.result.Manifest == nil { + return m.fail(errors.New("package ensure returned no install manifest")) } - - 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.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 + } } - m.Driver = msg.ID - - m.state = stVerifying - m.postInstallMessage = strings.Join(msg.PostInstall.Messages, "\n") + m.postInstallMessage = strings.Join(msg.result.Manifest.PostInstall.Messages, "\n") + m.state = stDone m = m.addEvent("extract.complete") m = m.addEvent("verify.start") - return m, func() tea.Msg { - return writeDriverManifestMsg{DriverInfo: msg.DriverInfo} - } - case writeDriverManifestMsg: - m.state = stDone - m.installedDriverInfo = msg.DriverInfo m = m.addEvent("verify.complete") m = m.addEvent("manifest.create") return m, tea.Quit - case installVerificationFailedMsg: - m = m.addEvent("extract.complete") - m = m.addEvent("verify.start") - return m.fail(msg.err) + 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) } diff --git a/cmd/dbc/install_source.go b/cmd/dbc/install_source.go new file mode 100644 index 00000000..f153a35a --- /dev/null +++ b/cmd/dbc/install_source.go @@ -0,0 +1,230 @@ +// 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) + } + inspection, err := os.Open(m.localPackagePath) + if err != nil { + return installItem{}, err + } + metadata, inspectErr := config.InspectPackageMetadata(inspection) + closeErr := inspection.Close() + if inspectErr != nil { + return installItem{}, inspectErr + } + if closeErr != nil { + return installItem{}, closeErr + } + driverID := legacyLocalPackageID(m.localPackagePath, platform) + if metadata.PackageVersion == 2 { + driverID = metadata.ID + } + 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 closeDirectInstallArchive(item *installItem) error { + if item == nil || item.Archive == nil { + return nil + } + err := item.Archive.Close() + item.Archive = 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 bcf6dff0..b2f466bb 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -18,10 +18,13 @@ import ( "archive/tar" "bytes" "compress/gzip" + "context" "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" + "io" "net/url" "os" "path/filepath" @@ -29,12 +32,54 @@ import ( "strings" "testing" - "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" ) +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", PackageVersion: 2, + 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()) @@ -374,6 +419,259 @@ func (suite *SubcommandTestSuite) TestInstallLocalPackageFixUpName() { suite.driverIsInstalled("test-driver-1", true) } +func (suite *SubcommandTestSuite) TestInstallPackslipDirectUsesSignedIDWithoutRegistryLookup() { + archive := packageV2ArchiveForInstall(suite.T(), "signed-driver-id", "1.2.3", config.PlatformTuple()) + 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) TestInstallPackslipDirectFailsClosedBeforeRuntimeMutation() { + tests := []struct { + name string + packageID string + badHash bool + hostReq bool + wantError string + wantFetchCall int + }{ + {name: "package ID mismatch", packageID: "different-id", wantError: "package id mismatch", wantFetchCall: 1}, + {name: "signed archive hash mismatch despite skip flags", packageID: "bad-hash-id", badHash: true, wantError: "does not match expected hash", 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() { + archiveID := tc.packageID + if tc.name == "package ID mismatch" { + archiveID = "archive-id" + } + archive := packageV2ArchiveForInstall(suite.T(), archiveID, "1.2.3", config.PlatformTuple()) + 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", + "github.com/example/repo=latest", + "github.com/example/repo=v1.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) TestInstallLocalPackageV2UsesMetadataIDAndDoesNotReadProjectFiles() { + archive := packageV2ArchiveForInstall(suite.T(), "declared-driver-id", "1.2.3", config.PlatformTuple()) + 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("misleading-name.tgz", 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: "misleading-name.tgz", 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("declared-driver-id", status.Driver) + suite.Equal(&jsonschema.InstallSource{Type: "path", Reference: "misleading-name.tgz"}, status.Source) + suite.driverIsInstalled("declared-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 TestParsePackslipInstallArgumentUsesCanonicalExactVersion(t *testing.T) { + project, version, matched, err := parsePackslipInstallArgument("github.com/owner/repo/tool=1.2.3-rc.1+build.5") + if err != nil || !matched || project != "github.com/owner/repo/tool" || version != "1.2.3-rc.1+build.5" { + t.Fatalf("valid Packslip direct argument parsed as %q, %q, %v, %v", project, version, matched, err) + } + for _, input := range []string{ + "github.com/owner/repo>=1.2.3", + "github.com/owner/repo=1.2", + "github.com/owner/repo=01.2.3", + "github.com/owner/repo=v1.2.3", + "github.com/owner/repo=latest", + } { + _, _, matched, err := parsePackslipInstallArgument(input) + if !matched || err == nil { + t.Errorf("invalid Packslip direct argument %q was not rejected before registry fallback", input) + } + } + 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 := packageV2ArchiveForInstall(t, "local-driver", "1.2.3", config.PlatformTuple()) + 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) + } + _, _ = makeSyncPackageV2Archive(t, path, "local-driver", "1.2.3", config.PlatformTuple()) + executor, err := model.newDirectInstallExecutor(item) + if err != nil { + t.Fatal(err) + } + if err := executor.prepareItem(context.Background(), &item); err == nil || !strings.Contains(err.Error(), "does not match expected hash") { + 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}. @@ -561,198 +859,6 @@ func openInstallArchive(t *testing.T, data []byte) *os.File { return f } -func TestInstallSignatureFailurePreservesExistingInstallation(t *testing.T) { - oldArchive, err := os.ReadFile(filepath.Join("testdata", "test-driver-1.tar.gz")) - if err != nil { - t.Fatal(err) - } - oldExpected := config.ExpectedPackageMetadata{ - ID: "test-driver-1", Version: "1.0.0", Platform: config.PlatformTuple(), - SourceType: "registry", SourceIdentity: testRegistry.BaseURL.String(), - } - root := t.TempDir() - cfg := config.Config{Level: config.ConfigEnv, Location: root} - oldManifest, err := config.InstallPackage(cfg, "test-driver-1", openInstallArchive(t, oldArchive), oldExpected, config.InstallOptions{ - Verify: func(stagingDir string, manifest config.Manifest) error { - return dbc.VerifyPackageSignature(stagingDir, manifest) - }, - }) - if err != nil { - t.Fatal(err) - } - oldManifestBytes, err := os.ReadFile(filepath.Join(root, "test-driver-1.toml")) - if err != nil { - t.Fatal(err) - } - oldLibraryPath := oldManifest.Driver.Shared.Get(config.PlatformTuple()) - oldLibraryBytes, err := os.ReadFile(oldLibraryPath) - if err != nil { - t.Fatal(err) - } - - badArchive, err := os.ReadFile(filepath.Join("testdata", "test-driver-no-sig.tar.gz")) - if err != nil { - t.Fatal(err) - } - model := progressiveInstallModel{ - Driver: "test-driver-1", - cfg: cfg, - DriverPackage: dbc.PkgInfo{ - Driver: dbc.Driver{Path: "test-driver-1", Registry: &testRegistry}, - Version: semver.MustParse("1.1.0"), - PlatformTuple: config.PlatformTuple(), - }, - } - _, install := model.startInstalling(openInstallArchive(t, badArchive)) - message := install() - verificationFailure, ok := message.(installVerificationFailedMsg) - if !ok { - t.Fatalf("startInstalling returned %T, want installVerificationFailedMsg", message) - } - installErr := verificationFailure.err - if !strings.Contains(installErr.Error(), "signature file 'test-driver-1-not-valid.so.sig' for driver is missing") { - t.Fatalf("unexpected install error: %v", installErr) - } - manifestBytes, err := os.ReadFile(filepath.Join(root, "test-driver-1.toml")) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(oldManifestBytes, manifestBytes) { - t.Fatal("runtime manifest changed after signature failure") - } - libraryBytes, err := os.ReadFile(oldLibraryPath) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(oldLibraryBytes, libraryBytes) { - t.Fatal("installed library changed after signature failure") - } -} - -func TestStartInstallingRegistryPackageV2RequiresAndUsesMetadata(t *testing.T) { - archive := packageV2ArchiveForInstall(t, "example", "1.2.3", config.PlatformTuple()) - digest := sha256.Sum256(archive) - size := int64(len(archive)) - baseURL, err := url.Parse("https://registry.example.test") - if err != nil { - t.Fatal(err) - } - root := t.TempDir() - model := progressiveInstallModel{ - Driver: "example", - cfg: config.Config{Level: config.ConfigEnv, Location: root}, - DriverPackage: dbc.PkgInfo{ - Driver: dbc.Driver{Path: "example", Registry: &dbc.Registry{BaseURL: baseURL}}, - Version: semver.MustParse("1.2.3"), - PlatformTuple: config.PlatformTuple(), - ArtifactHash: "sha256:" + hex.EncodeToString(digest[:]), - ArtifactSize: &size, - }, - } - _, install := model.startInstalling(openInstallArchive(t, archive)) - message := install() - manifest, ok := message.(config.Manifest) - if !ok { - t.Fatalf("startInstalling returned %T, want config.Manifest", message) - } - if manifest.PackageVersion != 2 { - t.Fatalf("package marker = %d, want 2", manifest.PackageVersion) - } - if err := dbc.VerifyPackageSignature("", manifest); err != nil { - t.Fatalf("package v2 must not require legacy PGP signature: %v", err) - } - var receipt config.InstallReceipt - receiptData, err := os.ReadFile(filepath.Join(filepath.Dir(manifest.Driver.Shared.Get(config.PlatformTuple())), "dbc-install-receipt.json")) - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(receiptData, &receipt); err != nil { - t.Fatal(err) - } - if receipt.SourceType != "registry" || receipt.SourceIdentity != baseURL.String() { - t.Fatalf("receipt source = %q %q, want registry %q", receipt.SourceType, receipt.SourceIdentity, baseURL) - } - if receipt.ArchiveHash != "sha256:"+hex.EncodeToString(digest[:]) || receipt.ArchiveSize != size { - t.Fatalf("receipt archive metadata = %q/%d, want %q/%d", receipt.ArchiveHash, receipt.ArchiveSize, "sha256:"+hex.EncodeToString(digest[:]), size) - } -} - -func TestStartInstallingRegistryV2WithoutArchiveMetadataIsRejected(t *testing.T) { - archive := packageV2ArchiveForInstall(t, "example", "1.2.3", config.PlatformTuple()) - root := t.TempDir() - model := progressiveInstallModel{ - Driver: "example", - cfg: config.Config{Level: config.ConfigEnv, Location: root}, - DriverPackage: dbc.PkgInfo{ - Driver: dbc.Driver{Path: "example", Registry: &dbc.Registry{BaseURL: must(url.Parse("https://registry.example.test"))}}, - Version: semver.MustParse("1.2.3"), - PlatformTuple: config.PlatformTuple(), - }, - } - _, install := model.startInstalling(openInstallArchive(t, archive)) - message := install() - installErr, ok := message.(error) - if !ok || !strings.Contains(installErr.Error(), "requires archive hash and size metadata") { - t.Fatalf("startInstalling returned %v, want missing metadata error", message) - } - if _, err := os.Stat(filepath.Join(root, "example.toml")); !os.IsNotExist(err) { - t.Fatalf("runtime manifest was published despite missing registry metadata: %v", err) - } -} - -func TestStartInstallingLocalV2UsesPackageMetadataID(t *testing.T) { - archive := packageV2ArchiveForInstall(t, "declared-driver", "1.2.3", config.PlatformTuple()) - root := t.TempDir() - packagePath := filepath.Join(root, "filename-driver.tar.gz") - if err := os.WriteFile(packagePath, archive, 0o600); err != nil { - t.Fatal(err) - } - downloaded, err := os.Open(packagePath) - if err != nil { - t.Fatal(err) - } - model := progressiveInstallModel{ - Driver: packagePath, - isLocal: true, - localPackagePath: packagePath, - cfg: config.Config{Level: config.ConfigEnv, Location: root}, - } - _, install := model.startInstalling(downloaded) - message := install() - manifest, ok := message.(config.Manifest) - if !ok { - t.Fatalf("startInstalling returned %T, want config.Manifest", message) - } - if manifest.ID != "declared-driver" { - t.Fatalf("installed runtime ID = %q, want package metadata ID", manifest.ID) - } - if _, err := os.Stat(filepath.Join(root, "declared-driver.toml")); err != nil { - t.Fatalf("runtime manifest for package metadata ID was not registered: %v", err) - } - if _, err := os.Stat(filepath.Join(root, "filename-driver.toml")); !os.IsNotExist(err) { - t.Fatalf("filename-derived runtime manifest was unexpectedly registered: %v", err) - } -} - -func TestStartInstallingRejectsPartialRegistryArchiveMetadata(t *testing.T) { - model := progressiveInstallModel{ - Driver: "example", - DriverPackage: dbc.PkgInfo{ - Driver: dbc.Driver{Path: "example", Registry: &dbc.Registry{BaseURL: must(url.Parse("https://registry.example.test"))}}, - Version: semver.MustParse("1.2.3"), - PlatformTuple: config.PlatformTuple(), - ArtifactHash: "sha256:" + strings.Repeat("0", 64), - }, - cfg: config.Config{Level: config.ConfigEnv, Location: t.TempDir()}, - } - _, install := model.startInstalling(openInstallArchive(t, packageV2ArchiveForInstall(t, "example", "1.2.3", config.PlatformTuple()))) - message := install() - installErr, ok := message.(error) - if !ok || !strings.Contains(installErr.Error(), "must include both archive hash and size") { - t.Fatalf("startInstalling returned %v, want partial metadata error", message) - } -} - func (suite *SubcommandTestSuite) TestInstallJSON() { m := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel, Json: true}. GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) @@ -773,6 +879,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() { @@ -848,17 +955,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) @@ -866,33 +971,19 @@ 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]) - break - } - } - 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") + 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())) } 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 index 3080365f..f6f0114f 100644 --- a/cmd/dbc/package_executor.go +++ b/cmd/dbc/package_executor.go @@ -235,8 +235,12 @@ func (e *packageExecutor) openRegistryArtifact(ctx context.Context, item install 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}, + Driver: dbc.Driver{Path: item.Release.DriverID, Title: item.Release.DriverID, Registry: &dbc.Registry{BaseURL: registryURL}}, Version: version, PlatformTuple: item.Platform, Path: artifactURL, diff --git a/internal/jsonschema/schema.go b/internal/jsonschema/schema.go index 22e1737d..014cc76f 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 diff --git a/internal/jsonschema/schema_test.go b/internal/jsonschema/schema_test.go index 7c2d3087..37f5caed 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) } From f12184cca272227ebfa31b9f8ddd263a28169e56 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 11:34:05 +0000 Subject: [PATCH 086/115] test: cover source replay platform boundaries --- .github/workflows/wasm.yml | 5 ++ cmd/dbc/sync_test.go | 120 +++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) 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/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index d89736f2..b768f4cd 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -2549,6 +2549,126 @@ func (suite *SubcommandTestSuite) TestSyncPathInstallFailureConvergesFromCandida suite.Equal("1.0.0", installed.Version.String()) } +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)) + makeSyncPackageV2Archive(suite.T(), archivePath, "test-driver-1", "1.2.3", config.PlatformTuple()) + 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, options config.InstallOptions, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + ensureCalls++ + result, err := config.EnsurePackage(ctx, cfg, driver, expected, options, 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, options config.InstallOptions, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + ensureCalls++ + return config.EnsurePackage(ctx, cfg, driver, expected, options, callbacks) + } + suite.Contains(suite.runCmdErr(third), "does not match expected hash") + 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) TestSyncReceiptRepairUsesSelectedMultiPathRegistrationRoot() { tests := []struct { name string From 4332790c4f6ccad72c2e6eacb9a2c4b9e68b8498 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 12:32:20 +0000 Subject: [PATCH 087/115] fix(add): honor explicitly declared registry sources --- cmd/dbc/add.go | 6 +- cmd/dbc/add_test.go | 137 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 141 insertions(+), 2 deletions(-) diff --git a/cmd/dbc/add.go b/cmd/dbc/add.go index eca11073..b3590196 100644 --- a/cmd/dbc/add.go +++ b/cmd/dbc/add.go @@ -320,7 +320,11 @@ func (m addModel) Init() tea.Cmd { return fmt.Errorf("path resolver returned an invalid package: %w", err) } default: - drv, err := findDriver(spec.Name, drivers) + requirement, err := requirementForDriverSpec(spec.Name, current) + if err != nil { + return err + } + drv, err := registryDriverForRequirement(requirement, drivers, nil) if err != nil { return wrapWithRegistryContext(err, registryErrors) } diff --git a/cmd/dbc/add_test.go b/cmd/dbc/add_test.go index fd5d26a7..85948098 100644 --- a/cmd/dbc/add_test.go +++ b/cmd/dbc/add_test.go @@ -20,6 +20,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "os" "path/filepath" "strings" @@ -188,7 +189,11 @@ func TestAddUpdatingDriverPreservesSource(t *testing.T) { runAdd := func(cmd AddCmd) DriversList { t.Helper() - msg := runTeaCmdToCompletion(t, cmd.GetModelCustom(testBaseModel()).(interface { + 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) })) @@ -218,6 +223,136 @@ func TestAddUpdatingDriverPreservesSource(t *testing.T) { 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") From 259ff78f8442210fad78f507572d73a19cbe960c Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 12:48:21 +0000 Subject: [PATCH 088/115] refactor(cli): remove unused project package resolver --- cmd/dbc/driver_list.go | 55 ----------------------- cmd/dbc/driver_list_test.go | 66 +++++++-------------------- cmd/dbc/registry_wiring_test.go | 80 --------------------------------- drivers.go | 3 -- source_test.go | 28 ------------ 5 files changed, 15 insertions(+), 217 deletions(-) diff --git a/cmd/dbc/driver_list.go b/cmd/dbc/driver_list.go index 77b7bdbb..a21557f1 100644 --- a/cmd/dbc/driver_list.go +++ b/cmd/dbc/driver_list.go @@ -15,14 +15,12 @@ package main import ( - "context" "errors" "fmt" "os" "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" ) @@ -239,56 +237,3 @@ type driverSpec struct { Version *semver.Constraints `toml:"version"` Source *dbc.DriverSource `toml:"source,omitempty"` } - -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) - } - if err := m.validateSources(); 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) - } - pkg.Source = spec.Source - - pkgs = append(pkgs, pkg) - } - - return pkgs, nil -} diff --git a/cmd/dbc/driver_list_test.go b/cmd/dbc/driver_list_test.go index 09e50413..94608402 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,55 +30,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestUnmarshalDriverList(t *testing.T) { - tests := []struct { - name string - contents string - expected []dbc.PkgInfo - err error - }{ - {"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}, - } - - 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()) - return - } - - 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 TestDriverSourceProjectConfigRoundTrip(t *testing.T) { tests := []struct { name string @@ -273,6 +222,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. diff --git a/cmd/dbc/registry_wiring_test.go b/cmd/dbc/registry_wiring_test.go index 338a5a2e..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 diff --git a/drivers.go b/drivers.go index 18461ffd..a03b9614 100644 --- a/drivers.go +++ b/drivers.go @@ -262,9 +262,6 @@ type PkgInfo struct { Driver Driver Version *semver.Version PlatformTuple string - // Source preserves the source explicitly declared by a project config. - // A nil value means the legacy/default registry source was requested. - Source *DriverSource `json:"source,omitempty"` Path *url.URL // ArtifactHash is the optional digest of the downloaded archive. diff --git a/source_test.go b/source_test.go index d467e077..e166464b 100644 --- a/source_test.go +++ b/source_test.go @@ -15,10 +15,8 @@ package dbc import ( - "encoding/json" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -61,29 +59,3 @@ func TestDriverSourceValidation(t *testing.T) { }) } } - -func TestDriverSourceJSONRoundTrip(t *testing.T) { - sources := []DriverSource{ - {Type: DriverSourceRegistry, URL: "https://registry.example.test"}, - {Type: DriverSourcePackslip, Project: "github.com/owner/project"}, - {Type: DriverSourcePath, Path: "../packages/driver.tar.gz"}, - } - for _, source := range sources { - t.Run(string(source.Type), func(t *testing.T) { - data, err := json.Marshal(PkgInfo{Source: &source}) - require.NoError(t, err) - - var output PkgInfo - require.NoError(t, json.Unmarshal(data, &output)) - require.NotNil(t, output.Source) - assert.Equal(t, source, *output.Source) - }) - } - - legacy := PkgInfo{} - legacyData, err := json.Marshal(legacy) - require.NoError(t, err) - var legacyRoundTrip PkgInfo - require.NoError(t, json.Unmarshal(legacyData, &legacyRoundTrip)) - assert.Nil(t, legacyRoundTrip.Source, "omitted legacy source must remain distinguishable from explicit registry") -} From 3a2ee1340ae6a6e495efd93502691b0cefc7d31d Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 12:56:55 +0000 Subject: [PATCH 089/115] docs(packslip): mark exact versions as PoC limitation --- cmd/dbc/driver_list_test.go | 2 ++ cmd/dbc/sync.go | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/cmd/dbc/driver_list_test.go b/cmd/dbc/driver_list_test.go index 94608402..8eea5671 100644 --- a/cmd/dbc/driver_list_test.go +++ b/cmd/dbc/driver_list_test.go @@ -117,6 +117,8 @@ func TestPackslipSourceRequiresExactStrictSemVer(t *testing.T) { {name: "v prefix", version: "v1.2.3", wantErr: true}, {name: "abbreviated version", version: "1.2", wantErr: true}, {name: "leading zero", version: "01.2.3", wantErr: true}, + // This pins the current PoC limitation only; omitted Packslip versions + // should be supported before GA by resolving to an exact release. {name: "missing version", version: "", wantErr: true}, } diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 24ccb6d9..3b3f1965 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -504,6 +504,12 @@ func requirementForDriverSpec(name string, spec driverSpec) (sourceresolution.Re 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()) From 8e232393822671c405aac924af41d74cbf8d5b47 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 14:53:06 +0000 Subject: [PATCH 090/115] fix(ci): restore cross-platform test portability --- .typos.toml | 2 +- cmd/dbc/install_test.go | 1 + cmd/dbc/registry_test.go | 23 +++++++++--- cmd/dbc/sync_test.go | 47 ++++++++++++++++++++++--- config/driver.go | 10 ++++-- config/driver_test.go | 2 ++ config/install_transaction_test.go | 10 +++--- config/package_archive_test.go | 22 ++++++++++-- internal/packslip/dbc_extension_test.go | 4 +-- packages/npm-wasm/test/smoke.cjs | 17 +++++---- packages/npm-wasm/test/worker.test.cjs | 17 +++++---- 11 files changed, 120 insertions(+), 35 deletions(-) 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/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index b2f466bb..97edd02c 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -850,6 +850,7 @@ func openInstallArchive(t *testing.T, data []byte) *os.File { if err != nil { t.Fatal(err) } + t.Cleanup(func() { _ = f.Close() }) if _, err := f.Write(data); err != nil { t.Fatal(err) } diff --git a/cmd/dbc/registry_test.go b/cmd/dbc/registry_test.go index d4233dee..91e1b92e 100644 --- a/cmd/dbc/registry_test.go +++ b/cmd/dbc/registry_test.go @@ -21,7 +21,6 @@ import ( "context" "errors" "os" - "path/filepath" "testing" "time" @@ -94,6 +93,23 @@ 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"}. GetModelCustom(testBaseModel()) @@ -109,9 +125,7 @@ func (s *RegistryTestSuite) TestInstallDriver() { s.Require().NoError(err) s.Equal("1.1.0", val) - val, _, err = k.GetStringValue("driver") - s.Require().NoError(err) - s.Equal(filepath.Join(s.cfgUserPath, "test-driver-1.1", "test-driver-1-not-valid.so"), val) + s.assertRegisteredPackagePath() } func (s *RegistryTestSuite) TestPartialReinstallDriver() { @@ -128,6 +142,7 @@ func (s *RegistryTestSuite) TestPartialReinstallDriver() { 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/sync_test.go b/cmd/dbc/sync_test.go index b768f4cd..f3bb62c2 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -102,7 +102,7 @@ driver = "driver.so" func makeSyncPackslipRelease(id, version, url, hash string, size int64) resolution.ResolvedRelease { primary := testTarget(config.PlatformTuple()) - secondary := resolution.Target{OS: "macos", Arch: "arm64"} + secondary := testTarget(differentTestPlatformTuple(config.PlatformTuple())) otherSize := int64(9) return resolution.ResolvedRelease{ DriverID: id, @@ -120,6 +120,35 @@ func makeSyncPackslipRelease(id, version, url, hash string, size int64) resoluti } } +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") +} + +func TestDifferentTestPlatformTupleAvoidsDuplicateTargets(t *testing.T) { + for _, host := range []string{"linux_amd64", "linux_arm64", "macos_amd64", "macos_arm64", "windows_amd64"} { + t.Run(host, func(t *testing.T) { + got, err := resolution.TargetFromPlatformTuple(differentTestPlatformTuple(host)) + require.NoError(t, err) + want, err := resolution.TargetFromPlatformTuple(host) + require.NoError(t, err) + assert.NotEqual(t, want, got, "secondary artifact target must differ from the current host") + }) + } +} + func TestSyncProgressPercentTracksCompletedItems(t *testing.T) { if got := syncProgressPercent(0, 2); got != 0.5 { t.Fatalf("first completed item progress = %v, want 0.5", got) @@ -238,9 +267,19 @@ func TestFreshPackslipResolutionSnapshotsAndReplaysWithoutDiscovery(t *testing.T assert.Equal(t, version, locked.Version.String()) assert.Equal(t, release.Evidence[0].Hash, locked.Evidence[0].Hash) require.Len(t, locked.Artifacts, 2) - assert.Equal(t, 2, locked.Artifacts[0].PackageVersion) - assert.Equal(t, release.Artifacts[1].Location, locked.Artifacts[1].Location) - assert.Equal(t, "tgz", locked.Artifacts[0].Format) + 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, 2, primaryArtifact.PackageVersion) + 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 diff --git a/config/driver.go b/config/driver.go index 346ed244..ee5d3946 100644 --- a/config/driver.go +++ b/config/driver.go @@ -214,6 +214,11 @@ func removeManifestSymlink(filePath, driverID string) { 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 @@ -229,8 +234,9 @@ func removeManifestSymlink(filePath, driverID string) { // 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. - isTargetRegistration = targetsExpectedManifest(filepath.Join(parentDir, target)) + // 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 } diff --git a/config/driver_test.go b/config/driver_test.go index 56aa14c0..d3225443 100644 --- a/config/driver_test.go +++ b/config/driver_test.go @@ -140,6 +140,7 @@ func TestRemoveManifestSymlinkOnlyRemovesTargetRegistration(t *testing.T) { 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") @@ -195,6 +196,7 @@ func TestRemoveManifestSymlinkDoesNotResolveForeignTargetFromWorkingDirectory(t 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() diff --git a/config/install_transaction_test.go b/config/install_transaction_test.go index 6a899223..0d0d4fc7 100644 --- a/config/install_transaction_test.go +++ b/config/install_transaction_test.go @@ -1121,12 +1121,10 @@ func TestUninstallDriverRetainsUnprovenLegacyDirectories(t *testing.T) { } func TestRelativeNestedEnvironmentInstallUpdateUninstall(t *testing.T) { - absRoot := filepath.Join(t.TempDir(), "nested", "drivers") - workingDirectory, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - relativeRoot, err := filepath.Rel(workingDirectory, absRoot) + root := t.TempDir() + t.Chdir(root) + absRoot := filepath.Join(root, "nested", "drivers") + relativeRoot, err := filepath.Rel(root, absRoot) if err != nil { t.Fatal(err) } diff --git a/config/package_archive_test.go b/config/package_archive_test.go index f60ab7ce..0626c6fa 100644 --- a/config/package_archive_test.go +++ b/config/package_archive_test.go @@ -91,6 +91,7 @@ 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) @@ -98,6 +99,23 @@ func openPackageArchive(t *testing.T, data []byte) *os.File { return file } +func differentPackagePlatform() string { + switch config.PlatformTuple() { + case "linux_amd64": + return "linux_arm64" + case "linux_arm64": + return "linux_amd64" + case "macos_amd64": + return "macos_arm64" + case "macos_arm64": + return "macos_amd64" + case "windows_amd64": + return "linux_amd64" + default: + return "linux_amd64" + } +} + func expectedPackage(id, version, platform, source string, archive []byte) config.ExpectedPackageMetadata { digest := sha256.Sum256(archive) return config.ExpectedPackageMetadata{ @@ -479,7 +497,7 @@ func TestInstallPackageArchiveChecksMetadataAndDigests(t *testing.T) { }{ {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: "platform mismatch", mutate: func(e *config.ExpectedPackageMetadata) { e.Platform = "macos_arm64" }}, + {name: "platform mismatch", mutate: func(e *config.ExpectedPackageMetadata) { e.Platform = differentPackagePlatform() }}, {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++ }}, } @@ -607,7 +625,7 @@ func TestValidatePackageRejectsMetadataAndVerifierFailures(t *testing.T) { }{ {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 = "macos_arm64" }}, + {name: "platform", mutate: func(expected *config.ExpectedPackageMetadata) { expected.Platform = differentPackagePlatform() }}, {name: "archive hash", mutate: func(expected *config.ExpectedPackageMetadata) { expected.ArchiveHash = "sha256:" + strings.Repeat("0", 64) }}, diff --git a/internal/packslip/dbc_extension_test.go b/internal/packslip/dbc_extension_test.go index 91585545..3b000a00 100644 --- a/internal/packslip/dbc_extension_test.go +++ b/internal/packslip/dbc_extension_test.go @@ -24,7 +24,7 @@ func TestDBCReleaseExtensionRequiresExactDeclaration(t *testing.T) { }{ {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: "unexpected end of JSON input"}, + {name: "malformed", raw: json.RawMessage("{"), want: "invalid packslip predicate.extensions.dbc declaration"}, {name: "array", raw: json.RawMessage("[]"), want: "non-null object"}, {name: "unknown field", raw: json.RawMessage(`{"schema_version":1,"driver_id":"driver","future":true}`), want: "unknown field"}, {name: "unknown schema", raw: json.RawMessage(`{"schema_version":2,"driver_id":"driver"}`), want: "unsupported dbc release extension schema_version"}, @@ -59,7 +59,7 @@ func TestDBCArtifactExtensionRequiresExactPackageMarker(t *testing.T) { want string }{ {name: "null", raw: json.RawMessage("null"), want: "non-null object"}, - {name: "malformed", raw: json.RawMessage("{"), want: "unexpected end of JSON input"}, + {name: "malformed", raw: json.RawMessage("{"), want: "invalid packslip artifact"}, {name: "array", raw: json.RawMessage("[]"), want: "non-null object"}, {name: "unknown field", raw: json.RawMessage(`{"package_version":2,"future":true}`), want: "unknown field"}, {name: "missing package version", raw: json.RawMessage(`{}`), want: "package_version 0"}, 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(); From 40eafd2226d26900541e0eabb227298216fc5f49 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 15:24:23 +0000 Subject: [PATCH 091/115] fix(ci): handle remaining cross-platform paths --- cmd/dbc/install_test.go | 12 +- cmd/dbc/registry_test.go | 10 +- cmd/dbc/sync_test.go | 33 +++--- packages/npm-wasm/boot.cjs | 92 ++++++++++++++- packages/npm-wasm/index.cjs | 28 ++++- packages/npm-wasm/test/normalize.test.cjs | 131 ++++++++++++++++++++-- 6 files changed, 258 insertions(+), 48 deletions(-) diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index 97edd02c..54642c3f 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -134,18 +134,18 @@ 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)) - installed, err := config.GetDriver(config.Config{Level: config.ConfigEnv, Location: suite.Dir()}, "test-driver-1") + 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) @@ -153,7 +153,7 @@ func (suite *SubcommandTestSuite) TestReinstallUpdateVersion() { relDir := filepath.ToSlash(filepath.Dir(relLibrary)) relLibrary = filepath.ToSlash(relLibrary) suite.Equal([]string{filepath.ToSlash(filepath.Join(relDir, "dbc-install-receipt.json")), - relLibrary, relLibrary + ".sig", "test-driver-1.toml"}, suite.getFilesInTempDir()) + relLibrary, relLibrary + ".sig", "test-driver-1.toml"}, suite.getFilesInDir(suite.Dir())) } func (suite *SubcommandTestSuite) TestReinstallDowngradeVersion() { diff --git a/cmd/dbc/registry_test.go b/cmd/dbc/registry_test.go index 91e1b92e..b56c930f 100644 --- a/cmd/dbc/registry_test.go +++ b/cmd/dbc/registry_test.go @@ -50,12 +50,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 } @@ -111,7 +113,7 @@ func (s *RegistryTestSuite) assertRegisteredPackagePath() { } 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) @@ -130,7 +132,7 @@ func (s *RegistryTestSuite) TestInstallDriver() { 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) @@ -138,7 +140,7 @@ 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) diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index f3bb62c2..7582a722 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -317,7 +317,13 @@ func TestFreshPackslipResolutionSnapshotsAndReplaysWithoutDiscovery(t *testing.T require.NoError(t, err) defer closePreparedArchives(replayed.items) assert.Equal(t, 1, downloadCalls) - assert.Equal(t, prepared.lock.Drivers[0].Artifacts, replayed.lock.Drivers[0].Artifacts) + 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 TestPackslipResolverMismatchIsRejectedBeforeCandidateLock(t *testing.T) { @@ -1519,9 +1525,8 @@ func (suite *SubcommandTestSuite) TestSyncPrepareFailurePreservesRuntimeAndLock( suite.Equal(oldLock, newLock) suite.Equal(2, len(downloaded)) suite.Equal(2, downloadCalls) - for driver, archive := range archives { - _, err = archive.Stat() - suite.ErrorIs(err, os.ErrClosed, "prepared archive for %s must be closed", driver) + for _, archive := range archives { + assertFileClosed(suite.T(), archive) } } @@ -1550,8 +1555,7 @@ func (suite *SubcommandTestSuite) TestSyncCandidateLockFailureDoesNotInstall() { newLock, err := os.ReadFile(lockPath) suite.Require().NoError(err) suite.Equal(oldLock, newLock) - _, err = downloadedArchive.Stat() - suite.ErrorIs(err, os.ErrClosed) + assertFileClosed(suite.T(), downloadedArchive) } func (suite *SubcommandTestSuite) TestSyncInstallFailureKeepsCompleteCandidateLock() { @@ -1560,7 +1564,7 @@ func (suite *SubcommandTestSuite) TestSyncInstallFailureKeepsCompleteCandidateLo suite.Require().NoError(os.WriteFile(path, []byte("[drivers]\n[drivers.test-driver-1]\n[drivers.test-driver-no-sig]\n"), 0644)) var preparedPaths []string var downloadedDrivers []string - model := SyncCmd{Path: path, NoVerify: true}.GetModelCustom(baseModel{ + model := SyncCmd{Path: path, Level: suite.configLevel, NoVerify: true}.GetModelCustom(baseModel{ getDriverRegistry: getTestDriverRegistry, downloadPkg: func(pkg dbc.PkgInfo) (*os.File, error) { var source string @@ -1599,7 +1603,7 @@ func (suite *SubcommandTestSuite) TestSyncInstallFailureKeepsCompleteCandidateLo suite.Len(lock.lockinfo, 2) installedCount := 0 for _, name := range []string{"test-driver-1", "test-driver-no-sig"} { - if _, err := config.GetDriver(config.Config{Level: config.ConfigEnv, Location: suite.Dir()}, name); err == nil { + if _, err := config.GetDriver(config.Get()[suite.configLevel], name); err == nil { installedCount++ } } @@ -1616,7 +1620,7 @@ func (suite *SubcommandTestSuite) TestSyncInstallFailureKeepsCompleteCandidateLo registryCalls, downloadCalls := 0, 0 var observedPackage dbc.PkgInfo - convergenceModel := SyncCmd{Path: path, NoVerify: true}.GetModelCustom(baseModel{ + 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") @@ -1643,7 +1647,7 @@ func (suite *SubcommandTestSuite) TestSyncInstallFailureKeepsCompleteCandidateLo suite.Require().NotNil(observedPackage.ArtifactSize) suite.Equal(*lockedArtifact.Size, *observedPackage.ArtifactSize) for _, name := range []string{"test-driver-1", "test-driver-no-sig"} { - _, err := config.GetDriver(config.Config{Level: config.ConfigEnv, Location: suite.Dir()}, name) + _, err := config.GetDriver(config.Get()[suite.configLevel], name) suite.NoError(err) } convergedLock, err := os.ReadFile(lockPath) @@ -3169,12 +3173,10 @@ func (suite *SubcommandTestSuite) TestSyncCancellationWaitsForWorkerCleanup() { suite.Equal(1, errorEnvelopes, "cancel should emit one terminal JSON error envelope") waitSyncTestSignal(suite.T(), model.worker.done) if preparedArchive != nil { - _, statErr = preparedArchive.Stat() - suite.ErrorIs(statErr, os.ErrClosed) + assertFileClosed(suite.T(), preparedArchive) } if stage == "download" { - _, statErr = downloadedArchive.Stat() - suite.ErrorIs(statErr, os.ErrClosed) + assertFileClosed(suite.T(), downloadedArchive) } projectLock, lockErr := fslock.Acquire(projectLockPath, time.Second) suite.NoError(lockErr) @@ -3237,8 +3239,7 @@ func (suite *SubcommandTestSuite) TestSyncProgramContextCancellationJoinsWorker( waitSyncTestSignal(suite.T(), run.done) waitSyncTestSignal(suite.T(), model.worker.done) suite.ErrorIs(run.err, context.Canceled) - _, err = archive.Stat() - suite.ErrorIs(err, os.ErrClosed) + assertFileClosed(suite.T(), archive) projectLock, err := fslock.Acquire(projectLockPath, time.Second) suite.NoError(err) if err == nil { diff --git a/packages/npm-wasm/boot.cjs b/packages/npm-wasm/boot.cjs index 35585909..3365d682 100644 --- a/packages/npm-wasm/boot.cjs +++ b/packages/npm-wasm/boot.cjs @@ -17,6 +17,67 @@ 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 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 +85,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 +109,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] = norm(out[k]); + } + } return out; } @@ -49,13 +126,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 +154,6 @@ async function bootRuntime() { module.exports = bootRuntime; module.exports.curateGoEnv = curateGoEnv; +module.exports.createGoFSAdapter = createGoFSAdapter; +module.exports.goPathToWindowsFS = goPathToWindowsFS; +module.exports.windowsPathToGo = windowsPathToGo; diff --git a/packages/npm-wasm/index.cjs b/packages/npm-wasm/index.cjs index 152b78c6..df9b7fd6 100644 --- a/packages/npm-wasm/index.cjs +++ b/packages/npm-wasm/index.cjs @@ -42,13 +42,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; diff --git a/packages/npm-wasm/test/normalize.test.cjs b/packages/npm-wasm/test/normalize.test.cjs index 4709d9b0..a8052dc4 100644 --- a/packages/npm-wasm/test/normalize.test.cjs +++ b/packages/npm-wasm/test/normalize.test.cjs @@ -15,8 +15,16 @@ "use strict"; const assert = require("assert"); +const path = require("path"); const { normalizeLocation } = require("../index.cjs"); -const { curateGoEnv } = require("../boot.cjs"); +const { + createGoFSAdapter, + curateGoEnv, + goPathToWindowsFS, + windowsPathToGo, +} = require("../boot.cjs"); + +const HOST_PLATFORM = process.platform; function withPlatform(platform, fn) { const orig = Object.getOwnPropertyDescriptor(process, "platform"); @@ -36,20 +44,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 +90,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 +99,7 @@ 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"); } // POSIX: HOME wins over USERPROFILE; XDG_* forwarded; PATH dropped; backslashes @@ -109,4 +125,95 @@ 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"); + +// 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"); From fb43031f842d9640a4a91449bf19e314d4ff525f Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 15:29:39 +0000 Subject: [PATCH 092/115] fix(wasm): preserve empty XDG paths on Windows --- packages/npm-wasm/boot.cjs | 2 +- packages/npm-wasm/test/normalize.test.cjs | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/npm-wasm/boot.cjs b/packages/npm-wasm/boot.cjs index 3365d682..bb1e21fd 100644 --- a/packages/npm-wasm/boot.cjs +++ b/packages/npm-wasm/boot.cjs @@ -111,7 +111,7 @@ function curateGoEnv(env, platform) { } if (isWin) { for (const k of ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"]) { - if (out[k] !== undefined) out[k] = norm(out[k]); + if (out[k] !== undefined && out[k] !== "") out[k] = norm(out[k]); } } return out; diff --git a/packages/npm-wasm/test/normalize.test.cjs b/packages/npm-wasm/test/normalize.test.cjs index a8052dc4..f6640f91 100644 --- a/packages/npm-wasm/test/normalize.test.cjs +++ b/packages/npm-wasm/test/normalize.test.cjs @@ -102,6 +102,17 @@ const ALLOWED_ENV_KEYS = ["HOME", "TMPDIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", 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 // are NOT rewritten (legal filename chars on POSIX). { From 004faa9a927d783bf21d3a4a909251631d2b04f8 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 15:36:22 +0000 Subject: [PATCH 093/115] fix(wasm): restore public Windows paths --- packages/npm-wasm/boot.cjs | 24 +++++++++++++ packages/npm-wasm/index.cjs | 7 ++-- packages/npm-wasm/test/normalize.test.cjs | 41 +++++++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/packages/npm-wasm/boot.cjs b/packages/npm-wasm/boot.cjs index bb1e21fd..5755fbad 100644 --- a/packages/npm-wasm/boot.cjs +++ b/packages/npm-wasm/boot.cjs @@ -42,6 +42,27 @@ function goPathToWindowsFS(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, "/"); @@ -156,4 +177,7 @@ 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 df9b7fd6..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" }; @@ -211,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 f6640f91..9cccb415 100644 --- a/packages/npm-wasm/test/normalize.test.cjs +++ b/packages/npm-wasm/test/normalize.test.cjs @@ -21,6 +21,9 @@ const { createGoFSAdapter, curateGoEnv, goPathToWindowsFS, + goPathToPublicWindowsPath, + restoreInstalledPaths, + restoreManifestPath, windowsPathToGo, } = require("../boot.cjs"); @@ -150,6 +153,44 @@ assert.strictEqual(windowsPathToGo("..\\drivers"), "../drivers", "preserve relat 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. { From b5bbdd3febeec0d01134feda2ff668250d95ad89 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 15:41:38 +0000 Subject: [PATCH 094/115] fix(windows): preserve ADBC registry metadata --- cmd/dbc/registry_test.go | 51 +++++++++++++++++++++++++++++++++ config/dirs_windows.go | 62 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/cmd/dbc/registry_test.go b/cmd/dbc/registry_test.go index b56c930f..737c9aeb 100644 --- a/cmd/dbc/registry_test.go +++ b/cmd/dbc/registry_test.go @@ -21,10 +21,12 @@ import ( "context" "errors" "os" + "path/filepath" "testing" "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" @@ -128,6 +130,55 @@ func (s *RegistryTestSuite) TestInstallDriver() { s.Equal("1.1.0", val) s.assertRegisteredPackagePath() + + installed, err := config.GetDriver(config.Config{Level: config.ConfigUser, Location: s.cfgUserPath}, "test-driver-1") + s.Require().NoError(err) + 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() { diff --git a/config/dirs_windows.go b/config/dirs_windows.go index 50db781e..91866f5a 100644 --- a/config/dirs_windows.go +++ b/config/dirs_windows.go @@ -110,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) @@ -153,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") @@ -293,7 +312,10 @@ func CreateManifest(cfg Config, driver DriverInfo) (err error) { } }() created := !openedExisting - valueNames := []string{"name", "manifest_version", "publisher", "license", "version", "source", "driver", "entrypoint"} + 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 @@ -328,6 +350,44 @@ func CreateManifest(cfg Config, driver DriverInfo) (err error) { 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 + } + 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 + } + 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()) } From bec4eaf19185d272ade7efdfd8fab4c30396447f Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 15:52:23 +0000 Subject: [PATCH 095/115] test(ci): align reinstall fixtures with config levels --- cmd/dbc/install_test.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index 54642c3f..a6372004 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -152,8 +152,14 @@ func (suite *SubcommandTestSuite) TestReinstallUpdateVersion() { suite.Require().NoError(err) relDir := filepath.ToSlash(filepath.Dir(relLibrary)) relLibrary = filepath.ToSlash(relLibrary) - suite.Equal([]string{filepath.ToSlash(filepath.Join(relDir, "dbc-install-receipt.json")), - relLibrary, relLibrary + ".sig", "test-driver-1.toml"}, suite.getFilesInDir(suite.Dir())) + 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() { @@ -162,7 +168,7 @@ 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.Config{Level: config.ConfigEnv, Location: suite.Dir()}, "test-driver-1") + previous, err := config.GetDriver(config.Get()[suite.configLevel], "test-driver-1") suite.Require().NoError(err) previousLibrary := previous.Driver.Shared.Get(config.PlatformTuple()) @@ -173,7 +179,7 @@ func (suite *SubcommandTestSuite) TestReinstallDowngradeVersion() { suite.runCmd(m)) files := suite.getFilesInDir(suite.Dir()) - installed, err := config.GetDriver(config.Config{Level: config.ConfigEnv, Location: suite.Dir()}, "test-driver-1") + 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) From 2788fe9c4603a38c0c5309af69383fdae109c108 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 15:54:49 +0000 Subject: [PATCH 096/115] fix(wasm): avoid directory flags during cleanup --- config/loaddir_js.go | 16 +++++++++------- config/loaddir_other.go | 4 ++++ config/package_archive.go | 2 +- 3 files changed, 14 insertions(+), 8 deletions(-) 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 index 8493801f..2e6d8529 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -1302,7 +1302,7 @@ func cleanupOwnedPackageDirectoriesWithRemoveAll(location, runtimeID string, pre } } } - entries, err := os.ReadDir(location) + 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)) From 25b688d04495427457af24599cf8bfd39a78a7ce Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 16:13:04 +0000 Subject: [PATCH 097/115] refactor(lockfile): drop obsolete v2 compatibility checks --- cmd/dbc/lockfile.go | 24 ---- cmd/dbc/lockfile_v2.go | 15 +-- cmd/dbc/lockfile_v2_test.go | 211 +----------------------------------- 3 files changed, 6 insertions(+), 244 deletions(-) diff --git a/cmd/dbc/lockfile.go b/cmd/dbc/lockfile.go index 0c761ec9..b87556b7 100644 --- a/cmd/dbc/lockfile.go +++ b/cmd/dbc/lockfile.go @@ -67,12 +67,6 @@ type lockEvidence struct { Kind resolution.EvidenceKind `toml:"kind"` Location resolution.ArtifactLocation `toml:"location"` Hash string `toml:"hash"` - // These fields detect the source-specific evidence shape used by the - // unreleased single-table lock representation. - LegacyBundleURL *string `toml:"bundle_url,omitempty"` - LegacyBundleHash *string `toml:"bundle_hash,omitempty"` - LegacyReleaseListURL *string `toml:"release_list_url,omitempty"` - LegacyReleaseListHash *string `toml:"release_list_hash,omitempty"` } type lockArtifact struct { @@ -83,17 +77,6 @@ type lockArtifact struct { Hash string `toml:"hash"` Size *int64 `toml:"size"` HostRequirements lockHostRequirements `toml:"host_requirements,omitempty"` - // These fields detect and reject the pre-location v2 wire form. - LegacyURL *string `toml:"url,omitempty"` - LegacyPath *string `toml:"path,omitempty"` - // These fields exist only to detect and reject obsolete v2 wire selectors. - // New lock entries never populate or serialize them; Target is the only - // artifact selector accepted for version 2 files. - LegacyPlatform *string `toml:"platform,omitempty"` - LegacyOS *string `toml:"os,omitempty"` - LegacyArch *string `toml:"arch,omitempty"` - LegacyLibC *string `toml:"libc,omitempty"` - LegacyVariant *string `toml:"variant,omitempty"` } type lockHostRequirements struct { @@ -291,13 +274,6 @@ func validateLockArtifacts(artifacts []lockArtifact) error { if artifact.PackageVersion != 0 && artifact.PackageVersion != 2 { return fmt.Errorf("artifact %d has unsupported dbc package version %d", i, artifact.PackageVersion) } - if artifact.LegacyURL != nil || artifact.LegacyPath != nil { - return fmt.Errorf("artifact %d uses obsolete direct URL or path fields; use the location table", i) - } - if artifact.LegacyPlatform != nil || artifact.LegacyOS != nil || artifact.LegacyArch != nil || - artifact.LegacyLibC != nil || artifact.LegacyVariant != nil { - return fmt.Errorf("artifact %d uses obsolete selector fields; use the target table", i) - } if err := resolution.ValidateConcreteTarget(artifact.Target); err != nil { return fmt.Errorf("artifact %d has invalid target: %w", i, err) } diff --git a/cmd/dbc/lockfile_v2.go b/cmd/dbc/lockfile_v2.go index f3556fe7..1e142bd0 100644 --- a/cmd/dbc/lockfile_v2.go +++ b/cmd/dbc/lockfile_v2.go @@ -77,6 +77,9 @@ type VerifiedLegacyLibrary struct { // 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 { @@ -108,14 +111,8 @@ func lockInfoFromResolvedRelease(name string, release resolution.ResolvedRelease return lockInfo{}, fmt.Errorf("unsupported resolved source type %q", release.Source.Type) } for _, artifact := range release.Artifacts { - if release.Source.Type == "packslip" && artifact.PackageVersion != 2 { - return lockInfo{}, fmt.Errorf("packslip artifact for %s has no supported dbc package_version declaration", name) - } entry.Artifacts = append(entry.Artifacts, lockArtifactFromResolved(artifact)) } - if err := validateLockInfo(entry); err != nil { - return lockInfo{}, err - } return entry, nil } @@ -223,12 +220,6 @@ func validateLockInfo(entry lockInfo) error { if err := validateLockSource(entry.Source); err != nil { return fmt.Errorf("driver %q source: %w", entry.Name, err) } - for i, evidence := range entry.Evidence { - if evidence.LegacyBundleURL != nil || evidence.LegacyBundleHash != nil || - evidence.LegacyReleaseListURL != nil || evidence.LegacyReleaseListHash != nil { - return fmt.Errorf("driver %q evidence %d uses obsolete source-specific fields", entry.Name, i) - } - } if err := resolution.ValidateEvidence(resolutionEvidenceFromLock(entry.Evidence)); err != nil { return fmt.Errorf("driver %q evidence: %w", entry.Name, err) } diff --git a/cmd/dbc/lockfile_v2_test.go b/cmd/dbc/lockfile_v2_test.go index 2960d793..62a5595b 100644 --- a/cmd/dbc/lockfile_v2_test.go +++ b/cmd/dbc/lockfile_v2_test.go @@ -305,91 +305,6 @@ func TestLockFileV2UsesTypedSourceFieldsAndRequiresEvidencePairs(t *testing.T) { assert.Contains(t, err.Error(), "no hash") } -func TestLockFileV2RejectsLegacyEvidenceWireForms(t *testing.T) { - base := "version = 2\nrevision = 0\n\n[[drivers]]\nname = 'example'\nversion = '1.2.3'\n\n[drivers.source]\ntype = 'registry'\nurl = 'https://registry.example.test'\n" - artifact := "\n[[drivers.artifacts]]\nformat = 'tar.gz'\nhash = 'sha256:" + strings.Repeat("a", 64) + "'\nsize = 1\n[drivers.artifacts.target]\nos = 'linux'\narch = 'amd64'\nlibc = 'gnu'\n[drivers.artifacts.location]\nkind = 'url'\nvalue = 'https://assets.example.test/archive.tar.gz'\n" - tests := []struct { - name string - evidence string - wantErr string - }{ - { - name: "old single table", - evidence: "\n[drivers.evidence]\nbundle_url = 'https://example.test/bundle'\nbundle_hash = 'sha256:" + strings.Repeat("e", 64) + "'\n", - wantErr: "obsolete source-specific fields", - }, - { - name: "old fields in new evidence array", - evidence: "\n[[drivers.evidence]]\nbundle_url = 'https://example.test/bundle'\nbundle_hash = 'sha256:" + strings.Repeat("e", 64) + "'\n", - wantErr: "obsolete source-specific fields", - }, - { - name: "old field alongside valid new evidence", - evidence: "\n[[drivers.evidence]]\nkind = 'release-metadata'\nhash = 'sha256:" + strings.Repeat("e", 64) + "'\nbundle_url = 'https://example.test/bundle'\n[drivers.evidence.location]\nkind = 'url'\nvalue = 'https://example.test/bundle'\n", - wantErr: "obsolete source-specific fields", - }, - { - name: "empty old field alongside valid new evidence", - evidence: "\n[[drivers.evidence]]\nkind = 'release-metadata'\nhash = 'sha256:" + strings.Repeat("e", 64) + "'\nbundle_url = ''\n[drivers.evidence.location]\nkind = 'url'\nvalue = 'https://example.test/bundle'\n", - wantErr: "obsolete source-specific fields", - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - path := filepath.Join(t.TempDir(), "dbc.lock") - require.NoError(t, os.WriteFile(path, []byte(base+test.evidence+artifact), 0o600)) - _, err := loadLockFile(path) - assert.ErrorContains(t, err, test.wantErr) - }) - } -} - -func TestLockFileV2RejectsObsoleteArtifactSelectorFields(t *testing.T) { - fields := []struct{ name, value string }{ - {name: "platform", value: `platform = "linux_amd64"`}, - {name: "os", value: `os = "linux"`}, - {name: "arch", value: `arch = "amd64"`}, - {name: "libc", value: `libc = "gnu"`}, - {name: "variant", value: `variant = "v1"`}, - } - for _, field := range fields { - for _, withTarget := range []bool{false, true} { - targetCase := "alone" - if withTarget { - targetCase = "with_target" - } - t.Run(field.name+"/"+targetCase, func(t *testing.T) { - contents := "version = 2\nrevision = 0\n\n[[drivers]]\nname = 'example'\nversion = '1.2.3'\n\n[drivers.source]\ntype = 'registry'\nurl = 'https://registry.example.test'\n\n[[drivers.artifacts]]\nformat = 'tar.gz'\nhash = 'sha256:" + strings.Repeat("a", 64) + "'\nsize = 1\n" + field.value + "\n\n[drivers.artifacts.location]\nkind = 'url'\nvalue = 'https://assets.example.test/archive.tar.gz'\n" - if withTarget { - contents += "[drivers.artifacts.target]\nos = 'linux'\narch = 'amd64'\nlibc = 'gnu'\n" - } - path := filepath.Join(t.TempDir(), "dbc.lock") - require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) - _, err := loadLockFile(path) - assert.ErrorContains(t, err, "obsolete selector fields") - }) - } - } -} - -func TestLockFileV2RejectsObsoleteDirectArtifactLocationFields(t *testing.T) { - for _, field := range []string{"url", "path"} { - for _, withLocation := range []bool{false, true} { - t.Run(field+"/with_location="+strconv.FormatBool(withLocation), func(t *testing.T) { - contents := "version = 2\nrevision = 0\n\n[[drivers]]\nname = 'example'\nversion = '1.2.3'\n\n[drivers.source]\ntype = 'registry'\nurl = 'https://registry.example.test'\n\n[[drivers.artifacts]]\n" - contents += field + " = ''\nformat = 'tar.gz'\nhash = 'sha256:" + strings.Repeat("a", 64) + "'\nsize = 1\n[drivers.artifacts.target]\nos = 'linux'\narch = 'amd64'\nlibc = 'gnu'\n" - if withLocation { - contents += "\n[drivers.artifacts.location]\nkind = 'url'\nvalue = 'https://assets.example.test/archive.tar.gz'\n" - } - path := filepath.Join(t.TempDir(), "dbc.lock") - require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) - _, err := loadLockFile(path) - assert.ErrorContains(t, err, "obsolete direct URL or path") - }) - } - } -} - func TestLockFileV1MigrationPreservesLibraryProofAndRequiresSamePlatformVerification(t *testing.T) { path := filepath.Join(t.TempDir(), "dbc.lock") legacyHash := strings.Repeat("d", 64) @@ -597,7 +512,6 @@ func TestLockRoundTripPreservesOneArtifactLocationSharedBySeveralTargets(t *test shared := release.Artifacts[0] shared.Target = resolution.Target{OS: "macos", Arch: "arm64"} release.Artifacts = append(release.Artifacts, shared) - require.NoError(t, resolution.ValidateResolvedRelease(release)) entry, err := lockInfoFromResolvedRelease("example", release) require.NoError(t, err) @@ -660,20 +574,6 @@ func TestRefreshAndUpgradeUseSourceAwareReleaseVersionIdentity(t *testing.T) { }) } -func TestLockSnapshotRequiresFinalizedArtifactMetadata(t *testing.T) { - release := testResolvedRelease() - release.Artifacts[0].Hash = "" - _, err := lockInfoFromResolvedRelease("example", release) - require.Error(t, err) - assert.Contains(t, err.Error(), "hash and size must either both be present or both be absent") - - release = testResolvedRelease() - release.Artifacts = append(release.Artifacts, release.Artifacts[0]) - _, err = lockInfoFromResolvedRelease("example", release) - require.Error(t, err) - assert.Contains(t, err.Error(), "duplicate target") -} - func TestSyncAdapterReusesCompleteV2SnapshotWithoutRegistryHashes(t *testing.T) { platform := config.PlatformTuple() registryURL, err := url.Parse("https://registry.example.test") @@ -858,53 +758,6 @@ func TestLockedArchiveDownloadMustMatchExpectedHashAndSize(t *testing.T) { } } -func TestV2RegistryReplaySkipsDiscoveryAndUsesLockedURLAndMetadata(t *testing.T) { - projectPath := filepath.Join(t.TempDir(), "dbc.toml") - lockPath := filepath.Join(filepath.Dir(projectPath), "dbc.lock") - entry := testRegistryLockEntryForPlatform(config.PlatformTuple()) - require.NoError(t, writeLockFileAtomic(lockPath, LockFile{Version: 2, Drivers: []lockInfo{entry}})) - require.NoError(t, os.WriteFile(projectPath, []byte("[drivers]\n[drivers.example]\n"), 0o600)) - - discoveryCalls := 0 - model := syncModel{ - baseModel: baseModel{getDriverRegistry: func() ([]dbc.Driver, error) { - discoveryCalls++ - return nil, errors.New("registry unavailable") - }}, - Path: projectPath, - // Deliberately include a changed/incomplete registry result. A valid v2 - // target artifact must remain authoritative and never consult it. - driverIndex: []dbc.Driver{{Path: "example", Title: "changed registry metadata"}}, - } - updated, cmd := model.Update(driversListMsg{path: projectPath, list: DriversList{ - Drivers: map[string]driverSpec{"example": {}}, - }}) - require.NotNil(t, cmd) - msg := cmd() - items, ok := msg.([]installItem) - require.True(t, ok, "expected lock replay install items, got %T", msg) - require.Len(t, items, 1) - assert.Zero(t, discoveryCalls, "registry discovery must not run for a complete v2 registry artifact") - selected, err := items[0].selectedArtifact() - require.NoError(t, err) - assert.Equal(t, entry.Artifacts[0].Location, selected.Location) - assert.Equal(t, entry.Artifacts[0].Hash, selected.Hash) - assert.Equal(t, *entry.Artifacts[0].Size, *selected.Size) - assert.Equal(t, entry.Source.URL, items[0].Release.Source.Reference) - - // The normal completion path builds the entry from the locked PkgInfo and - // rewrites v2 without changing its artifact snapshot. - updatedModel := updated.(syncModel) - updatedModel.LockFilePath = lockPath - rewritten, err := lockEntryForItem(items[0]) - require.NoError(t, err) - updatedModel.locked = LockFile{Version: 2, Drivers: []lockInfo{rewritten}} - require.NoError(t, updatedModel.writeLockFile()) - reloaded, err := loadLockFile(lockPath) - require.NoError(t, err) - assert.Equal(t, entry.Artifacts[0], reloaded.lockinfo["example"].Artifacts[0]) -} - func testRegistryLockEntryForPlatform(platform string) lockInfo { return lockInfo{ Name: "example", @@ -918,66 +771,6 @@ func testRegistryLockEntryForPlatform(platform string) lockInfo { } } -func TestV2RegistryReplayFallsBackToDiscoveryOnlyWhenTargetArtifactIsMissing(t *testing.T) { - tmp := t.TempDir() - projectPath := filepath.Join(tmp, "dbc.toml") - lockPath := filepath.Join(tmp, "dbc.lock") - otherPlatform := "linux_amd64" - if config.PlatformTuple() == otherPlatform { - otherPlatform = "macos_arm64" - } - entry := testRegistryLockEntryForPlatform(otherPlatform) - require.NoError(t, writeLockFileAtomic(lockPath, LockFile{Version: 2, Drivers: []lockInfo{entry}})) - require.NoError(t, os.WriteFile(projectPath, []byte("[drivers]\n[drivers.example]\n"), 0o600)) - - discoveryCalls := 0 - model := syncModel{ - baseModel: baseModel{getDriverRegistry: func() ([]dbc.Driver, error) { - discoveryCalls++ - return []dbc.Driver{}, nil - }}, - Path: projectPath, - } - _, cmd := model.Update(driversListMsg{path: projectPath, list: DriversList{ - Drivers: map[string]driverSpec{"example": {}}, - }}) - require.NotNil(t, cmd) - msg := cmd() - _, ok := msg.(driversWithRegistryError) - require.True(t, ok, "missing target should enter the existing registry fallback, got %T", msg) - assert.Equal(t, 1, discoveryCalls) -} - -func TestV2RegistryReplayAllowsExplicitPrereleaseConstraintOffline(t *testing.T) { - projectPath := filepath.Join(t.TempDir(), "dbc.toml") - entry := testRegistryLockEntryForPlatform(config.PlatformTuple()) - entry.Version = semver.MustParse("1.2.3-beta.1") - lockPath := filepath.Join(filepath.Dir(projectPath), "dbc.lock") - require.NoError(t, writeLockFileAtomic(lockPath, LockFile{Version: 2, Drivers: []lockInfo{entry}})) - require.NoError(t, os.WriteFile(projectPath, []byte("[drivers]\n[drivers.example]\n"), 0o600)) - constraint, err := semver.NewConstraint("=1.2.3-beta.1") - require.NoError(t, err) - - discoveryCalls := 0 - model := syncModel{ - baseModel: baseModel{getDriverRegistry: func() ([]dbc.Driver, error) { - discoveryCalls++ - return nil, errors.New("registry unavailable") - }}, - Path: projectPath, - } - _, cmd := model.Update(driversListMsg{path: projectPath, list: DriversList{ - Drivers: map[string]driverSpec{"example": {Version: constraint}}, - }}) - require.NotNil(t, cmd) - msg := cmd() - items, ok := msg.([]installItem) - require.True(t, ok, "expected explicit prerelease constraint to replay lock, got %T", msg) - require.Len(t, items, 1) - assert.Equal(t, "1.2.3-beta.1", items[0].Release.Version) - assert.Zero(t, discoveryCalls, "an explicit matching prerelease constraint must not trigger registry discovery") -} - func TestLockReplayRejectsMuslForGenericLinuxTargetAndAmbiguousArtifacts(t *testing.T) { entry := testLockEntry() entry.Artifacts = []lockArtifact{{ @@ -1219,7 +1012,9 @@ func TestUnsupportedHostRequirementsFailClosedBeforePreparationOrEnsure(t *testi assert.Zero(t, downloadCalls) assert.Zero(t, ensureCalls) - _, ensureErr := model.ensurePreparedPackage(context.Background(), &item) + executor, err := model.newPackageExecutor() + require.NoError(t, err) + _, ensureErr := executor.ensurePreparedPackage(context.Background(), &item) require.ErrorContains(t, ensureErr, "unsupported host requirements for example") assert.Zero(t, ensureCalls, "unsupported requirements must be rejected before EnsurePackage") after, err := config.GetDriver(cfg, "example") From 313a56f4755f46c82cdfccd00a29a0a596b0366d Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 16:57:05 +0000 Subject: [PATCH 098/115] refactor(sync): simplify orchestration and reuse prepared packages --- cmd/dbc/add_test.go | 103 +--- cmd/dbc/driver_list_test.go | 111 +--- cmd/dbc/install.go | 20 +- cmd/dbc/install_source.go | 13 +- cmd/dbc/install_test.go | 26 +- cmd/dbc/lockfile_v2_test.go | 79 +-- cmd/dbc/package_executor.go | 158 ++--- cmd/dbc/sync.go | 136 ++--- cmd/dbc/sync_test.go | 1077 ++++----------------------------- config/ensure_package_test.go | 277 ++++++--- config/package_archive.go | 264 +++++++- 11 files changed, 677 insertions(+), 1587 deletions(-) diff --git a/cmd/dbc/add_test.go b/cmd/dbc/add_test.go index 85948098..03957c26 100644 --- a/cmd/dbc/add_test.go +++ b/cmd/dbc/add_test.go @@ -364,7 +364,7 @@ func TestAddUpdatesPackslipVersionWithoutRegistryLookup(t *testing.T) { require.NoError(t, os.WriteFile(path, []byte(initial), 0o644)) release := makeSyncPackslipRelease( - "test-driver-1", "1.2.4+build.5", "https://assets.example.test/driver.tgz", + "test-driver-1", "1.2.4", "https://assets.example.test/driver.tgz", "sha256:"+strings.Repeat("a", 64), 10) resolver := &syncPackslipResolverStub{release: release} registryCalls := 0 @@ -376,7 +376,7 @@ func TestAddUpdatesPackslipVersionWithoutRegistryLookup(t *testing.T) { base.newPackslipResolver = func() (packslip.Resolver, error) { return resolver, nil } msg := runTeaCmdToCompletion(t, AddCmd{ Path: path, - Driver: []string{"test-driver-1=1.2.4+build.5"}, + Driver: []string{"test-driver-1=1.2.4"}, }.GetModelCustom(base).(interface { Init() tea.Cmd Update(tea.Msg) (tea.Model, tea.Cmd) @@ -386,7 +386,7 @@ func TestAddUpdatesPackslipVersionWithoutRegistryLookup(t *testing.T) { 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+build.5"}, resolver.request) + assert.Equal(t, packslip.Request{DriverID: "test-driver-1", Version: "1.2.4"}, resolver.request) data, readErr := os.ReadFile(path) require.NoError(t, readErr) @@ -394,7 +394,7 @@ func TestAddUpdatesPackslipVersionWithoutRegistryLookup(t *testing.T) { require.NoError(t, toml.Unmarshal(data, &updated)) require.NoError(t, updated.validateSources()) got := updated.Drivers["test-driver-1"] - assert.Equal(t, "1.2.4+build.5", got.Version.String()) + 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) } @@ -493,73 +493,34 @@ func TestAddPathSourceRequiresExactVersionToMatchArchive(t *testing.T) { assert.Equal(t, initial, string(data), "version mismatch must leave dbc.toml byte-for-byte unchanged") } -func TestAddRejectsInvalidPackslipResolverResultWithoutMutation(t *testing.T) { - tests := []struct { - name string - mutate func(*resolution.ResolvedRelease) - want string - }{ - { - name: "wrong driver ID", - mutate: func(release *resolution.ResolvedRelease) { - release.DriverID = "some-other-driver" - }, - want: `driver ID "some-other-driver" does not match requested driver "test-driver-1"`, - }, - { - name: "wrong version", - mutate: func(release *resolution.ResolvedRelease) { - release.Version = "1.2.5" - }, - want: `packslip release version "1.2.5" does not match requested version "1.2.4"`, - }, - { - name: "wrong source", - mutate: func(release *resolution.ResolvedRelease) { - release.Source.Reference = "github.com/example/other" - }, - want: `source identity "github.com/example/other" does not match declared project "github.com/example/driver"`, - }, - { - name: "missing dbc package declaration", - mutate: func(release *resolution.ResolvedRelease) { - release.Artifacts[0].PackageVersion = 1 - }, - want: "unsupported dbc package version 1", - }, - } - 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]\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.4", "https://assets.example.test/driver.tgz", - "sha256:"+strings.Repeat("a", 64), 10) - tt.mutate(&release) - 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, tt.want) - data, err := os.ReadFile(path) - require.NoError(t, err) - assert.Equal(t, initial, string(data)) - }) +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) { @@ -675,9 +636,7 @@ func TestAddNonRegistrySourcesRejectInvalidVersionAndPreWithoutMutation(t *testi input string pre bool }{ - {name: "packslip range", source: "packslip", input: "test-driver-1>=1.2.3"}, {name: "packslip missing", source: "packslip", input: "test-driver-1"}, - {name: "packslip coercible", source: "packslip", input: "test-driver-1=v1.2.3"}, {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"}, diff --git a/cmd/dbc/driver_list_test.go b/cmd/dbc/driver_list_test.go index 8eea5671..adcb39e2 100644 --- a/cmd/dbc/driver_list_test.go +++ b/cmd/dbc/driver_list_test.go @@ -101,63 +101,13 @@ func TestDriverSourceProjectConfigValidation(t *testing.T) { } } -func TestPackslipSourceRequiresExactStrictSemVer(t *testing.T) { - tests := []struct { - name string - version string - wantErr bool - }{ - {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: "exact prerelease and build metadata", version: "1.2.3-rc.1+build.5"}, - {name: "explicit equality operator", version: "=1.2.3", wantErr: true}, - {name: "range", version: ">=1.2.3", wantErr: true}, - {name: "latest", version: "latest", wantErr: true}, - {name: "v prefix", version: "v1.2.3", wantErr: true}, - {name: "abbreviated version", version: "1.2", wantErr: true}, - {name: "leading zero", version: "01.2.3", wantErr: true}, - // This pins the current PoC limitation only; omitted Packslip versions - // should be supported before GA by resolving to an exact release. - {name: "missing version", version: "", wantErr: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - contents := "[drivers.example]\n" - if tt.version != "" { - contents += "version = '" + tt.version + "'\n" - } - contents += "[drivers.example.source]\ntype = 'packslip'\nproject = 'github.com/owner/project'\n" - - var list DriversList - err := toml.Unmarshal([]byte(contents), &list) - if err == nil { - err = list.validateSources() - } - if tt.wantErr { - require.Error(t, err) - if list.Drivers["example"].Source != nil && list.Drivers["example"].Version != nil { - require.ErrorContains(t, err, "exact SemVer 2.0.0", - "a parsed constraint must retain enough spelling for strict validation") - } - return - } - require.NoError(t, err) - }) - } - - // Packslip's exact-version requirement must not tighten the legacy source - // behavior: registry constraints and unconstrained registry entries remain - // valid. - for _, contents := range []string{ - "[drivers.example]\nversion = '>=1.2.3'\n", - "[drivers.example]\n", - } { - var list DriversList - require.NoError(t, toml.Unmarshal([]byte(contents), &list)) - require.NoError(t, list.validateSources()) - } +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) { @@ -416,48 +366,13 @@ func TestRegistriesChanged(t *testing.T) { }) } -func TestDriverSourceIdentityComparison(t *testing.T) { - tests := []struct { - name string - a *dbc.DriverSource - b *dbc.DriverSource - want bool - }{ - {name: "both omitted sources match", want: true}, - {name: "omitted source remains distinct from explicit registry", b: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "https://registry.example.test"}}, - {name: "registry canonical equivalences match", a: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "HTTPS://REGISTRY.EXAMPLE.TEST/#one"}, b: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "https://registry.example.test"}, want: true}, - {name: "registry escaped path distinction is preserved", a: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "https://registry.example.test/a%2Fb"}, b: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "https://registry.example.test/a/b"}}, - {name: "registry force query distinction is preserved", a: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "https://registry.example.test?"}, b: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "https://registry.example.test"}}, - {name: "packslip owner and repo case normalize", a: &dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "GitHub.com/Example/Driver/Tools"}, b: &dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "github.com/example/driver/Tools"}, want: true}, - {name: "packslip tool path case is preserved", a: &dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "github.com/example/driver/Tools"}, b: &dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "github.com/example/driver/tools"}}, - {name: "path is exact string identity", a: &dbc.DriverSource{Type: dbc.DriverSourcePath, Path: "./packages/../driver.tgz"}, b: &dbc.DriverSource{Type: dbc.DriverSourcePath, Path: "./driver.tgz"}}, - {name: "invalid source does not fall back to raw comparison", a: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "not a URL"}, b: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: "not a URL"}}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - assert.Equal(t, test.want, sameDriverSourceIdentity(test.a, test.b)) - }) - } -} - -func TestLockSourceIdentityComparison(t *testing.T) { - tests := []struct { - name string - a lockSource - b lockSource - want bool - }{ - {name: "registry URL canonical equivalences match", a: lockSource{Type: "registry", URL: "HTTPS://REGISTRY.EXAMPLE.TEST/a/#one"}, b: lockSource{Type: "registry", URL: "https://registry.example.test/a"}, want: true}, - {name: "escaped separator remains distinct", a: lockSource{Type: "registry", URL: "https://registry.example.test/a%2Fb"}, b: lockSource{Type: "registry", URL: "https://registry.example.test/a/b"}}, - {name: "packslip owner and repo case normalize", a: lockSource{Type: "packslip", Project: "GitHub.com/Example/Driver/Tools"}, b: lockSource{Type: "packslip", Project: "github.com/example/driver/Tools"}, want: true}, - {name: "path aliases stay distinct", a: lockSource{Type: "path", Path: "./packages/../driver.tgz"}, b: lockSource{Type: "path", Path: "./driver.tgz"}}, - {name: "malformed identity has no raw fallback", a: lockSource{Type: "registry", URL: "not a URL"}, b: lockSource{Type: "registry", URL: "not a URL"}}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - assert.Equal(t, test.want, sameLockSourceIdentity(test.a, test.b)) - }) +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) { diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index cde84c5e..610c24fa 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -294,9 +294,17 @@ func (m progressiveInstallModel) startDirectInstall(item installItem) (tea.Model if err != nil { return err } + installCfg, err := installConfigForEnsure(executor.cfg) + if err != nil { + if closeErr := closeDirectInstallArchive(&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 := closeDirectInstallArchive(&item); closeErr != nil { - err = errors.Join(err, fmt.Errorf("close prepared package archive: %w", closeErr)) + err = errors.Join(err, fmt.Errorf("close prepared package: %w", closeErr)) } return err } @@ -311,17 +319,9 @@ func (m progressiveInstallModel) startDirectEnsure(item installItem, executor *p } m.state = stInstalling return m, func() tea.Msg { - installCfg, err := installConfigForEnsure(executor.cfg) - if err != nil { - if closeErr := closeDirectInstallArchive(&item); closeErr != nil { - err = errors.Join(err, fmt.Errorf("close prepared package archive: %w", closeErr)) - } - return directInstallFinishedMsg{item: item, err: err} - } - executor.cfg = installCfg result, err := executor.ensurePreparedPackage(context.Background(), &item) if closeErr := closeDirectInstallArchive(&item); closeErr != nil { - err = errors.Join(err, fmt.Errorf("close prepared package archive: %w", closeErr)) + err = errors.Join(err, fmt.Errorf("close prepared package: %w", closeErr)) } return directInstallFinishedMsg{item: item, result: result, err: err} } diff --git a/cmd/dbc/install_source.go b/cmd/dbc/install_source.go index f153a35a..91d565de 100644 --- a/cmd/dbc/install_source.go +++ b/cmd/dbc/install_source.go @@ -193,11 +193,18 @@ func (reader *installProgressReadCloser) Read(p []byte) (int, error) { } func closeDirectInstallArchive(item *installItem) error { - if item == nil || item.Archive == nil { + if item == nil { return nil } - err := item.Archive.Close() - item.Archive = 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 } diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index a6372004..1c83ed7e 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -483,7 +483,7 @@ func (suite *SubcommandTestSuite) TestInstallPackslipDirectFailsClosedBeforeRunt wantFetchCall int }{ {name: "package ID mismatch", packageID: "different-id", wantError: "package id mismatch", wantFetchCall: 1}, - {name: "signed archive hash mismatch despite skip flags", packageID: "bad-hash-id", badHash: true, wantError: "does not match expected hash", wantFetchCall: 1}, + {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 { @@ -529,8 +529,6 @@ func (suite *SubcommandTestSuite) TestInstallPackslipDirectFailsClosedBeforeRunt func (suite *SubcommandTestSuite) TestInstallPackslipRejectsInvalidInputsAndPreBeforeResolver() { for _, input := range []string{ "github.com/example/repo>=1.2.3", - "github.com/example/repo=latest", - "github.com/example/repo=v1.2.3", "https://github.com/example/repo/releases/download/v1.2.3/package.tgz", } { suite.Run(input, func() { @@ -596,22 +594,10 @@ func (suite *SubcommandTestSuite) TestInstallLocalPackageV2UsesMetadataIDAndDoes } } -func TestParsePackslipInstallArgumentUsesCanonicalExactVersion(t *testing.T) { - project, version, matched, err := parsePackslipInstallArgument("github.com/owner/repo/tool=1.2.3-rc.1+build.5") - if err != nil || !matched || project != "github.com/owner/repo/tool" || version != "1.2.3-rc.1+build.5" { - t.Fatalf("valid Packslip direct argument parsed as %q, %q, %v, %v", project, version, matched, err) - } - for _, input := range []string{ - "github.com/owner/repo>=1.2.3", - "github.com/owner/repo=1.2", - "github.com/owner/repo=01.2.3", - "github.com/owner/repo=v1.2.3", - "github.com/owner/repo=latest", - } { - _, _, matched, err := parsePackslipInstallArgument(input) - if !matched || err == nil { - t.Errorf("invalid Packslip direct argument %q was not rejected before registry fallback", input) - } +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) @@ -640,7 +626,7 @@ func TestDirectLocalPackageRejectsReplacementAfterResolution(t *testing.T) { if err != nil { t.Fatal(err) } - if err := executor.prepareItem(context.Background(), &item); err == nil || !strings.Contains(err.Error(), "does not match expected hash") { + 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 { diff --git a/cmd/dbc/lockfile_v2_test.go b/cmd/dbc/lockfile_v2_test.go index 62a5595b..fe6a3f35 100644 --- a/cmd/dbc/lockfile_v2_test.go +++ b/cmd/dbc/lockfile_v2_test.go @@ -692,72 +692,6 @@ func TestSyncAdapterVerifiesLegacyLibraryProofBeforeReusingV2Entry(t *testing.T) assert.Contains(t, err.Error(), "legacy installed-library checksum mismatch") } -func TestDownloadedArchiveSnapshotUsesArchiveBytes(t *testing.T) { - archivePath := filepath.Join(t.TempDir(), "package.tar.gz") - archiveBytes := []byte("compressed package fixture") - require.NoError(t, os.WriteFile(archivePath, archiveBytes, 0o600)) - archive, err := os.Open(archivePath) - require.NoError(t, err) - defer archive.Close() - - 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: "tar.gz", Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://registry.example.test/archive.tar.gz"}}}, - }, config.PlatformTuple(), nil) - require.NoError(t, snapshotDownloadedArchive(&item, archive)) - digest := sha256.Sum256(archiveBytes) - selected, err := item.selectedArtifact() - require.NoError(t, err) - assert.Equal(t, "sha256:"+hex.EncodeToString(digest[:]), selected.Hash) - assert.EqualValues(t, len(archiveBytes), *selected.Size) - assert.Empty(t, item.InstalledLibraryHash, "archive bytes must not be recorded as an installed-library hash") -} - -func TestLockedArchiveDownloadMustMatchExpectedHashAndSize(t *testing.T) { - archivePath := filepath.Join(t.TempDir(), "package.tar.gz") - archiveBytes := []byte("downloaded bytes differ from the lock") - require.NoError(t, os.WriteFile(archivePath, archiveBytes, 0o600)) - digest := sha256.Sum256(archiveBytes) - actualHash := "sha256:" + hex.EncodeToString(digest[:]) - actualSize := int64(len(archiveBytes)) - - tests := []struct { - name string - hash string - size *int64 - want string - }{ - { - name: "hash mismatch", - hash: "sha256:" + strings.Repeat("a", 64), size: &actualSize, - want: "does not match expected hash", - }, - { - name: "size mismatch", - hash: actualHash, size: int64Pointer(actualSize + 1), - want: "does not match expected size", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - archive, err := os.Open(archivePath) - require.NoError(t, err) - defer archive.Close() - 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: "tar.gz", - Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://registry.example.test/archive.tar.gz"}, Hash: tt.hash, Size: cloneInt64(tt.size)}}, - }, config.PlatformTuple(), nil) - err = snapshotDownloadedArchive(&item, archive) - require.Error(t, err) - assert.Contains(t, err.Error(), tt.want) - selected, selectErr := item.selectedArtifact() - require.NoError(t, selectErr) - assert.Equal(t, tt.hash, selected.Hash, "failed verification must not produce a lock snapshot") - }) - } -} - func testRegistryLockEntryForPlatform(platform string) lockInfo { return lockInfo{ Name: "example", @@ -860,7 +794,8 @@ func TestResolvedTGZArtifactReplaysThroughPackageValidation(t *testing.T) { model := syncModel{baseModel: baseModel{downloadPkg: func(dbc.PkgInfo) (*os.File, error) { return os.Open(archivePath) - }}, LockFilePath: filepath.Join(t.TempDir(), "dbc.lock")} + }}, 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 closePreparedArchives(prepared.items) @@ -895,7 +830,8 @@ func TestSyncOpensRelativePathArtifactFromProjectDirectory(t *testing.T) { 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")} + 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 closePreparedArchives(prepared.items) @@ -992,7 +928,7 @@ func TestUnsupportedHostRequirementsFailClosedBeforePreparationOrEnsure(t *testi }, config.PlatformTuple(), nil) downloadCalls, ensureCalls := 0, 0 worker := newSyncWorker() - worker.hooks.ensurePackage = func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.InstallOptions, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + worker.hooks.ensurePackage = func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { ensureCalls++ return config.EnsurePackageResult{}, nil } @@ -1012,11 +948,6 @@ func TestUnsupportedHostRequirementsFailClosedBeforePreparationOrEnsure(t *testi assert.Zero(t, downloadCalls) assert.Zero(t, ensureCalls) - executor, err := model.newPackageExecutor() - require.NoError(t, err) - _, ensureErr := executor.ensurePreparedPackage(context.Background(), &item) - require.ErrorContains(t, ensureErr, "unsupported host requirements for example") - assert.Zero(t, ensureCalls, "unsupported requirements must be rejected before EnsurePackage") after, err := config.GetDriver(cfg, "example") require.NoError(t, err) assert.Equal(t, loaded.Name, after.Name) diff --git a/cmd/dbc/package_executor.go b/cmd/dbc/package_executor.go index f6f0114f..f0d36970 100644 --- a/cmd/dbc/package_executor.go +++ b/cmd/dbc/package_executor.go @@ -42,14 +42,14 @@ type packageExecutor struct { 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.InstallOptions, config.EnsurePackageCallbacks) (config.EnsurePackageResult, 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.InstallOptions, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error), + ensurePackage func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error), ) *packageExecutor { if ensurePackage == nil { ensurePackage = config.EnsurePackage @@ -74,7 +74,7 @@ func (s syncModel) newPackageExecutor() (*packageExecutor, error) { return nil, err } } - var ensurePackage func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.InstallOptions, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) + var ensurePackage func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) if s.worker != nil { ensurePackage = s.worker.hooks.ensurePackage } @@ -159,47 +159,6 @@ func expectedSyncPackageMetadata(item installItem) (config.ExpectedPackageMetada return expected, hasHash, nil } -func snapshotDownloadedArchive(item *installItem, archive *os.File) error { - if archive == nil { - return fmt.Errorf("download returned no archive") - } - selected, err := item.selectedArtifact() - if err != nil { - return err - } - if (selected.Hash == "") != (selected.Size == nil) { - return errors.New("artifact hash and size must either both be present or both be absent") - } - if err := resolution.ValidateArtifactMetadata(selected.Hash, selected.Size); err != nil { - return err - } - info, err := archive.Stat() - if err != nil { - return fmt.Errorf("failed to stat downloaded archive: %w", err) - } - actualSize := info.Size() - actualHash, err := checksumFile(archive, archive.Name()) - if err != nil { - return err - } - actualHash = "sha256:" + actualHash - if err := resolution.ValidateArtifactMetadata(actualHash, &actualSize); err != nil { - return err - } - if selected.Hash != "" && selected.Hash != actualHash { - return fmt.Errorf("downloaded archive hash %s does not match expected hash %s", actualHash, selected.Hash) - } - if selected.Size != nil && *selected.Size != actualSize { - return fmt.Errorf("downloaded archive size %d does not match expected size %d", actualSize, *selected.Size) - } - selected.Hash = actualHash - selected.Size = cloneInt64(&actualSize) - if _, err := archive.Seek(0, io.SeekStart); err != nil { - return fmt.Errorf("failed to rewind downloaded archive: %w", err) - } - return nil -} - func (e *packageExecutor) openResolvedArtifact(ctx context.Context, item installItem) (*sourceresolution.OpenedArtifact, error) { selected, err := item.selectedArtifact() if err != nil { @@ -338,17 +297,10 @@ func (e *packageExecutor) downloadAndValidateItem(ctx context.Context, item *ins if err := ctx.Err(); err != nil { return err } - selected, err := item.selectedArtifact() - if err != nil { + if _, err := item.selectedArtifact(); 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 - } - expected, hasMetadata, err := expectedSyncPackageMetadata(*item) + expected, _, err := expectedSyncPackageMetadata(*item) if err != nil { return err } @@ -363,50 +315,40 @@ func (e *packageExecutor) downloadAndValidateItem(ctx context.Context, item *ins item.Archive = archive } archive := item.Archive.File - if err := snapshotDownloadedArchive(item, archive); err != nil { - return fmt.Errorf("failed to snapshot downloaded driver archive: %w", err) - } - selected, err = item.selectedArtifact() - if err != nil { - return err - } - if !hasMetadata { - if _, err := archive.Seek(0, io.SeekStart); err != nil { - return fmt.Errorf("failed to rewind downloaded driver archive: %w", err) - } - packageManifest, inspectErr := config.InspectPackageMetadata(archive) - if inspectErr != nil { - return inspectErr - } - selected.PackageVersion = packageManifest.PackageVersion - expected.PackageVersion = selected.PackageVersion - if packageManifest.PackageVersion == 2 { - return errors.New("package v2 requires archive hash and size metadata") - } - } - expected.ArchiveHash = selected.Hash - expected.ArchiveSize = *selected.Size - item.Expected = expected - if _, err := archive.Seek(0, io.SeekStart); err != nil { - return fmt.Errorf("failed to rewind downloaded driver archive: %w", err) - } var verify func(string, config.Manifest) error if !e.noVerify { verify = func(stagingDir string, manifest config.Manifest) error { return dbc.VerifyPackageSignature(stagingDir, manifest) } } - validation, err := config.ValidatePackage(item.Release.DriverID, archive, expected, config.InstallOptions{Verify: verify}) + 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 validate driver package: %w", 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 + } + if selected.Hash == "" { + selected.Hash = validation.ArchiveHash + selected.Size = cloneInt64(&validation.ArchiveSize) } selected.PackageVersion = validation.PackageVersion - expected.PackageVersion = validation.PackageVersion + expected, _, err = expectedSyncPackageMetadata(*item) + if err != nil { + return err + } item.Expected = expected - item.Validation = &validation item.ValidatedLibraryHash = strings.TrimPrefix(validation.VerifiedLibraryHash, "sha256:") if item.ValidatedLibraryHash == "" { candidateLibrary := validation.Registration.Driver.Shared.Get(item.Platform) @@ -416,6 +358,14 @@ func (e *packageExecutor) downloadAndValidateItem(ctx context.Context, item *ins } } } + 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 } @@ -466,48 +416,35 @@ func (e *packageExecutor) itemCurrentMatches(item *installItem, current *config. } func (e *packageExecutor) ensurePreparedPackage(ctx context.Context, item *installItem) (config.EnsurePackageResult, error) { - if err := ctx.Err(); err != nil { - return config.EnsurePackageResult{}, err + if item != nil && item.Validation != nil && item.Validation.Prepared != nil { + defer func() { _ = item.Validation.Prepared.Close() }() } - selected, err := item.selectedArtifact() - if err != nil { + if err := ctx.Err(); err != nil { return config.EnsurePackageResult{}, err } - if err := validateInstallableArtifactFormat(selected.Format); err != nil { - return config.EnsurePackageResult{}, fmt.Errorf("driver %s: %w", item.Release.DriverID, err) - } - if err := validateHostRequirements(item.Release.DriverID, selected.HostRequirements); err != nil { + 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) }, - Archive: func(ctx context.Context) (*os.File, error) { - if item.Archive == nil || item.Validation == nil { + Prepare: func(ctx context.Context) (*config.PreparedPackage, error) { + if item.Validation == nil || item.Validation.Prepared == nil { if err := e.downloadAndValidateItem(ctx, item); err != nil { return nil, err } } - if err := ctx.Err(); err != nil { - return nil, err + if item.Validation == nil || item.Validation.Prepared == nil { + return nil, errors.New("package preparation completed without a prepared payload") } - if _, err := item.Archive.File.Seek(0, io.SeekStart); err != nil { - return nil, fmt.Errorf("failed to rewind prepared driver archive: %w", err) - } - return item.Archive.File, nil + return item.Validation.Prepared, nil }, ValidateResult: func(result config.EnsurePackageResult) error { return e.validateEnsureResult(item, result) }, } - var verify func(string, config.Manifest) error - if !e.noVerify { - verify = func(stagingDir string, manifest config.Manifest) error { - return dbc.VerifyPackageSignature(stagingDir, manifest) - } - } - result, err := e.ensurePackage(ctx, e.cfg, item.Release.DriverID, item.Expected, config.InstallOptions{Verify: verify}, callbacks) + 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)) } @@ -558,13 +495,6 @@ func (e *packageExecutor) validateEnsureResult(item *installItem, result config. !config.VerifyInstallReceiptLibraryIntegrity(libraryPath, receipt) { return errors.New("installed package receipt does not match the validated package") } - actualHash, err := checksum(libraryPath) - if err != nil { - return syncChecksumError{err: err} - } - if actualHash != item.ValidatedLibraryHash { - return syncChecksumError{err: errors.New("installed library checksum does not match validated package")} - } return nil } diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 3b3f1965..23149561 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -186,7 +186,7 @@ type syncModel struct { 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.InstallOptions, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) + ensurePackage func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) } type syncWorker struct { @@ -220,13 +220,6 @@ type syncResolvingMsg struct{ driver string } type syncItemsMsg struct{ items []installItem } -// driversListMsg is retained for focused lock-resolution tests. The production -// sync path reads the driver list inside the project-lock-owning worker. -type driversListMsg struct { - path string - list DriversList -} - func (s syncModel) Init() tea.Cmd { return func() tea.Msg { if s.worker == nil { @@ -966,7 +959,7 @@ func canReuseLockedEntry(item installItem) bool { return false } version, err := semver.NewVersion(item.Release.Version) - if err != nil || !packageVersionsMatch(item.LockEntry.Source.Type, item.LockEntry.Version, version) { + if err != nil || !sourceresolution.SameReleaseVersion(sourceidentity.Kind(item.LockEntry.Source.Type), item.LockEntry.Version, version) { return false } source, err := lockSourceForResolvedRelease(item.Release) @@ -983,10 +976,6 @@ func canReuseLockedEntry(item installItem) bool { reflect.DeepEqual(canonicalHostRequirements(locked.HostRequirements), canonicalHostRequirements(lockHostRequirementsFromResolution(selected.HostRequirements))) } -func packageVersionsMatch(sourceType string, locked, resolved *semver.Version) bool { - return sourceresolution.SameReleaseVersion(sourceidentity.Kind(sourceType), locked, resolved) -} - func lockSourceForResolvedRelease(release resolution.ResolvedRelease) (lockSource, error) { source := lockSource{Type: release.Source.Type} switch release.Source.Type { @@ -1039,38 +1028,6 @@ func (s syncModel) prepareInstallItems(ctx context.Context, items []installItem) return prepared, nil } -func (s syncModel) downloadAndValidateItem(ctx context.Context, item *installItem) error { - executor, err := s.newPackageExecutor() - if err != nil { - return err - } - return executor.downloadAndValidateItem(ctx, item) -} - -func (s syncModel) itemCurrentMatches(item *installItem, current *config.DriverInfo) (bool, error) { - executor, err := s.newPackageExecutor() - if err != nil { - return false, err - } - return executor.itemCurrentMatches(item, current) -} - -func (s syncModel) ensurePreparedPackage(ctx context.Context, item *installItem) (config.EnsurePackageResult, error) { - executor, err := s.newPackageExecutor() - if err != nil { - return config.EnsurePackageResult{}, err - } - return executor.ensurePreparedPackage(ctx, item) -} - -func (s syncModel) validateEnsureResult(item *installItem, result config.EnsurePackageResult) error { - executor, err := s.newPackageExecutor() - if err != nil { - return err - } - return executor.validateEnsureResult(item, result) -} - type syncChecksumError struct{ err error } func (e syncChecksumError) Error() string { return e.err.Error() } @@ -1095,8 +1052,8 @@ func acquireSyncProjectLock(ctx context.Context, lockPath string) (fslock.Lock, return fslock.Lock{}, fmt.Errorf("could not acquire lock in %s: %w", filepath.Dir(lockPath), err) } -func (s syncModel) runSyncWorker(worker *syncWorker) syncWorkerResultMsg { - result := syncWorkerResultMsg{code: "sync_failed"} +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 @@ -1175,7 +1132,14 @@ func (s syncModel) runSyncWorker(worker *syncWorker) syncWorkerResultMsg { } prepared, err := s.prepareInstallItems(ctx, items) - defer closePreparedArchives(prepared.items) + defer func() { + if cleanupErr := closePreparedArchives(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) @@ -1198,12 +1162,16 @@ func (s syncModel) runSyncWorker(worker *syncWorker) syncWorkerResultMsg { } 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 := s.ensurePreparedPackage(ctx, item) + ensured, err := executor.ensurePreparedPackage(ctx, item) if err != nil { var checksumErr syncChecksumError if errors.As(err, &checksumErr) { @@ -1244,13 +1212,23 @@ func (s syncModel) runSyncWorker(worker *syncWorker) syncWorkerResultMsg { return result } -func closePreparedArchives(items []installItem) { +func closePreparedArchives(items []installItem) error { + var closeErr error for i := range items { if items[i].Archive != nil { - _ = items[i].Archive.Close() + 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 { @@ -1276,14 +1254,7 @@ func (s syncModel) fail(code string, err error) (syncModel, tea.Cmd) { } func lockEntryForItem(item installItem) (lockInfo, error) { - selected, err := item.selectedArtifact() - if err != nil { - return lockInfo{}, err - } - if err := validateInstallableArtifactFormat(selected.Format); err != nil { - return lockInfo{}, fmt.Errorf("driver %s: %w", item.Release.DriverID, err) - } - if err := validateHostRequirements(item.Release.DriverID, selected.HostRequirements); err != nil { + if _, err := item.selectedArtifact(); err != nil { return lockInfo{}, err } legacyProofMatches := true @@ -1306,7 +1277,7 @@ func lockEntryForItem(item installItem) (lockInfo, error) { return lockInfo{}, err } if item.LockEntry == nil || item.LockEntry.Version == nil || - !packageVersionsMatch(candidate.Source.Type, item.LockEntry.Version, candidate.Version) { + !sourceresolution.SameReleaseVersion(sourceidentity.Kind(candidate.Source.Type), item.LockEntry.Version, candidate.Version) { return candidate, nil } if len(item.LockEntry.Artifacts) == 0 { @@ -1331,7 +1302,9 @@ func lockEntryForItem(item installItem) (lockInfo, error) { func hasValidatedReplacementEvidence(item installItem) bool { selected, err := item.selectedArtifact() - if err != nil || item.AlreadyInstalled != nil || item.Archive == nil || selected.Hash == "" || selected.Size == nil || *selected.Size <= 0 || item.ValidatedLibraryHash == "" { + if err != nil || item.AlreadyInstalled != nil || item.Validation == nil || item.Validation.Prepared == nil || + !item.Validation.Prepared.MatchesExpected(item.Expected) || + selected.Hash == "" || selected.Size == nil || *selected.Size <= 0 || item.ValidatedLibraryHash == "" { return false } if validateLegacyLibraryHash(item.ValidatedLibraryHash) != nil || @@ -1341,8 +1314,7 @@ func hasValidatedReplacementEvidence(item installItem) bool { item.Expected.ArchiveHash != selected.Hash || item.Expected.ArchiveSize != *selected.Size { return false } - archiveInfo, err := item.Archive.File.Stat() - return err == nil && archiveInfo.Size() == *selected.Size + return true } func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -1357,32 +1329,6 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd s.progress, cmd = s.progress.Update(msg) return s, cmd - case driversListMsg: - // This adapter exists for focused lock-resolution tests. Runtime syncs - // keep this entire sequence in runSyncWorker under the project lock. - 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) - } - planned, needsRegistry, err := s.planSyncItems(s.list) - if err != nil { - return s, errCmd("failed to inspect lock file: %w", err) - } - if !needsRegistry { - return s, func() tea.Msg { - returnItems, err := s.createInstallList(planned) - if err != nil { - return err - } - return returnItems - } - } - return s, func() tea.Msg { - drivers, err := s.getDriverRegistry() - return driversWithRegistryError{drivers: drivers, err: err} - } case syncItemsMsg: s.spinner = spinner.New() s.progress = progress.New( @@ -1426,18 +1372,6 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { tea.Sequence(tea.Printf("%s %s-%s already installed", checkMark, msg.info.ID, msg.info.Version), s.worker.nextEvent()), ) case installedDrvMsg: - // Production workers verify the post-install checksum before publishing - // this progress event. Retain the direct-update path for isolated model - // tests and embedders that inject the event without a worker. - if s.worker == nil { - chksum, err := checksum(msg.info.Driver.Shared.Get(msg.item.Platform)) - if err != nil { - return s.fail("checksum_failed", err) - } - if msg.item.InstalledLibraryHash != "" && msg.item.InstalledLibraryHash != chksum { - return s.fail("checksum_failed", errors.New("installed library checksum does not match validated package")) - } - } s.newlyInstalled = append(s.newlyInstalled, jsonschema.SyncedDriver{ Name: msg.info.ID, Version: msg.info.Version.String(), diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index 7582a722..f3c7eb92 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -137,18 +137,6 @@ func differentTestPlatformTuple(platform string) string { panic("no alternate test platform is available") } -func TestDifferentTestPlatformTupleAvoidsDuplicateTargets(t *testing.T) { - for _, host := range []string{"linux_amd64", "linux_arm64", "macos_amd64", "macos_arm64", "windows_amd64"} { - t.Run(host, func(t *testing.T) { - got, err := resolution.TargetFromPlatformTuple(differentTestPlatformTuple(host)) - require.NoError(t, err) - want, err := resolution.TargetFromPlatformTuple(host) - require.NoError(t, err) - assert.NotEqual(t, want, got, "secondary artifact target must differ from the current host") - }) - } -} - func TestSyncProgressPercentTracksCompletedItems(t *testing.T) { if got := syncProgressPercent(0, 2); got != 0.5 { t.Fatalf("first completed item progress = %v, want 0.5", got) @@ -158,28 +146,6 @@ func TestSyncProgressPercentTracksCompletedItems(t *testing.T) { } } -func TestPackageExecutorEnsureHookDoesNotRequireSyncWorker(t *testing.T) { - item := mustTestInstallItem(t, 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", - Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: "./example.tgz"}, - }}, - }, config.PlatformTuple(), nil) - wantErr := errors.New("ensure hook invoked") - calls := 0 - executor := newPackageExecutor(config.Config{}, t.TempDir(), true, nil, nil, nil, - func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.InstallOptions, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { - calls++ - return config.EnsurePackageResult{}, wantErr - }) - _, err := executor.ensurePreparedPackage(context.Background(), &item) - require.ErrorIs(t, err, wantErr) - assert.Equal(t, 1, calls) -} - func TestFreshRegistryInstallItemDefaultsToTarGZWithoutHostRequirements(t *testing.T) { drivers, err := getTestDriverRegistry() require.NoError(t, err) @@ -245,6 +211,7 @@ func TestFreshPackslipResolutionSnapshotsAndReplaysWithoutDiscovery(t *testing.T }, }, Path: projectPath, 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) @@ -300,6 +267,7 @@ func TestFreshPackslipResolutionSnapshotsAndReplaysWithoutDiscovery(t *testing.T }, }, Path: projectPath, LockFilePath: model.LockFilePath, NoVerify: true, + cfg: config.Config{Level: config.ConfigEnv, Location: filepath.Join(dir, "replay-install")}, } replayPlan, replayNeedsRegistry, err := replay.planSyncItems(list) require.NoError(t, err) @@ -326,42 +294,6 @@ func assertFileClosed(t *testing.T, file *os.File) { assert.Error(t, err, "stat on a closed file must fail") } -func TestPackslipResolverMismatchIsRejectedBeforeCandidateLock(t *testing.T) { - changes := []struct { - name string - change func(*resolution.ResolvedRelease) - want string - }{ - {name: "driver ID", change: func(release *resolution.ResolvedRelease) { release.DriverID = "other-driver" }, want: "requested driver"}, - {name: "source identity", change: func(release *resolution.ResolvedRelease) { release.Source.Reference = "github.com/example/other" }, want: "source identity"}, - {name: "version", change: func(release *resolution.ResolvedRelease) { release.Version = "1.2.4" }, want: "requested version"}, - {name: "package marker", change: func(release *resolution.ResolvedRelease) { release.Artifacts[0].PackageVersion = 0 }, want: "package_version 2"}, - } - for _, test := range changes { - t.Run(test.name, func(t *testing.T) { - dir := t.TempDir() - base := makeSyncPackslipRelease("test-driver-1", "1.2.3", "https://assets.example.test/driver.tgz", "sha256:"+strings.Repeat("a", 64), 10) - test.change(&base) - resolver := &syncPackslipResolverStub{release: base} - version, err := semver.NewConstraint("1.2.3") - require.NoError(t, err) - source := dbc.DriverSource{Type: dbc.DriverSourcePackslip, Project: "github.com/example/driver"} - model := syncModel{ - baseModel: baseModel{newPackslipResolver: func() (packslip.Resolver, error) { return resolver, nil }}, - LockFilePath: filepath.Join(dir, "dbc.lock"), - } - planned, _, err := model.planSyncItems(DriversList{Drivers: map[string]driverSpec{ - "test-driver-1": {Version: version, Source: &source}, - }}) - require.NoError(t, err) - _, err = model.createInstallListContext(context.Background(), planned) - require.ErrorContains(t, err, test.want) - assert.Equal(t, 1, resolver.calls) - assert.NoFileExists(t, model.LockFilePath) - }) - } -} - func TestPathResolutionDerivesVersionAndUsesProjectRelativeArchive(t *testing.T) { dir := t.TempDir() packagesDir := filepath.Join(dir, "packages") @@ -376,6 +308,7 @@ func TestPathResolutionDerivesVersionAndUsesProjectRelativeArchive(t *testing.T) 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) @@ -404,7 +337,8 @@ func TestPathPackageV2MarkerSurvivesResolutionAndLockSnapshot(t *testing.T) { archivePath := filepath.Join(dir, "driver.tar.gz") makeSyncPackageV2Archive(t, archivePath, "test-driver-1", "1.2.3", config.PlatformTuple()) 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} + 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}, }}) @@ -432,7 +366,8 @@ func TestPathArchiveMutationAfterResolutionFailsBeforeCandidateLock(t *testing.T 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} + 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) @@ -440,7 +375,7 @@ func TestPathArchiveMutationAfterResolutionFailsBeforeCandidateLock(t *testing.T require.NoError(t, os.WriteFile(archivePath, append(archiveBytes, []byte("changed")...), 0o600)) prepared, err := model.prepareInstallItems(context.Background(), items) defer closePreparedArchives(prepared.items) - require.ErrorContains(t, err, "does not match expected hash") + require.ErrorContains(t, err, "package archive hash mismatch") assert.NoFileExists(t, model.LockFilePath) } @@ -458,7 +393,8 @@ func TestPathMetadataDerivedRefreshAdoptsArchiveVersionWithoutOldSnapshotProof(t 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} + 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}, }}) @@ -875,41 +811,6 @@ func TestRegistryReplayPlansDefaultAndExplicitSourceWithoutDiscovery(t *testing. } } -func TestRegistryResolverResultsAreValidatedBeforeInstallItemCreation(t *testing.T) { - constraint, err := semver.NewConstraint("=1.1.0") - require.NoError(t, err) - requirement, err := requirementForDriverSpec("test-driver-1", driverSpec{ - Version: constraint, - Source: &dbc.DriverSource{Type: dbc.DriverSourceRegistry, URL: testRegistry.BaseURL.String()}, - }) - require.NoError(t, err) - base := resolution.ResolvedRelease{ - DriverID: "test-driver-1", Version: "1.1.0", - Source: resolution.SourceSpec{Type: "registry", Reference: testRegistry.BaseURL.String()}, - Artifacts: []resolution.Artifact{{ - Target: testTarget(config.PlatformTuple()), Format: "tar.gz", - Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://assets.example.test/driver.tar.gz"}, - }}, - } - for _, test := range []struct { - name string - change func(*resolution.ResolvedRelease) - want string - }{ - {name: "driver", change: func(release *resolution.ResolvedRelease) { release.DriverID = "other" }, want: "driver ID"}, - {name: "source", change: func(release *resolution.ResolvedRelease) { release.Source.Reference = "https://other.example.test" }, want: "source"}, - {name: "version", change: func(release *resolution.ResolvedRelease) { release.Version = "9.9.9" }, want: "version"}, - } { - t.Run(test.name, func(t *testing.T) { - release := cloneResolvedReleaseForSync(base) - test.change(&release) - item, err := installItemFromResolverResult(requirement, release, nil) - require.ErrorContains(t, err, test.want) - assert.Empty(t, item.Release.DriverID, "invalid resolver output cannot create an install candidate") - }) - } -} - 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") @@ -1062,23 +963,6 @@ func TestExplicitRegistryURLNormalizationAllowsOfflineLockedReplay(t *testing.T) assert.Equal(t, "https://registry.example.test", expected.SourceIdentity) } -func TestPackageVersionIdentityDependsOnSource(t *testing.T) { - locked := semver.MustParse("1.2.3+foo") - resolvedSame := semver.MustParse("1.2.3+foo") - resolvedDifferent := semver.MustParse("1.2.3+bar") - - assert.True(t, packageVersionsMatch("packslip", locked, resolvedSame)) - assert.False(t, packageVersionsMatch("packslip", locked, resolvedDifferent), - "Packslip artifact identity includes build metadata") - assert.True(t, packageVersionsMatch("path", locked, resolvedSame)) - assert.False(t, packageVersionsMatch("path", locked, resolvedDifferent), - "path artifact identity includes build metadata") - assert.True(t, packageVersionsMatch("registry", locked, resolvedDifferent), - "registry keeps the existing SemVer equality semantics") - assert.False(t, packageVersionsMatch("unknown", locked, resolvedSame), - "unsupported source types have no release version identity rule") -} - func TestAcquireSyncProjectLockDeadlineIsContentionButCancelIsNot(t *testing.T) { lockPath := filepath.Join(t.TempDir(), ".dbc.project.lock") held, err := fslock.Acquire(lockPath, time.Second) @@ -1231,46 +1115,6 @@ func (suite *SubcommandTestSuite) TestSyncInstallFailSig() { suite.Equal([]string{"dbc.toml"}, suite.getFilesInTempDir()) } -func (suite *SubcommandTestSuite) TestSyncSignatureFailurePreservesExistingInstallation() { - oldArchive, err := os.Open(filepath.Join("testdata", "test-driver-1.tar.gz")) - suite.Require().NoError(err) - cfg := config.Config{Level: config.ConfigEnv, Location: suite.Dir()} - oldManifest, err := config.InstallPackage(cfg, "test-driver-no-sig", oldArchive, config.ExpectedPackageMetadata{ - ID: "test-driver-no-sig", Version: "1.0.0", Platform: config.PlatformTuple(), - SourceType: "registry", SourceIdentity: testRegistry.BaseURL.String(), - }, config.InstallOptions{ - Verify: func(stagingDir string, manifest config.Manifest) error { - return dbc.VerifyPackageSignature(stagingDir, manifest) - }, - }) - suite.Require().NoError(err) - oldManifest.Version = semver.MustParse("0.9.0") - suite.Require().NoError(config.CreateManifest(cfg, oldManifest.DriverInfo)) - oldManifestBytes, err := os.ReadFile(filepath.Join(suite.Dir(), "test-driver-no-sig.toml")) - suite.Require().NoError(err) - oldLibraryPath := oldManifest.Driver.Shared.Get(config.PlatformTuple()) - oldLibraryBytes, err := os.ReadFile(oldLibraryPath) - suite.Require().NoError(err) - - 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()) - out := suite.runCmdErr(m) - suite.Contains(out, "signature file 'test-driver-1-not-valid.so.sig' for driver is missing") - - manifestBytes, err := os.ReadFile(filepath.Join(suite.Dir(), "test-driver-no-sig.toml")) - suite.Require().NoError(err) - suite.Equal(oldManifestBytes, manifestBytes) - libraryBytes, err := os.ReadFile(oldLibraryPath) - suite.Require().NoError(err) - suite.Equal(oldLibraryBytes, libraryBytes) - installed, err := config.GetDriver(cfg, "test-driver-no-sig") - suite.Require().NoError(err) - suite.Equal("0.9.0", installed.Version.String()) -} - func (suite *SubcommandTestSuite) TestSyncInstallNoVerify() { m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() suite.runCmd(m) @@ -1556,14 +1400,19 @@ func (suite *SubcommandTestSuite) TestSyncCandidateLockFailureDoesNotInstall() { suite.Require().NoError(err) suite.Equal(oldLock, newLock) assertFileClosed(suite.T(), downloadedArchive) + entries, err := os.ReadDir(suite.Dir()) + suite.Require().NoError(err) + for _, entry := range entries { + suite.False(strings.HasPrefix(entry.Name(), ".dbc-install-"), "candidate-save failure leaked prepared staging directory %q", entry.Name()) + } } 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 preparedPaths []string 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) { @@ -1578,23 +1427,20 @@ func (suite *SubcommandTestSuite) TestSyncInstallFailureKeepsCompleteCandidateLo } copyPath := suite.copyArchiveForSyncTest(source) downloadedDrivers = append(downloadedDrivers, pkg.Driver.Path) - return os.Open(copyPath) + archive, err := os.Open(copyPath) + if err == nil { + downloadedArchives = append(downloadedArchives, archive) + } + return archive, err }, }).(syncModel) - model.worker.hooks.duringPrepare = func(_ context.Context, _ int, item installItem) error { - preparedPaths = append(preparedPaths, item.Archive.File.Name()) - return nil - } - model.writeCandidateLock = func(path string, lock LockFile) error { - if err := writeLockFileAtomic(path, lock); err != nil { - return err - } - if len(preparedPaths) != 2 { - return fmt.Errorf("expected two prepared archive snapshots, got %d", len(preparedPaths)) + 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") } - // Corrupt the final item only after the complete candidate is durable, - // forcing execution to fail after one install while preserving the lock. - return os.WriteFile(preparedPaths[len(preparedPaths)-1], []byte("broken after prepare"), 0600) + return config.EnsurePackage(ctx, cfg, runtimeID, expected, callbacks) } suite.runCmdErr(model) @@ -1609,6 +1455,11 @@ func (suite *SubcommandTestSuite) TestSyncInstallFailureKeepsCompleteCandidateLo } 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) @@ -1806,8 +1657,8 @@ func (suite *SubcommandTestSuite) TestSyncLegacyManifestOnlyProofCompatibility() return os.Open(archivePath) }, }).(syncModel) - model.worker.hooks.ensurePackage = func(ctx context.Context, cfg config.Config, driver string, expected config.ExpectedPackageMetadata, options config.InstallOptions, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { - result, err := config.EnsurePackage(ctx, cfg, driver, expected, options, callbacks) + 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++ } @@ -1883,65 +1734,12 @@ func (suite *SubcommandTestSuite) TestSyncLegacyManifestOnlyProofCompatibility() suite.Equal(lockAfterMigration, lockAfterReplay) }) - for _, test := range []struct { - name string - candidatePathChanged bool - candidateEntrypoint string - }{ - {name: "external path changed", candidatePathChanged: true, candidateEntrypoint: "DriverInit"}, - {name: "entrypoint changed", candidateEntrypoint: "OtherInit"}, - } { - suite.Run(test.name, func() { - t := suite.T() - root := t.TempDir() - currentPath := filepath.Join(root, "external-a", "library.so") - candidatePath := currentPath - if test.candidatePathChanged { - candidatePath = filepath.Join(root, "external-b", "library.so") - } - data := []byte("same legacy external library") - fixture := setup(t, currentPath, candidatePath, data, data, true, "DriverInit", test.candidateEntrypoint, true) - before := map[string][]byte{} - for _, external := range []string{currentPath, candidatePath} { - for _, file := range []string{external, filepath.Join(filepath.Dir(external), "LICENSE")} { - if _, ok := before[file]; ok { - continue - } - var readErr error - before[file], readErr = os.ReadFile(file) - suite.Require().NoError(readErr) - } - } - counts := &syncArchiveRunCounts{} - _, err := run(t, fixture.root, fixture.listPath, fixture.archivePath, counts, getTestDriverRegistry) - suite.NoError(err) - suite.Equal(1, counts.install) - installed, err := config.GetDriver(config.Config{Level: config.ConfigEnv, Location: fixture.root}, "test-driver-1") - suite.Require().NoError(err) - suite.Equal(candidatePath, installed.Driver.Shared.Get(config.PlatformTuple())) - suite.Equal(test.candidateEntrypoint, installed.Driver.Entrypoint) - installedHash, err := checksum(candidatePath) - suite.Require().NoError(err) - suite.Equal(fixture.proofHash, installedHash) - 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) - for file, expected := range before { - dataAfter, readErr := os.ReadFile(file) - suite.NoError(readErr) - suite.Equal(expected, dataAfter, "external files and siblings must remain untouched") - } - }) - } - for _, test := range []struct { name string candidateData []byte candidateFound bool }{ {name: "candidate bytes mismatch", candidateData: []byte("not the legacy library"), candidateFound: true}, - {name: "candidate file missing", candidateFound: false}, } { suite.Run(test.name, func() { t := suite.T() @@ -1979,55 +1777,6 @@ func (suite *SubcommandTestSuite) TestSyncLegacyManifestOnlyProofCompatibility() }) } - for _, test := range []struct { - name string - candidateData []byte - candidateFound bool - wantInstall int - }{ - {name: "unregistered external proof installs", candidateData: []byte("unregistered legacy library"), candidateFound: true, wantInstall: 1}, - {name: "unregistered mismatched external proof fails", candidateData: []byte("wrong bytes"), candidateFound: true}, - {name: "unregistered missing external proof fails", candidateFound: false}, - } { - suite.Run(test.name, func() { - t := suite.T() - root := t.TempDir() - candidatePath := filepath.Join(root, "external", "library.so") - proofData := []byte("unregistered legacy library") - fixture := setup(t, "", candidatePath, proofData, test.candidateData, test.candidateFound, "", "DriverInit", false) - counts := &syncArchiveRunCounts{} - out, err := run(t, fixture.root, fixture.listPath, fixture.archivePath, counts, getTestDriverRegistry) - if test.wantInstall == 0 { - suite.Error(err) - suite.Contains(out, "candidate package external library does not match the legacy lock proof") - suite.Equal(0, counts.install) - lockAfter, readErr := os.ReadFile(fixture.lockPath) - suite.Require().NoError(readErr) - suite.Equal(fixture.oldLock, lockAfter) - _, driverErr := config.GetDriver(config.Config{Level: config.ConfigEnv, Location: fixture.root}, "test-driver-1") - suite.Error(driverErr) - } else { - suite.NoError(err) - suite.Equal(1, counts.install) - installed, driverErr := config.GetDriver(config.Config{Level: config.ConfigEnv, Location: fixture.root}, "test-driver-1") - suite.Require().NoError(driverErr) - suite.Equal(candidatePath, installed.Driver.Shared.Get(config.PlatformTuple())) - installedHash, hashErr := checksum(candidatePath) - suite.Require().NoError(hashErr) - suite.Equal(fixture.proofHash, installedHash) - updated, loadErr := loadLockFile(fixture.lockPath) - suite.Require().NoError(loadErr) - suite.Require().NotNil(updated.lockinfo["test-driver-1"].Legacy) - } - suite.Equal(1, counts.registry) - suite.Equal(1, counts.download) - if test.candidateFound { - candidateAfter, readErr := os.ReadFile(candidatePath) - suite.Require().NoError(readErr) - suite.Equal(test.candidateData, candidateAfter) - } - }) - } } type syncArchiveRunCounts struct { @@ -2082,7 +1831,7 @@ func (suite *SubcommandTestSuite) TestSyncPartialRegistryDownloadsEachArchiveOnc // be rejected after its one download rather than treating measured values as // source-provided metadata. v2Path := filepath.Join(suite.tempdir, "v2-without-registry-metadata.tar.gz") - suite.writeV2MetadataArchive(v2Path) + makeSyncPackageV2Archive(suite.T(), v2Path, "test-driver-1", "1.1.0", config.PlatformTuple()) v2List := filepath.Join(suite.tempdir, "v2.toml") suite.Require().NoError(os.WriteFile(v2List, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0644)) v2Downloads := 0 @@ -2117,211 +1866,6 @@ func (suite *SubcommandTestSuite) TestSyncExactLockedArtifactConvergesWithoutReg suite.Equal(0, downloadCalls) } -func (suite *SubcommandTestSuite) TestSyncSameVersionRequiresMatchingManagedReceipt() { - tests := []struct { - name string - mutate func(*testing.T, string, string, config.InstallReceipt) - }{ - {name: "healthy receipt skips", mutate: func(*testing.T, string, string, config.InstallReceipt) {}}, - {name: "missing receipt repairs", mutate: func(t *testing.T, _ string, receiptPath string, _ config.InstallReceipt) { - if err := os.Remove(receiptPath); err != nil { - t.Fatal(err) - } - }}, - {name: "invalid receipt repairs", mutate: func(t *testing.T, _ string, receiptPath string, _ config.InstallReceipt) { - if err := os.WriteFile(receiptPath, []byte("not json"), 0o600); err != nil { - t.Fatal(err) - } - }}, - {name: "driver id mismatch repairs", mutate: func(t *testing.T, _ string, receiptPath string, receipt config.InstallReceipt) { - receipt.DriverID = "another-driver" - writeSyncReceipt(t, receiptPath, receipt) - }}, - {name: "version mismatch repairs", mutate: func(t *testing.T, _ string, receiptPath string, receipt config.InstallReceipt) { - receipt.DriverVersion = "9.9.9" - writeSyncReceipt(t, receiptPath, receipt) - }}, - {name: "source type mismatch repairs", mutate: func(t *testing.T, _ string, receiptPath string, receipt config.InstallReceipt) { - receipt.SourceType = "local" - writeSyncReceipt(t, receiptPath, receipt) - }}, - {name: "source identity mismatch repairs", mutate: func(t *testing.T, _ string, receiptPath string, receipt config.InstallReceipt) { - receipt.SourceIdentity = "https://other.example" - writeSyncReceipt(t, receiptPath, receipt) - }}, - {name: "target mismatch repairs", mutate: func(t *testing.T, _ string, receiptPath string, receipt config.InstallReceipt) { - if receipt.Platform == "windows_amd64" { - receipt.Platform = "linux_amd64" - } else { - receipt.Platform = "windows_amd64" - } - writeSyncReceipt(t, receiptPath, receipt) - }}, - {name: "archive hash mismatch repairs", mutate: func(t *testing.T, _ string, receiptPath string, receipt config.InstallReceipt) { - receipt.ArchiveHash = "sha256:" + strings.Repeat("0", 64) - writeSyncReceipt(t, receiptPath, receipt) - }}, - {name: "archive size mismatch repairs", mutate: func(t *testing.T, _ string, receiptPath string, receipt config.InstallReceipt) { - receipt.ArchiveSize++ - writeSyncReceipt(t, receiptPath, receipt) - }}, - {name: "missing fingerprint repairs", mutate: func(t *testing.T, _ string, receiptPath string, receipt config.InstallReceipt) { - receipt.RegistrationFingerprintAlgorithm = "" - receipt.RegistrationFingerprintVersion = 0 - receipt.RegistrationFingerprint = "" - writeSyncReceipt(t, receiptPath, receipt) - }}, - {name: "unknown fingerprint repairs", mutate: func(t *testing.T, _ string, receiptPath string, receipt config.InstallReceipt) { - receipt.RegistrationFingerprintVersion++ - writeSyncReceipt(t, receiptPath, receipt) - }}, - {name: "fingerprint mismatch repairs", mutate: func(t *testing.T, _ string, receiptPath string, receipt config.InstallReceipt) { - receipt.RegistrationFingerprint = "sha256:" + strings.Repeat("0", 64) - writeSyncReceipt(t, receiptPath, receipt) - }}, - {name: "runtime registration metadata mismatch repairs", mutate: func(t *testing.T, libraryPath, _ string, _ config.InstallReceipt) { - root := filepath.Dir(filepath.Dir(libraryPath)) - cfg := config.Config{Level: config.ConfigEnv, Location: root} - installed, err := config.GetDriver(cfg, "test-driver-1") - if err != nil { - t.Fatal(err) - } - installed.Name = "Tampered Runtime Name" - if err := config.CreateManifest(cfg, installed); err != nil { - t.Fatal(err) - } - }}, - {name: "tampered library repairs", mutate: func(t *testing.T, libraryPath, _ string, _ config.InstallReceipt) { - if err := os.WriteFile(libraryPath, []byte("tampered"), 0o644); err != nil { - t.Fatal(err) - } - }}, - {name: "missing library repairs", mutate: func(t *testing.T, libraryPath, _ string, _ config.InstallReceipt) { - if err := os.Remove(libraryPath); err != nil { - t.Fatal(err) - } - }}, - } - - for _, test := range tests { - suite.Run(test.name, func() { - t := suite.T() - root := t.TempDir() - t.Setenv("ADBC_DRIVER_PATH", root) - driverListPath := filepath.Join(root, "dbc.toml") - if err := os.WriteFile(driverListPath, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0o644); err != nil { - t.Fatal(err) - } - suite.runCmd(SyncCmd{Path: driverListPath, NoVerify: true}.GetModelCustom(testBaseModel())) - - cfg := config.Config{Level: config.ConfigEnv, Location: root} - installed, err := config.GetDriver(cfg, "test-driver-1") - if err != nil { - t.Fatal(err) - } - libraryPath := installed.Driver.Shared.Get(config.PlatformTuple()) - receiptPath := filepath.Join(filepath.Dir(libraryPath), "dbc-install-receipt.json") - receiptBytes, err := os.ReadFile(receiptPath) - if err != nil { - t.Fatal(err) - } - var receipt config.InstallReceipt - if err := json.Unmarshal(receiptBytes, &receipt); err != nil { - t.Fatal(err) - } - test.mutate(t, libraryPath, receiptPath, receipt) - - registryCalls, downloadCalls := 0, 0 - installCalls := 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) - }, - }).(syncModel) - model.worker.hooks.ensurePackage = func(ctx context.Context, cfg config.Config, driver string, expected config.ExpectedPackageMetadata, options config.InstallOptions, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { - result, err := config.EnsurePackage(ctx, cfg, driver, expected, options, callbacks) - if result.Manifest != nil { - installCalls++ - } - return result, err - } - suite.runCmd(model) - suite.Equal(0, registryCalls) - if strings.Contains(test.name, "healthy") { - suite.Equal(0, downloadCalls) - suite.Zero(installCalls) - } else { - suite.Equal(1, downloadCalls) - suite.Equal(1, installCalls) - } - - installed, err = config.GetDriver(cfg, "test-driver-1") - if err != nil { - t.Fatal(err) - } - libraryPath = installed.Driver.Shared.Get(config.PlatformTuple()) - receipt, managed, present, valid := config.InspectInstallReceipt(root, "test-driver-1", libraryPath) - if !managed || !present || !valid || !config.VerifyInstallReceiptLibraryIntegrity(libraryPath, receipt) { - t.Fatalf("repaired installation has invalid receipt/library: managed %v present %v valid %v", managed, present, valid) - } - if !config.InstallReceiptMatchesRuntimeRegistration(receipt, installed, config.PlatformTuple()) { - t.Fatal("repaired receipt does not prove current runtime registration") - } - }) - } -} - -func (suite *SubcommandTestSuite) TestSyncLockedPackageVersionMismatchDoesNotReuseReceiptOrMutateInstall() { - t := suite.T() - root := t.TempDir() - t.Setenv("ADBC_DRIVER_PATH", root) - driverListPath := filepath.Join(root, "dbc.toml") - lockPath := filepath.Join(root, "dbc.lock") - require.NoError(t, 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") - require.NoError(t, err) - beforeLibrary := before.Driver.Shared.Get(config.PlatformTuple()) - lock, err := loadLockFile(lockPath) - require.NoError(t, err) - entry := lock.lockinfo["test-driver-1"] - require.Len(t, entry.Artifacts, 1) - entry.Artifacts[0].PackageVersion = 2 - lock.Drivers = []lockInfo{entry} - require.NoError(t, writeLockFileAtomic(lockPath, lock)) - - downloadCalls, installCalls := 0, 0 - model := SyncCmd{Path: driverListPath, NoVerify: true}.GetModelCustom(baseModel{ - getDriverRegistry: func() ([]dbc.Driver, error) { - return nil, errors.New("exact locked replay must not discover registries") - }, - 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, options config.InstallOptions, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { - installCalls++ - return config.EnsurePackage(ctx, cfg, driver, expected, options, callbacks) - } - - output := suite.runCmdErr(model) - require.Contains(t, output, "dbc package version mismatch") - require.Equal(t, 1, downloadCalls, "a receipt for another package format must not skip archive validation") - require.Zero(t, installCalls, "package format validation must fail before package installation") - after, err := config.GetDriver(cfg, "test-driver-1") - require.NoError(t, err) - assert.Equal(t, before.FilePath, after.FilePath) - assert.Equal(t, beforeLibrary, after.Driver.Shared.Get(config.PlatformTuple()), "failed validation must leave the installed generation untouched") -} - func writeSyncReceipt(t *testing.T, path string, receipt config.InstallReceipt) { t.Helper() data, err := json.Marshal(receipt) @@ -2379,7 +1923,7 @@ func (suite *SubcommandTestSuite) TestSyncManifestOnlyExternalLibraryWithoutProo suite.True(config.VerifyInstallReceiptLibraryIntegrity(managedLibrary, receipt)) } -func (suite *SubcommandTestSuite) TestSyncManifestOnlyInstallFailureConvergesFromCandidateLock() { +func (suite *SubcommandTestSuite) TestSyncManifestOnlyInstallUsesPreparedSnapshotAfterArchiveMutation() { root := suite.T().TempDir() suite.T().Setenv("ADBC_DRIVER_PATH", root) driverListPath := filepath.Join(root, "dbc.toml") @@ -2416,20 +1960,16 @@ func (suite *SubcommandTestSuite) TestSyncManifestOnlyInstallFailureConvergesFro if err != nil { return nil, err } + preparedArchivePath = archive.Name() return archive, nil }, }).(syncModel) - first.worker.hooks.duringPrepare = func(_ context.Context, _ int, item installItem) error { - preparedArchivePath = item.Archive.File.Name() - return nil - } first.worker.hooks.beforeCandidateSave = func(context.Context) error { return os.WriteFile(preparedArchivePath, []byte("corrupted after validation"), 0o600) } - firstOutput := suite.runCmdErr(first) - suite.Contains(firstOutput, "archive hash mismatch") + suite.runCmd(first) suite.Equal("test-driver-1", preparedPackage.Driver.Path) - candidateLock, err := os.ReadFile(lockPath) + _, err := os.ReadFile(lockPath) suite.Require().NoError(err) locked, err := loadLockFile(lockPath) suite.Require().NoError(err) @@ -2438,160 +1978,12 @@ func (suite *SubcommandTestSuite) TestSyncManifestOnlyInstallFailureConvergesFro suite.NotEmpty(artifact.Hash) suite.Require().NotNil(artifact.Size) suite.Equal(int64(len(archiveBytes)), *artifact.Size) - _, err = config.GetDriver(config.Config{Level: config.ConfigEnv, Location: root}, "test-driver-1") - suite.Error(err, "failed installation must not register the candidate") - - registryCalls, downloadCalls := 0, 0 - var replayPackage dbc.PkgInfo - second := SyncCmd{Path: driverListPath, NoVerify: true}.GetModelCustom(baseModel{ - getDriverRegistry: func() ([]dbc.Driver, error) { - registryCalls++ - return nil, errors.New("candidate lock replay must not discover the registry") - }, - downloadPkg: func(pkg dbc.PkgInfo) (*os.File, error) { - downloadCalls++ - replayPackage = pkg - return makeArchive() - }, - }) - suite.runCmd(second) - suite.Zero(registryCalls) - suite.Equal(1, downloadCalls) - suite.Equal(artifact.Location.Value, replayPackage.Path.String()) - suite.Equal(artifact.Hash, replayPackage.ArtifactHash) - suite.Require().NotNil(replayPackage.ArtifactSize) - suite.Equal(*artifact.Size, *replayPackage.ArtifactSize) - convergedLock, err := os.ReadFile(lockPath) - suite.Require().NoError(err) - suite.Equal(candidateLock, convergedLock) 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) TestSyncPackslipInstallFailureConvergesFromCandidateLock() { - root := suite.T().TempDir() - suite.T().Setenv("ADBC_DRIVER_PATH", root) - projectPath := filepath.Join(root, "dbc.toml") - archivePath := filepath.Join(root, "driver.tgz") - lockPath := filepath.Join(root, "dbc.lock") - const version = "1.2.3+build.5" - archiveBytes, archiveHash := makeSyncPackageV2Archive(suite.T(), archivePath, "test-driver-1", version, config.PlatformTuple()) - archiveSize := int64(len(archiveBytes)) - location := "https://assets.example.test/driver.tgz" - release := makeSyncPackslipRelease("test-driver-1", version, location, archiveHash, archiveSize) - resolver := &syncPackslipResolverStub{release: release} - suite.Require().NoError(os.WriteFile(projectPath, []byte("[drivers.test-driver-1]\nversion = '"+version+"'\n[drivers.test-driver-1.source]\ntype = 'packslip'\nproject = 'github.com/example/driver'\n"), 0o600)) - - var registryCalls, resolverCreations int - first := SyncCmd{Path: projectPath, NoVerify: true}.GetModelCustom(baseModel{ - getDriverRegistry: func() ([]dbc.Driver, error) { - registryCalls++ - return nil, errors.New("Packslip sync must not discover registries") - }, - newPackslipResolver: func() (packslip.Resolver, error) { - resolverCreations++ - return resolver, nil - }, - fetchPackslipArtifact: func(_ context.Context, artifactURL *url.URL) (io.ReadCloser, error) { - suite.Equal(location, artifactURL.String()) - return os.Open(archivePath) - }, - }).(syncModel) - first.worker.hooks.ensurePackage = func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.InstallOptions, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { - return config.EnsurePackageResult{}, errors.New("injected install failure") - } - suite.Contains(suite.runCmdErr(first), "injected install failure") - suite.Equal(0, registryCalls) - suite.Equal(1, resolverCreations) - candidateLock, err := os.ReadFile(lockPath) - suite.Require().NoError(err) - locked, err := loadLockFile(lockPath) - suite.Require().NoError(err) - suite.Equal("packslip", locked.lockinfo["test-driver-1"].Source.Type) - suite.Equal(version, locked.lockinfo["test-driver-1"].Version.String()) - _, err = config.GetDriver(config.Config{Level: config.ConfigEnv, Location: root}, "test-driver-1") - suite.Error(err, "an install failure after candidate save must not mutate runtime registration") - - var replayRegistryCalls, replayResolverCreations, replayDownloads int - second := SyncCmd{Path: projectPath, NoVerify: true}.GetModelCustom(baseModel{ - getDriverRegistry: func() ([]dbc.Driver, error) { - replayRegistryCalls++ - return nil, errors.New("candidate lock replay must not discover registries") - }, - newPackslipResolver: func() (packslip.Resolver, error) { - replayResolverCreations++ - return nil, errors.New("candidate lock replay must not create a resolver") - }, - fetchPackslipArtifact: func(_ context.Context, artifactURL *url.URL) (io.ReadCloser, error) { - replayDownloads++ - suite.Equal(location, artifactURL.String()) - return os.Open(archivePath) - }, - }) - suite.runCmd(second) - suite.Zero(replayRegistryCalls) - suite.Zero(replayResolverCreations) - suite.Equal(1, replayDownloads, "replay opens the locked artifact without source discovery") - convergedLock, err := os.ReadFile(lockPath) - suite.Require().NoError(err) - suite.Equal(candidateLock, convergedLock) - installed, err := config.GetDriver(config.Config{Level: config.ConfigEnv, Location: root}, "test-driver-1") - suite.Require().NoError(err) - suite.Equal(version, installed.Version.String()) -} - -func (suite *SubcommandTestSuite) TestSyncPathInstallFailureConvergesFromCandidateLock() { - root := suite.T().TempDir() - suite.T().Setenv("ADBC_DRIVER_PATH", root) - projectPath := filepath.Join(root, "dbc.toml") - archivePath := filepath.Join(root, "packages", "driver.tar.gz") - lockPath := filepath.Join(root, "dbc.lock") - suite.Require().NoError(os.MkdirAll(filepath.Dir(archivePath), 0o700)) - archiveBytes, err := os.ReadFile(filepath.Join("testdata", "test-driver-1.tar.gz")) - suite.Require().NoError(err) - suite.Require().NoError(os.WriteFile(archivePath, archiveBytes, 0o600)) - declaredPath := "./packages/driver.tar.gz" - suite.Require().NoError(os.WriteFile(projectPath, []byte("[drivers.test-driver-1.source]\ntype = 'path'\npath = '"+declaredPath+"'\n"), 0o600)) - - var registryCalls int - newModel := func() syncModel { - return SyncCmd{Path: projectPath, NoVerify: true}.GetModelCustom(baseModel{ - getDriverRegistry: func() ([]dbc.Driver, error) { - registryCalls++ - return nil, errors.New("path sync must not discover registries") - }, - }).(syncModel) - } - first := newModel() - first.worker.hooks.ensurePackage = func(context.Context, config.Config, string, config.ExpectedPackageMetadata, config.InstallOptions, config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { - return config.EnsurePackageResult{}, errors.New("injected install failure") - } - suite.Contains(suite.runCmdErr(first), "injected install failure") - suite.Zero(registryCalls) - candidateLock, err := os.ReadFile(lockPath) - suite.Require().NoError(err) - locked, err := loadLockFile(lockPath) - suite.Require().NoError(err) - suite.Equal("path", locked.lockinfo["test-driver-1"].Source.Type) - suite.Equal(declaredPath, locked.lockinfo["test-driver-1"].Source.Path) - suite.Equal(declaredPath, locked.lockinfo["test-driver-1"].Artifacts[0].Location.Value) - suite.Equal("1.0.0", locked.lockinfo["test-driver-1"].Version.String()) - _, err = config.GetDriver(config.Config{Level: config.ConfigEnv, Location: root}, "test-driver-1") - suite.Error(err, "an install failure after candidate save must not mutate runtime registration") - - second := newModel() - suite.runCmd(second) - suite.Zero(registryCalls) - convergedLock, err := os.ReadFile(lockPath) - suite.Require().NoError(err) - suite.Equal(candidateLock, convergedLock) - installed, err := config.GetDriver(config.Config{Level: config.ConfigEnv, Location: root}, "test-driver-1") - suite.Require().NoError(err) - suite.Equal("1.0.0", installed.Version.String()) -} - func (suite *SubcommandTestSuite) TestSyncPathReplayUsesReceiptBeforeOpeningChangedArchive() { root := suite.T().TempDir() suite.T().Setenv("ADBC_DRIVER_PATH", root) @@ -2652,9 +2044,9 @@ func (suite *SubcommandTestSuite) TestSyncPathReplayUsesReceiptBeforeOpeningChan return nil } ensureCalls := 0 - second.worker.hooks.ensurePackage = func(ctx context.Context, cfg config.Config, driver string, expected config.ExpectedPackageMetadata, options config.InstallOptions, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + 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, options, callbacks) + 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") } @@ -2691,11 +2083,11 @@ func (suite *SubcommandTestSuite) TestSyncPathReplayUsesReceiptBeforeOpeningChan third := newModel() ensureCalls = 0 - third.worker.hooks.ensurePackage = func(ctx context.Context, cfg config.Config, driver string, expected config.ExpectedPackageMetadata, options config.InstallOptions, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { + 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, options, callbacks) + return config.EnsurePackage(ctx, cfg, driver, expected, callbacks) } - suite.Contains(suite.runCmdErr(third), "does not match expected hash") + 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) @@ -2712,86 +2104,6 @@ func (suite *SubcommandTestSuite) TestSyncPathReplayUsesReceiptBeforeOpeningChan suite.Equal(libraryBefore, libraryAfter) } -func (suite *SubcommandTestSuite) TestSyncReceiptRepairUsesSelectedMultiPathRegistrationRoot() { - tests := []struct { - name string - mutate func(*testing.T, string, string, config.InstallReceipt) - }{ - {name: "receipt mismatch", mutate: func(t *testing.T, _ string, receiptPath string, receipt config.InstallReceipt) { - receipt.SourceIdentity = "https://different.example" - writeSyncReceipt(t, receiptPath, receipt) - }}, - {name: "library tampering", mutate: func(t *testing.T, libraryPath, _ string, _ config.InstallReceipt) { - if err := os.WriteFile(libraryPath, []byte("tampered"), 0o644); err != nil { - t.Fatal(err) - } - }}, - } - for _, test := range tests { - suite.Run(test.name, func() { - t := suite.T() - root := t.TempDir() - first := filepath.Join(root, "first") - second := filepath.Join(root, "second") - suite.Require().NoError(os.MkdirAll(first, 0o755)) - suite.Require().NoError(os.MkdirAll(second, 0o755)) - t.Setenv("ADBC_DRIVER_PATH", first+string(filepath.ListSeparator)+second) - 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())) - - firstManifest := filepath.Join(first, "test-driver-1.toml") - suite.Require().NoError(os.Remove(firstManifest)) - archive, err := os.Open(filepath.Join("testdata", "test-driver-1.1.tar.gz")) - suite.Require().NoError(err) - _, err = config.InstallPackage(config.Config{Level: config.ConfigEnv, Location: second}, "test-driver-1", archive, config.ExpectedPackageMetadata{ - ID: "test-driver-1", Version: "1.1.0", Platform: config.PlatformTuple(), - SourceType: "registry", SourceIdentity: testRegistry.BaseURL.String(), - }, config.InstallOptions{}) - suite.Require().NoError(err) - suite.NoError(archive.Close()) - - combined := config.Config{Level: config.ConfigEnv, Location: first + string(filepath.ListSeparator) + second} - installed, err := config.GetDriver(combined, "test-driver-1") - suite.Require().NoError(err) - suite.Equal(second, installed.FilePath) - libraryPath := installed.Driver.Shared.Get(config.PlatformTuple()) - receiptPath := filepath.Join(filepath.Dir(libraryPath), "dbc-install-receipt.json") - receiptBytes, err := os.ReadFile(receiptPath) - suite.Require().NoError(err) - var receipt config.InstallReceipt - suite.Require().NoError(json.Unmarshal(receiptBytes, &receipt)) - test.mutate(t, libraryPath, receiptPath, receipt) - - downloadCalls, registryCalls := 0, 0 - model := SyncCmd{Path: driverListPath, NoVerify: true}.GetModelCustom(baseModel{ - getDriverRegistry: func() ([]dbc.Driver, error) { - registryCalls++ - return nil, errors.New("exact lock 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) - - installed, err = config.GetDriver(config.Config{Level: config.ConfigEnv, Location: first + string(filepath.ListSeparator) + second}, "test-driver-1") - suite.Require().NoError(err) - suite.Equal(first, installed.FilePath, "repair should publish to the configured primary environment path") - finalLibrary := installed.Driver.Shared.Get(config.PlatformTuple()) - finalReceipt, managed, present, valid, err := config.InspectDriverInstallReceipt(config.Config{Level: config.ConfigEnv, Location: first + string(filepath.ListSeparator) + second}, installed) - suite.Require().NoError(err) - suite.True(managed) - suite.True(present) - suite.True(valid) - suite.True(config.VerifyInstallReceiptLibraryIntegrity(finalLibrary, finalReceipt)) - }) - } -} - func (suite *SubcommandTestSuite) TestSyncPostDownloadReceiptMatchControlsSkip() { for _, test := range []struct { name string @@ -2836,8 +2148,8 @@ func (suite *SubcommandTestSuite) TestSyncPostDownloadReceiptMatchControlsSkip() return downloadTestPkg(pkg) }, }).(syncModel) - model.worker.hooks.ensurePackage = func(ctx context.Context, cfg config.Config, driver string, expected config.ExpectedPackageMetadata, options config.InstallOptions, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { - result, err := config.EnsurePackage(ctx, cfg, driver, expected, options, callbacks) + 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++ } @@ -2868,128 +2180,6 @@ func (suite *SubcommandTestSuite) TestSyncPostDownloadReceiptMatchControlsSkip() } } -func (suite *SubcommandTestSuite) TestSyncRechecksPreparedReceiptBeforeSkipping() { - 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())) - 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()) - - downloadCalls, installCalls := 0, 0 - model := SyncCmd{Path: driverListPath, NoVerify: true}.GetModelCustom(baseModel{ - getDriverRegistry: func() ([]dbc.Driver, error) { - return nil, errors.New("exact lock replay must not discover registries") - }, - downloadPkg: func(pkg dbc.PkgInfo) (*os.File, error) { - downloadCalls++ - return downloadTestPkg(pkg) - }, - }).(syncModel) - model.worker.hooks.duringPrepare = func(_ context.Context, _ int, _ installItem) error { - current, err := config.GetDriver(cfg, "test-driver-1") - if err != nil { - return err - } - return config.UninstallDriver(cfg, current) - } - model.worker.hooks.ensurePackage = func(ctx context.Context, cfg config.Config, driver string, expected config.ExpectedPackageMetadata, options config.InstallOptions, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { - result, err := config.EnsurePackage(ctx, cfg, driver, expected, options, callbacks) - if result.Manifest != nil { - installCalls++ - } - return result, err - } - suite.runCmd(model) - suite.Equal(1, downloadCalls, "a prepared fast-skip hint that became stale must lazily fetch the exact locked artifact") - suite.Equal(1, installCalls) - after, err := config.GetDriver(cfg, "test-driver-1") - suite.Require().NoError(err) - suite.NotEqual(beforeLibrary, after.Driver.Shared.Get(config.PlatformTuple())) -} - -func (suite *SubcommandTestSuite) TestSyncSkipsExactCandidateInstalledDuringArchiveProvider() { - 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())) - cfg := config.Config{Level: config.ConfigEnv, Location: root} - current, err := config.GetDriver(cfg, "test-driver-1") - suite.Require().NoError(err) - libraryPath := current.Driver.Shared.Get(config.PlatformTuple()) - receiptPath := filepath.Join(filepath.Dir(libraryPath), "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://stale.example" - writeSyncReceipt(suite.T(), receiptPath, receipt) - - downloadCalls, directInstalls, ensureInstalls := 0, 0, 0 - var predicateCalls int - model := SyncCmd{Path: driverListPath, NoVerify: true}.GetModelCustom(baseModel{ - getDriverRegistry: func() ([]dbc.Driver, error) { - return nil, errors.New("exact lock replay must not discover registries") - }, - 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, options config.InstallOptions, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { - currentMatches := callbacks.CurrentMatches - callbacks.CurrentMatches = func(current *config.DriverInfo) (bool, error) { - predicateCalls++ - return currentMatches(current) - } - archiveProvider := callbacks.Archive - callbacks.Archive = func(ctx context.Context) (*os.File, error) { - archive, err := archiveProvider(ctx) - if err != nil { - return nil, err - } - concurrentArchive, err := os.Open(archive.Name()) - if err != nil { - return nil, err - } - _, installErr := config.InstallPackage(cfg, driver, concurrentArchive, expected, options) - closeErr := concurrentArchive.Close() - if installErr != nil { - return nil, installErr - } - if closeErr != nil { - return nil, closeErr - } - directInstalls++ - return archive, nil - } - result, err := config.EnsurePackage(ctx, cfg, driver, expected, options, callbacks) - if result.Manifest != nil { - ensureInstalls++ - } - return result, err - } - run := startSyncProgram(model) - resultModel := waitSyncProgram(suite.T(), run) - suite.Require().NoError(resultModel.Err(), run.output.String()) - suite.Equal(1, downloadCalls) - suite.Equal(1, directInstalls) - suite.Equal(0, ensureInstalls, "phase two must skip after the provider's concurrent exact install") - suite.Equal(2, predicateCalls, "phase one and phase two must both inspect current registration") - after, err := config.GetDriver(cfg, "test-driver-1") - suite.Require().NoError(err) - finalReceipt, managed, present, valid, err := config.InspectDriverInstallReceipt(cfg, after) - suite.Require().NoError(err) - suite.True(managed) - suite.True(present) - suite.True(valid) - suite.True(config.InstallReceiptMatchesRuntimeRegistration(finalReceipt, after, config.PlatformTuple())) -} - type syncProgramRun struct { program *tea.Program done chan struct{} @@ -3063,7 +2253,8 @@ func (suite *SubcommandTestSuite) TestSyncCancellationWaitsForWorkerCleanup() { var releaseOnce sync.Once unblock := func() { releaseOnce.Do(func() { close(release) }) } defer unblock() - var downloadedArchive, preparedArchive *os.File + 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" { @@ -3082,7 +2273,9 @@ func (suite *SubcommandTestSuite) TestSyncCancellationWaitsForWorkerCleanup() { }, }).(syncModel) model.worker.hooks.duringPrepare = func(_ context.Context, _ int, item installItem) error { - preparedArchive = item.Archive.File + if item.Validation != nil { + preparedPayload = item.Validation.Prepared + } return nil } block := func(context.Context) error { @@ -3093,25 +2286,22 @@ func (suite *SubcommandTestSuite) TestSyncCancellationWaitsForWorkerCleanup() { switch stage { case "prepare": model.worker.hooks.duringPrepare = func(ctx context.Context, _ int, item installItem) error { - preparedArchive = item.Archive.File + 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, options config.InstallOptions, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { - archiveProvider := callbacks.Archive - callbacks.Archive = func(ctx context.Context) (*os.File, error) { - file, err := archiveProvider(ctx) - if err != nil { - return nil, err - } - preparedArchive = file - close(entered) - <-release - return nil, ctx.Err() + 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 } - return config.EnsurePackage(ctx, cfg, driver, expected, options, callbacks) + close(entered) + <-release + return config.EnsurePackageResult{}, ctx.Err() } } @@ -3135,10 +2325,9 @@ func (suite *SubcommandTestSuite) TestSyncCancellationWaitsForWorkerCleanup() { suite.Nil(downloadedArchive) case "download": _, statErr = downloadedArchive.Stat() - suite.NoError(statErr, "source response stays open until the interrupted snapshot completes") - default: - _, statErr = preparedArchive.Stat() - suite.NoError(statErr, "prepared snapshot stays open until the worker exits") + 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) @@ -3172,12 +2361,17 @@ func (suite *SubcommandTestSuite) TestSyncCancellationWaitsForWorkerCleanup() { } suite.Equal(1, errorEnvelopes, "cancel should emit one terminal JSON error envelope") waitSyncTestSignal(suite.T(), model.worker.done) - if preparedArchive != nil { - assertFileClosed(suite.T(), preparedArchive) - } if stage == "download" { assertFileClosed(suite.T(), downloadedArchive) } + if preparedPayload != nil { + suite.NoError(preparedPayload.Close(), "prepared payload should already have been closed by worker cleanup") + } + entries, readErr := os.ReadDir(suite.Dir()) + suite.Require().NoError(readErr) + for _, entry := range entries { + suite.False(strings.HasPrefix(entry.Name(), ".dbc-install-"), "cancellation leaked staging workspace %q", entry.Name()) + } projectLock, lockErr := fslock.Acquire(projectLockPath, time.Second) suite.NoError(lockErr) if lockErr == nil { @@ -3330,8 +2524,8 @@ func (suite *SubcommandTestSuite) TestSyncSerializesSameProjectUntilWorkerFinish return downloadTestPkg(pkg) }, }).(syncModel) - second.worker.hooks.ensurePackage = func(ctx context.Context, cfg config.Config, driver string, expected config.ExpectedPackageMetadata, options config.InstallOptions, callbacks config.EnsurePackageCallbacks) (config.EnsurePackageResult, error) { - result, err := config.EnsurePackage(ctx, cfg, driver, expected, options, callbacks) + 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) } @@ -3380,32 +2574,6 @@ func (suite *SubcommandTestSuite) copyArchiveForSyncTest(source string) string { return output.Name() } -func (suite *SubcommandTestSuite) writeV2MetadataArchive(path string) { - suite.T().Helper() - metadata := []byte(fmt.Sprintf(`package_version = 2 -id = "test-driver-1" -name = "Test Driver 1" -version = "1.1.0" -platform = %q - -[Driver] -entrypoint = "AdbcDriverTestInit" - -[Files] -driver = "driver.so" -`, config.PlatformTuple())) - file, err := os.Create(path) - suite.Require().NoError(err) - gzipWriter := gzip.NewWriter(file) - tarWriter := tar.NewWriter(gzipWriter) - suite.Require().NoError(tarWriter.WriteHeader(&tar.Header{Name: "dbc-package.toml", Mode: 0600, Size: int64(len(metadata)), Typeflag: tar.TypeReg})) - _, err = tarWriter.Write(metadata) - suite.Require().NoError(err) - suite.Require().NoError(tarWriter.Close()) - suite.Require().NoError(gzipWriter.Close()) - suite.Require().NoError(file.Close()) -} - type syncInjectedMessageModel struct { model syncModel message tea.Msg @@ -3445,69 +2613,42 @@ func (m syncInjectedMessageModel) FinalOutput() string { func (m syncInjectedMessageModel) IsJSONMode() bool { return m.model.IsJSONMode() } func (suite *SubcommandTestSuite) TestSyncTerminalErrorsUseSingleOutputContract() { - for _, failure := range []string{"generic", "checksum-read", "checksum-mismatch"} { - for _, mode := range []string{"plain", "json", "json-stream"} { - suite.Run(failure+"/"+mode, func() { - var ( - message tea.Msg - wantErr error - code string - ) - switch failure { - case "generic": - wantErr = errors.New("injected sync failure") - message = wantErr - code = "sync_failed" - case "checksum-read": - missingPath := filepath.Join(suite.T().TempDir(), "missing-driver.so") - _, wantErr = checksum(missingPath) - info := config.DriverInfo{ID: "example", Version: semver.MustParse("1.0.0")} - info.Driver.Shared.Set(config.PlatformTuple(), missingPath) - message = installedDrvMsg{info: info, item: installItem{Platform: config.PlatformTuple()}} - code = "checksum_failed" - case "checksum-mismatch": - libraryPath := filepath.Join(suite.T().TempDir(), "driver.so") - suite.Require().NoError(os.WriteFile(libraryPath, []byte("installed library"), 0600)) - info := config.DriverInfo{ID: "example", Version: semver.MustParse("1.0.0")} - info.Driver.Shared.Set(config.PlatformTuple(), libraryPath) - item := installItem{Platform: config.PlatformTuple(), InstalledLibraryHash: strings.Repeat("0", 64)} - message = installedDrvMsg{info: info, item: item} - wantErr = errors.New("installed library checksum does not match validated package") - code = "checksum_failed" - } + 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") - 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 - } + 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) - }) - } + 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/config/ensure_package_test.go b/config/ensure_package_test.go index 346541f2..268ea202 100644 --- a/config/ensure_package_test.go +++ b/config/ensure_package_test.go @@ -46,10 +46,17 @@ func installEnsureFixture(t *testing.T, cfg Config, version, source string, arch return info } -func packageFileProvider(t *testing.T, data []byte) (*os.File, func(context.Context) (*os.File, error)) { +func packagePreparer(t *testing.T, cfg Config, runtimeID string, expected ExpectedPackageMetadata, data []byte, options InstallOptions) func(context.Context) (*PreparedPackage, error) { t.Helper() - file := writeInstallArchive(t, data, "ensure-candidate") - return file, func(context.Context) (*os.File, error) { return file, nil } + 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 { @@ -58,7 +65,7 @@ func expectedEnsurePackage(id, version, source string, archive []byte) ExpectedP return expected } -func TestEnsurePackageSkipsWithoutRequestingArchive(t *testing.T) { +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")) @@ -66,14 +73,14 @@ func TestEnsurePackageSkipsWithoutRequestingArchive(t *testing.T) { providerCalls := 0 currentMatchesCalls := 0 var validated EnsurePackageResult - result, err := EnsurePackage(context.Background(), cfg, "example", installExpected("example", "source", archive), InstallOptions{}, EnsurePackageCallbacks{ + 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 }, - Archive: func(context.Context) (*os.File, error) { + Prepare: func(context.Context) (*PreparedPackage, error) { providerCalls++ - return nil, errors.New("archive provider must not run for a skip") + return nil, errors.New("package preparer must not run for a skip") }, ValidateResult: func(result EnsurePackageResult) error { validated = result @@ -84,7 +91,7 @@ func TestEnsurePackageSkipsWithoutRequestingArchive(t *testing.T) { t.Fatal(err) } if !result.Skipped || providerCalls != 0 || currentMatchesCalls != 1 { - t.Fatalf("EnsurePackage result skipped=%t provider calls=%d CurrentMatches calls=%d, want skip, zero provider calls, and one predicate call", result.Skipped, providerCalls, currentMatchesCalls) + 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) @@ -94,19 +101,19 @@ func TestEnsurePackageSkipsWithoutRequestingArchive(t *testing.T) { } } -func TestEnsurePackageInstallsExactProvidedArchiveAndLeavesItOpen(t *testing.T) { +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")) - archiveFile, provider := packageFileProvider(t, newArchive) providerCalls := 0 - result, err := EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "new-source", newArchive), InstallOptions{}, EnsurePackageCallbacks{ + 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 }, - Archive: func(ctx context.Context) (*os.File, error) { + Prepare: func(ctx context.Context) (*PreparedPackage, error) { providerCalls++ - return provider(ctx) + 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 { @@ -130,10 +137,101 @@ func TestEnsurePackageInstallsExactProvidedArchiveAndLeavesItOpen(t *testing.T) 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) } - if _, err := archiveFile.Stat(); err != nil { - t.Fatalf("EnsurePackage closed the caller-owned archive: %v", err) +} + +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) } - _ = archiveFile.Close() } func TestEnsurePackageErrorsBeforeMutationAndReportsPostInstallValidationFailure(t *testing.T) { @@ -149,11 +247,11 @@ func TestEnsurePackageErrorsBeforeMutationAndReportsPostInstallValidationFailure callbackErr := errors.New("predicate failed") providerCalls := 0 - _, err = EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "new-source", newArchive), InstallOptions{}, EnsurePackageCallbacks{ + _, err = EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "new-source", newArchive), EnsurePackageCallbacks{ CurrentMatches: func(*DriverInfo) (bool, error) { return false, callbackErr }, - Archive: func(context.Context) (*os.File, error) { + Prepare: func(context.Context) (*PreparedPackage, error) { providerCalls++ - return writeInstallArchive(t, newArchive, "unused"), nil + return nil, errors.New("preparer must not run after CurrentMatches failure") }, }) if !errors.Is(err, callbackErr) || providerCalls != 0 { @@ -161,9 +259,9 @@ func TestEnsurePackageErrorsBeforeMutationAndReportsPostInstallValidationFailure } providerErr := errors.New("provider failed") - providerResult, err := EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "new-source", newArchive), InstallOptions{}, EnsurePackageCallbacks{ + providerResult, err := EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "new-source", newArchive), EnsurePackageCallbacks{ CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, - Archive: func(context.Context) (*os.File, error) { return nil, providerErr }, + 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) @@ -174,10 +272,9 @@ func TestEnsurePackageErrorsBeforeMutationAndReportsPostInstallValidationFailure } validationErr := errors.New("post-install validation failed") - archiveFile, provider := packageFileProvider(t, newArchive) - result, err := EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "new-source", newArchive), InstallOptions{}, EnsurePackageCallbacks{ + result, err := EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "new-source", newArchive), EnsurePackageCallbacks{ CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, - Archive: provider, + 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") @@ -197,10 +294,6 @@ func TestEnsurePackageErrorsBeforeMutationAndReportsPostInstallValidationFailure t.Fatalf("driver lock remained held after validation error: %v", err) } release() - if _, err := archiveFile.Stat(); err != nil { - t.Fatalf("EnsurePackage closed caller archive after validation error: %v", err) - } - _ = archiveFile.Close() } func TestEnsurePackageRechecksRegistrationAfterAcquiringDriverLock(t *testing.T) { @@ -231,13 +324,13 @@ func TestEnsurePackageRechecksRegistrationAfterAcquiringDriverLock(t *testing.T) providerCalls := 0 go func() { close(started) - result, err := EnsurePackage(context.Background(), cfg, "example", installExpected("example", "source", archive), InstallOptions{}, EnsurePackageCallbacks{ + 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 }, - Archive: func(context.Context) (*os.File, error) { + Prepare: func(context.Context) (*PreparedPackage, error) { providerCalls++ - return nil, errors.New("provider must not run") + return nil, errors.New("preparer must not run") }, }) done <- struct { @@ -295,9 +388,8 @@ func TestEnsurePackageCancellationReleasesPartiallyAcquiredLocks(t *testing.T) { earlyLockAcquired := make(chan struct{}, 1) done := make(chan error, 1) go func() { - _, err := ensurePackageWithLockObserver(ctx, cfg, "example", ExpectedPackageMetadata{ID: "example"}, InstallOptions{}, EnsurePackageCallbacks{ + _, err := ensurePackageWithLockObserver(ctx, cfg, "example", ExpectedPackageMetadata{ID: "example"}, EnsurePackageCallbacks{ CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, - Archive: func(context.Context) (*os.File, error) { return nil, errors.New("provider must not run") }, }, func(root string) { if root == early { earlyLockAcquired <- struct{}{} @@ -365,15 +457,15 @@ func TestEnsurePackageCancellationDuringPhaseTwoLockAcquisitionReleasesPartialLo done := make(chan ensureOutcome, 1) currentMatchesCalls := 0 go func() { - result, err := ensurePackageWithLockObserver(ctx, cfg, "example", expectedEnsurePackage("example", "2.0.0", "new", archiveData), InstallOptions{}, EnsurePackageCallbacks{ + result, err := ensurePackageWithLockObserver(ctx, cfg, "example", expectedEnsurePackage("example", "2.0.0", "new", archiveData), EnsurePackageCallbacks{ CurrentMatches: func(*DriverInfo) (bool, error) { currentMatchesCalls++ return false, nil }, - Archive: func(context.Context) (*os.File, error) { + Prepare: func(ctx context.Context) (*PreparedPackage, error) { close(providerStarted) <-releaseProvider - return archiveFile, nil + return packagePreparer(t, cfg, "example", expectedEnsurePackage("example", "2.0.0", "new", archiveData), archiveData, InstallOptions{})(ctx) }, }, func(path string) { if path == early { @@ -448,12 +540,12 @@ func TestEnsurePackageObserverReportsBothLockingPhases(t *testing.T) { defer archiveFile.Close() observed := make(chan string, 4) currentMatchesCalls := 0 - result, err := ensurePackageWithLockObserver(context.Background(), cfg, "example", expectedEnsurePackage("example", "1.0.0", "source", archiveData), InstallOptions{}, EnsurePackageCallbacks{ + result, err := ensurePackageWithLockObserver(context.Background(), cfg, "example", expectedEnsurePackage("example", "1.0.0", "source", archiveData), EnsurePackageCallbacks{ CurrentMatches: func(*DriverInfo) (bool, error) { currentMatchesCalls++ return false, nil }, - Archive: func(context.Context) (*os.File, error) { return archiveFile, nil }, + Prepare: packagePreparer(t, cfg, "example", expectedEnsurePackage("example", "1.0.0", "source", archiveData), archiveData, InstallOptions{}), }, func(path string) { observed <- path }) @@ -494,9 +586,9 @@ func TestEnsurePackageCancellationHonorsProviderAndWaitsForInstall(t *testing.T) err error }, 1) go func() { - result, err := EnsurePackage(ctx, cfg, "example", expectedEnsurePackage("example", "2.0.0", "new", newArchive), InstallOptions{}, EnsurePackageCallbacks{ + result, err := EnsurePackage(ctx, cfg, "example", expectedEnsurePackage("example", "2.0.0", "new", newArchive), EnsurePackageCallbacks{ CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, - Archive: func(providerCtx context.Context) (*os.File, error) { + Prepare: func(providerCtx context.Context) (*PreparedPackage, error) { close(providerStarted) <-providerCtx.Done() return nil, providerCtx.Err() @@ -548,28 +640,26 @@ func TestEnsurePackageCancellationHonorsProviderAndWaitsForInstall(t *testing.T) 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")) - archiveFile := writeInstallArchive(t, newArchive, "cancelled-install") ctx, cancel := context.WithCancel(context.Background()) defer cancel() - verifyStarted := make(chan struct{}) - releaseVerify := make(chan struct{}) + validationStarted := make(chan struct{}) + releaseValidation := make(chan struct{}) var releaseOnce sync.Once - unblockVerify := func() { releaseOnce.Do(func() { close(releaseVerify) }) } - defer unblockVerify() + 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), InstallOptions{ - Verify: func(string, Manifest) error { - close(verifyStarted) - <-releaseVerify + 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 }, - }, EnsurePackageCallbacks{ - CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, - Archive: func(context.Context) (*os.File, error) { return archiveFile, nil }, }) done <- struct { result EnsurePackageResult @@ -577,21 +667,21 @@ func TestEnsurePackageCancellationHonorsProviderAndWaitsForInstall(t *testing.T) }{result, err} }() select { - case <-verifyStarted: + case <-validationStarted: case <-time.After(5 * time.Second): - t.Fatal("EnsurePackage did not enter install verification") + 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 install was blocked: %v", lockErr) + t.Fatalf("driver lock was not held while result validation was blocked: %v", lockErr) } if lockRelease != nil { lockRelease() } - unblockVerify() + unblockValidation() select { case result := <-done: if !errors.Is(result.err, context.Canceled) || result.result.Installed == nil || result.result.Installed.Version.String() != "2.0.0" { @@ -604,10 +694,6 @@ func TestEnsurePackageCancellationHonorsProviderAndWaitsForInstall(t *testing.T) if err != nil || current.Version.String() != "2.0.0" { t.Fatalf("completed install was not retained after cancellation: current=%#v error=%v", current, err) } - if _, err := archiveFile.Stat(); err != nil { - t.Fatalf("EnsurePackage closed caller-owned archive: %v", err) - } - _ = archiveFile.Close() }) } @@ -634,21 +720,21 @@ func TestEnsurePackageUsesConfigEnvPrecedenceAndExplicitlyRejectsMissingRoots(t 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), InstallOptions{}, EnsurePackageCallbacks{ + 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 }, - Archive: func(context.Context) (*os.File, error) { + Prepare: func(context.Context) (*PreparedPackage, error) { providerCalls++ - return nil, errors.New("provider must not run") + 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 provider calls=%d error=%v", result, providerCalls, err) + 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), InstallOptions{}, EnsurePackageCallbacks{ + _, 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") { @@ -656,29 +742,28 @@ func TestEnsurePackageUsesConfigEnvPrecedenceAndExplicitlyRejectsMissingRoots(t } } -func TestEnsurePackageArchiveProviderDoesNotBlockConcurrentUninstall(t *testing.T) { +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")) - archiveFile := writeInstallArchive(t, newArchive, "blocked-provider") - providerStarted := make(chan struct{}) - releaseProvider := make(chan struct{}) + prepareStarted := make(chan struct{}) + releasePrepare := make(chan struct{}) var releaseOnce sync.Once - unblockProvider := func() { releaseOnce.Do(func() { close(releaseProvider) }) } - defer unblockProvider() + 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), InstallOptions{}, EnsurePackageCallbacks{ + result, err := EnsurePackage(context.Background(), cfg, "example", expectedEnsurePackage("example", "2.0.0", "new", newArchive), EnsurePackageCallbacks{ CurrentMatches: func(*DriverInfo) (bool, error) { return false, nil }, - Archive: func(context.Context) (*os.File, error) { - close(providerStarted) - <-releaseProvider - return archiveFile, 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 { @@ -687,41 +772,39 @@ func TestEnsurePackageArchiveProviderDoesNotBlockConcurrentUninstall(t *testing. }{result, err} }() select { - case <-providerStarted: + case <-prepareStarted: case <-time.After(5 * time.Second): - t.Fatal("EnsurePackage did not reach archive provider") + 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) } - unblockProvider() + 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 archive provider returned") + 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) } - if _, err := archiveFile.Stat(); err != nil { - t.Fatalf("EnsurePackage closed caller-owned archive: %v", err) - } - _ = archiveFile.Close() } -func TestEnsurePackageRechecksConcurrentInstallAfterArchiveRetrieval(t *testing.T) { +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-provider") + 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 @@ -733,7 +816,7 @@ func TestEnsurePackageRechecksConcurrentInstallAfterArchiveRetrieval(t *testing. }, 1) currentMatchesCalls := 0 go func() { - result, err := EnsurePackage(context.Background(), cfg, "example", expected, InstallOptions{}, EnsurePackageCallbacks{ + 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" { @@ -753,10 +836,13 @@ func TestEnsurePackageRechecksConcurrentInstallAfterArchiveRetrieval(t *testing. receipt.ArchiveSize == expected.ArchiveSize && VerifyInstallReceiptLibraryIntegrity(current.Driver.Shared.Get(PlatformTuple()), receipt), nil }, - Archive: func(context.Context) (*os.File, error) { + Prepare: func(ctx context.Context) (*PreparedPackage, error) { close(providerStarted) <-releaseProvider - return ensuredFile, nil + defer ensuredFile.Close() + validation, err := PreparePackage(cfg, "example", ensuredFile, expected, InstallOptions{}) + prepared = validation.Prepared + return validation.Prepared, err }, }) ensureDone <- struct { @@ -767,7 +853,7 @@ func TestEnsurePackageRechecksConcurrentInstallAfterArchiveRetrieval(t *testing. select { case <-providerStarted: case <-time.After(5 * time.Second): - t.Fatal("EnsurePackage did not reach archive provider") + 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) @@ -783,7 +869,7 @@ func TestEnsurePackageRechecksConcurrentInstallAfterArchiveRetrieval(t *testing. 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 provider returned") + 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) @@ -792,11 +878,12 @@ func TestEnsurePackageRechecksConcurrentInstallAfterArchiveRetrieval(t *testing. 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 _, err := ensuredFile.Stat(); err != nil { - t.Fatalf("EnsurePackage closed the caller-owned archive after phase-two skip: %v", 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) } - _ = ensuredFile.Close() - _ = concurrentFile.Close() } func TestPackageInstallRootKeyCaseSensitivity(t *testing.T) { diff --git a/config/package_archive.go b/config/package_archive.go index 2e6d8529..bbb3c479 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -90,12 +90,90 @@ type InstallOptions struct { // driver library file. type PackageValidation struct { VerifiedLibraryHash string + ArchiveHash string + ArchiveSize int64 PackageVersion int 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, size, and package version 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 { @@ -381,18 +459,20 @@ func InstallPackage(cfg Config, runtimeID string, downloaded *os.File, expected // 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 obtaining the archive. Archive runs at most once and outside the +// 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. Archive +// 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) - Archive func(context.Context) (*os.File, 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 } @@ -410,31 +490,36 @@ type EnsurePackageResult struct { // EnsurePackage checks the effective runtime registration under all // configured driver install locks. If it does not match, EnsurePackage -// releases those locks, obtains one archive, reacquires all locks, and checks +// releases those locks, prepares one package, reacquires all locks, and checks // the latest registration again before deciding to skip or install. This -// prevents archive retrieval from blocking unrelated driver operations while +// prevents package preparation from blocking unrelated driver operations while // ensuring no stale registration snapshot is used for the final decision. -// The archive is caller-owned and is never closed here, including when phase +// 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. Archive +// 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 archive retrieval and again against the latest registration after. +// before preparation and again against the latest registration after. // Result.Current is the most recently inspected registration: it remains the -// phase-one snapshot on provider or phase-two lock acquisition failure. +// 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, installOptions InstallOptions, callbacks EnsurePackageCallbacks) (EnsurePackageResult, error) { - return ensurePackageWithLockObserver(ctx, cfg, runtimeID, expected, installOptions, callbacks, nil) +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, installOptions InstallOptions, callbacks EnsurePackageCallbacks, lockObserver func(root string)) (EnsurePackageResult, error) { - var result EnsurePackageResult +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") } @@ -475,20 +560,17 @@ func ensurePackageWithLockObserver(ctx context.Context, cfg Config, runtimeID st if err := ctx.Err(); err != nil { return result, err } - if callbacks.Archive == nil { - return result, errors.New("package archive provider is nil") + if callbacks.Prepare == nil { + return result, errors.New("package preparer is nil") } - archive, providerErr := callbacks.Archive(ctx) - if err := ctx.Err(); err != nil { - return result, err - } - if providerErr != nil { - return result, fmt.Errorf("could not provide package archive: %w", providerErr) + prepared, err = callbacks.Prepare(ctx) + if err != nil { + return result, fmt.Errorf("could not prepare package: %w", err) } - if archive == nil { - return result, errors.New("package archive provider returned nil") + if prepared == nil { + return result, errors.New("package preparer returned nil") } - if err := ctx.Err(); err != nil { + if err := prepared.claim(primary, runtimeID, expected); err != nil { return result, err } @@ -511,7 +593,7 @@ func ensurePackageWithLockObserver(ctx context.Context, cfg Config, runtimeID st if err := ctx.Err(); err != nil { return result, err } - manifest, previous, err := installPackageLocked(cfg, runtimeID, primary, archive, expected, installOptions, CreateManifest, cleanupOwnedPackageDirectories) + manifest, previous, err := installPreparedPackageLocked(cfg, runtimeID, primary, expected, prepared, CreateManifest, cleanupOwnedPackageDirectories) if err != nil { return result, err } @@ -727,11 +809,82 @@ func cloneDriverInfo(info *DriverInfo) *DriverInfo { return &clone } -// ValidatePackage verifies and stages an already-downloaded package in a -// private temporary directory without registering it or changing shared -// configuration. The temporary directory is removed before ValidatePackage -// returns. The archive remains open and can be passed to InstallPackage after -// validation. +// 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") + } + if manifest.PackageVersion == 2 && requested.ArchiveHash == "" { + return PackageValidation{}, errors.New("package v2 requires archive hash and size metadata") + } + finalExpected := requested + finalExpected.PackageVersion = manifest.PackageVersion + finalExpected.ArchiveHash = receipt.ArchiveHash + finalExpected.ArchiveSize = receipt.ArchiveSize + 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, + PackageVersion: manifest.PackageVersion, Registration: manifest.DriverInfo, + RegistrationFingerprintAlgorithm: receipt.RegistrationFingerprintAlgorithm, + RegistrationFingerprintVersion: receipt.RegistrationFingerprintVersion, + RegistrationFingerprint: receipt.RegistrationFingerprint, + registrationSharedIdentity: sharedIdentity, + Prepared: prepared, + }, nil +} + +// ValidatePackage verifies an already-downloaded archive without retaining a +// staged payload. The archive remains open for the caller. func ValidatePackage(runtimeID string, downloaded *os.File, expected ExpectedPackageMetadata, options InstallOptions) (validation PackageValidation, err error) { if downloaded == nil { return PackageValidation{}, errors.New("package archive is nil") @@ -756,12 +909,14 @@ func ValidatePackage(runtimeID string, downloaded *os.File, expected ExpectedPac if err != nil { return PackageValidation{}, err } - receipt, ok := readPackageReceipt(workDir, runtimeID, payloadDir) + receipt, ok := readPackageReceiptEvidence(workDir, runtimeID, payloadDir) if !ok { return PackageValidation{}, errors.New("could not read validated package receipt") } return PackageValidation{ - VerifiedLibraryHash: receipt.InstalledLibraryHash, PackageVersion: manifest.PackageVersion, Registration: manifest.DriverInfo, + VerifiedLibraryHash: receipt.InstalledLibraryHash, + ArchiveHash: receipt.ArchiveHash, ArchiveSize: receipt.ArchiveSize, + PackageVersion: manifest.PackageVersion, Registration: manifest.DriverInfo, RegistrationFingerprintAlgorithm: receipt.RegistrationFingerprintAlgorithm, RegistrationFingerprintVersion: receipt.RegistrationFingerprintVersion, RegistrationFingerprint: receipt.RegistrationFingerprint, @@ -937,6 +1092,51 @@ func installPackageLocked(cfg Config, runtimeID, loc string, downloaded *os.File 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) } From 5ba9a90b0421fd17e5a16f0645628d42e2282b77 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 17:26:55 +0000 Subject: [PATCH 099/115] refactor(config): remove test-only package APIs --- cmd/dbc/install.go | 6 +- cmd/dbc/install_source.go | 2 +- cmd/dbc/lockfile_v2_test.go | 4 +- cmd/dbc/package_executor.go | 6 +- cmd/dbc/sync.go | 4 +- cmd/dbc/sync_test.go | 154 +++++++++++------------- config/install_transaction_test.go | 13 +- config/package_archive.go | 54 --------- config/package_archive_test.go | 97 +++++++++------ config/registration_fingerprint_test.go | 10 +- 10 files changed, 166 insertions(+), 184 deletions(-) diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index 610c24fa..0f3f1f44 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -296,14 +296,14 @@ func (m progressiveInstallModel) startDirectInstall(item installItem) (tea.Model } installCfg, err := installConfigForEnsure(executor.cfg) if err != nil { - if closeErr := closeDirectInstallArchive(&item); closeErr != 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 := closeDirectInstallArchive(&item); closeErr != nil { + if closeErr := closeDirectInstallResources(&item); closeErr != nil { err = errors.Join(err, fmt.Errorf("close prepared package: %w", closeErr)) } return err @@ -320,7 +320,7 @@ func (m progressiveInstallModel) startDirectEnsure(item installItem, executor *p m.state = stInstalling return m, func() tea.Msg { result, err := executor.ensurePreparedPackage(context.Background(), &item) - if closeErr := closeDirectInstallArchive(&item); closeErr != nil { + 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} diff --git a/cmd/dbc/install_source.go b/cmd/dbc/install_source.go index 91d565de..4be091af 100644 --- a/cmd/dbc/install_source.go +++ b/cmd/dbc/install_source.go @@ -192,7 +192,7 @@ func (reader *installProgressReadCloser) Read(p []byte) (int, error) { return n, err } -func closeDirectInstallArchive(item *installItem) error { +func closeDirectInstallResources(item *installItem) error { if item == nil { return nil } diff --git a/cmd/dbc/lockfile_v2_test.go b/cmd/dbc/lockfile_v2_test.go index fe6a3f35..64ee3bce 100644 --- a/cmd/dbc/lockfile_v2_test.go +++ b/cmd/dbc/lockfile_v2_test.go @@ -798,7 +798,7 @@ func TestResolvedTGZArtifactReplaysThroughPackageValidation(t *testing.T) { cfg: config.Config{Level: config.ConfigEnv, Location: t.TempDir()}} prepared, err := model.prepareInstallItems(context.Background(), []installItem{item}) require.NoError(t, err) - defer closePreparedArchives(prepared.items) + 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, @@ -834,7 +834,7 @@ func TestSyncOpensRelativePathArtifactFromProjectDirectory(t *testing.T) { cfg: config.Config{Level: config.ConfigEnv, Location: filepath.Join(projectDir, "install")}} prepared, err := model.prepareInstallItems(context.Background(), []installItem{item}) require.NoError(t, err) - defer closePreparedArchives(prepared.items) + 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") diff --git a/cmd/dbc/package_executor.go b/cmd/dbc/package_executor.go index f0d36970..ca9a96e1 100644 --- a/cmd/dbc/package_executor.go +++ b/cmd/dbc/package_executor.go @@ -259,7 +259,7 @@ func (e *packageExecutor) prepareItem(ctx context.Context, item *installItem) er needsArchive := item.AlreadyInstalled == nil || !canReuseLockedEntry(*item) if needsArchive { - if err := e.downloadAndValidateItem(ctx, item); err != nil { + if err := e.downloadAndPrepareItem(ctx, item); err != nil { return err } if sameVersionInstalled != nil { @@ -293,7 +293,7 @@ func (e *packageExecutor) prepareItem(ctx context.Context, item *installItem) er return nil } -func (e *packageExecutor) downloadAndValidateItem(ctx context.Context, item *installItem) error { +func (e *packageExecutor) downloadAndPrepareItem(ctx context.Context, item *installItem) error { if err := ctx.Err(); err != nil { return err } @@ -431,7 +431,7 @@ func (e *packageExecutor) ensurePreparedPackage(ctx context.Context, item *insta }, Prepare: func(ctx context.Context) (*config.PreparedPackage, error) { if item.Validation == nil || item.Validation.Prepared == nil { - if err := e.downloadAndValidateItem(ctx, item); err != nil { + if err := e.downloadAndPrepareItem(ctx, item); err != nil { return nil, err } } diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 23149561..fbeeabcd 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -1133,7 +1133,7 @@ func (s syncModel) runSyncWorker(worker *syncWorker) (result syncWorkerResultMsg prepared, err := s.prepareInstallItems(ctx, items) defer func() { - if cleanupErr := closePreparedArchives(prepared.items); cleanupErr != nil { + 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" @@ -1212,7 +1212,7 @@ func (s syncModel) runSyncWorker(worker *syncWorker) (result syncWorkerResultMsg return result } -func closePreparedArchives(items []installItem) error { +func closePreparedItems(items []installItem) error { var closeErr error for i := range items { if items[i].Archive != nil { diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index f3c7eb92..c2d6dc9c 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -226,7 +226,7 @@ func TestFreshPackslipResolutionSnapshotsAndReplaysWithoutDiscovery(t *testing.T prepared, err := model.prepareInstallItems(context.Background(), items) require.NoError(t, err) - defer closePreparedArchives(prepared.items) + defer closePreparedItems(prepared.items) require.Len(t, prepared.lock.Drivers, 1) locked := prepared.lock.Drivers[0] assert.Equal(t, "packslip", locked.Source.Type) @@ -283,7 +283,7 @@ func TestFreshPackslipResolutionSnapshotsAndReplaysWithoutDiscovery(t *testing.T assert.Equal(t, archiveSize, *replayedArtifact.Size) replayed, err := replay.prepareInstallItems(context.Background(), replayItems) require.NoError(t, err) - defer closePreparedArchives(replayed.items) + defer closePreparedItems(replayed.items) assert.Equal(t, 1, downloadCalls) assert.ElementsMatch(t, prepared.lock.Drivers[0].Artifacts, replayed.lock.Drivers[0].Artifacts) } @@ -294,6 +294,18 @@ func assertFileClosed(t *testing.T, file *os.File) { 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") @@ -322,7 +334,7 @@ func TestPathResolutionDerivesVersionAndUsesProjectRelativeArchive(t *testing.T) prepared, err := model.prepareInstallItems(context.Background(), items) require.NoError(t, err) - defer closePreparedArchives(prepared.items) + 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) @@ -351,7 +363,7 @@ func TestPathPackageV2MarkerSurvivesResolutionAndLockSnapshot(t *testing.T) { assert.Equal(t, "1.2.3", items[0].Release.Version) prepared, err := model.prepareInstallItems(context.Background(), items) require.NoError(t, err) - defer closePreparedArchives(prepared.items) + defer closePreparedItems(prepared.items) require.Len(t, prepared.lock.Drivers, 1) assert.Equal(t, 2, prepared.lock.Drivers[0].Artifacts[0].PackageVersion) expected, _, err := expectedSyncPackageMetadata(prepared.items[0]) @@ -374,7 +386,7 @@ func TestPathArchiveMutationAfterResolutionFailsBeforeCandidateLock(t *testing.T require.NoError(t, err) require.NoError(t, os.WriteFile(archivePath, append(archiveBytes, []byte("changed")...), 0o600)) prepared, err := model.prepareInstallItems(context.Background(), items) - defer closePreparedArchives(prepared.items) + defer closePreparedItems(prepared.items) require.ErrorContains(t, err, "package archive hash mismatch") assert.NoFileExists(t, model.LockFilePath) } @@ -410,7 +422,7 @@ func TestPathMetadataDerivedRefreshAdoptsArchiveVersionWithoutOldSnapshotProof(t prepared, err := model.prepareInstallItems(context.Background(), items) require.NoError(t, err) - defer closePreparedArchives(prepared.items) + 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()) @@ -1400,11 +1412,7 @@ func (suite *SubcommandTestSuite) TestSyncCandidateLockFailureDoesNotInstall() { suite.Require().NoError(err) suite.Equal(oldLock, newLock) assertFileClosed(suite.T(), downloadedArchive) - entries, err := os.ReadDir(suite.Dir()) - suite.Require().NoError(err) - for _, entry := range entries { - suite.False(strings.HasPrefix(entry.Name(), ".dbc-install-"), "candidate-save failure leaked prepared staging directory %q", entry.Name()) - } + assertNoPreparedWorkspaces(suite.T(), suite.Dir()) } func (suite *SubcommandTestSuite) TestSyncInstallFailureKeepsCompleteCandidateLock() { @@ -1517,8 +1525,10 @@ func (suite *SubcommandTestSuite) TestSyncLegacyLibraryProofUsesValidatedArchive ID: "test-driver-1", Version: "1.0.0", Platform: config.PlatformTuple(), SourceType: "registry", SourceIdentity: testRegistry.BaseURL.String(), } - validation, err := config.ValidatePackage("test-driver-1", archive, expected, config.InstallOptions{}) + 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) @@ -1582,7 +1592,7 @@ func (suite *SubcommandTestSuite) TestSyncLegacyManifestOnlyProofCompatibility() root, listPath, lockPath, archivePath, proofHash string oldLock []byte } - setup := func(t *testing.T, currentPath, candidatePath string, proofData, candidateData []byte, candidateExists bool, currentEntrypoint, candidateEntrypoint string, registered bool) fixture { + setup := func(t *testing.T, currentPath, candidatePath string, proofData, candidateData []byte) fixture { t.Helper() root := t.TempDir() t.Setenv("ADBC_DRIVER_PATH", root) @@ -1600,28 +1610,24 @@ func (suite *SubcommandTestSuite) TestSyncLegacyManifestOnlyProofCompatibility() t.Fatal(err) } } - if registered { - writeExternal(currentPath, proofData) - if err := os.WriteFile(filepath.Join(filepath.Dir(currentPath), "LICENSE"), []byte("external 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 = currentEntrypoint - current.Driver.Shared.Set(config.PlatformTuple(), currentPath) - if err := config.CreateManifest(config.Config{Level: config.ConfigEnv, Location: root}, current); 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 candidateExists { - if candidatePath != currentPath || !registered { - writeExternal(candidatePath, candidateData) - } + 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) @@ -1635,7 +1641,7 @@ func (suite *SubcommandTestSuite) TestSyncLegacyManifestOnlyProofCompatibility() t.Fatal(err) } archivePath := filepath.Join(t.TempDir(), "manifest-only.tar.gz") - archiveBytes := makeSyncManifestOnlyArchive(t, "1.1.0", candidatePath, candidateEntrypoint) + archiveBytes := makeSyncManifestOnlyArchive(t, "1.1.0", candidatePath, "DriverInit") if err := os.WriteFile(archivePath, archiveBytes, 0o600); err != nil { t.Fatal(err) } @@ -1645,7 +1651,7 @@ func (suite *SubcommandTestSuite) TestSyncLegacyManifestOnlyProofCompatibility() } } - run := func(t *testing.T, root, driverListPath, archivePath string, count *syncArchiveRunCounts, registry func() ([]dbc.Driver, error)) (string, error) { + 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) { @@ -1703,9 +1709,9 @@ func (suite *SubcommandTestSuite) TestSyncLegacyManifestOnlyProofCompatibility() t := suite.T() path := filepath.Join(t.TempDir(), "external", "library.so") data := []byte("legacy external library") - fixture := setup(t, path, path, data, data, true, "DriverInit", "DriverInit", true) + fixture := setup(t, path, path, data, data) counts := &syncArchiveRunCounts{} - _, err := run(t, fixture.root, fixture.listPath, fixture.archivePath, counts, getTestDriverRegistry) + _, err := run(t, fixture.listPath, fixture.archivePath, counts, getTestDriverRegistry) suite.NoError(err) suite.Equal(syncArchiveRunCounts{registry: 1, download: 1, install: 0}, *counts) updated, err := loadLockFile(fixture.lockPath) @@ -1724,7 +1730,7 @@ func (suite *SubcommandTestSuite) TestSyncLegacyManifestOnlyProofCompatibility() suite.NotEqual(fixture.oldLock, lockAfterMigration) replayCounts := &syncArchiveRunCounts{} - _, err = run(t, fixture.root, fixture.listPath, fixture.archivePath, replayCounts, func() ([]dbc.Driver, error) { + _, 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) @@ -1734,48 +1740,36 @@ func (suite *SubcommandTestSuite) TestSyncLegacyManifestOnlyProofCompatibility() suite.Equal(lockAfterMigration, lockAfterReplay) }) - for _, test := range []struct { - name string - candidateData []byte - candidateFound bool - }{ - {name: "candidate bytes mismatch", candidateData: []byte("not the legacy library"), candidateFound: true}, - } { - suite.Run(test.name, 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") - fixture := setup(t, currentPath, candidatePath, proofData, test.candidateData, test.candidateFound, "DriverInit", "DriverInit", true) - beforeManifest, err := os.ReadFile(filepath.Join(fixture.root, "test-driver-1.toml")) - suite.Require().NoError(err) - counts := &syncArchiveRunCounts{} - out, err := run(t, fixture.root, 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) - if test.candidateFound { - candidateAfter, err := os.ReadFile(candidatePath) - suite.Require().NoError(err) - suite.Equal(test.candidateData, candidateAfter) - } else { - _, err := os.Stat(candidatePath) - suite.ErrorIs(err, os.ErrNotExist) - } - }) - } + 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) + }) } @@ -2367,11 +2361,7 @@ func (suite *SubcommandTestSuite) TestSyncCancellationWaitsForWorkerCleanup() { if preparedPayload != nil { suite.NoError(preparedPayload.Close(), "prepared payload should already have been closed by worker cleanup") } - entries, readErr := os.ReadDir(suite.Dir()) - suite.Require().NoError(readErr) - for _, entry := range entries { - suite.False(strings.HasPrefix(entry.Name(), ".dbc-install-"), "cancellation leaked staging workspace %q", entry.Name()) - } + assertNoPreparedWorkspaces(suite.T(), suite.Dir()) projectLock, lockErr := fslock.Acquire(projectLockPath, time.Second) suite.NoError(lockErr) if lockErr == nil { diff --git a/config/install_transaction_test.go b/config/install_transaction_test.go index 0d0d4fc7..b3123817 100644 --- a/config/install_transaction_test.go +++ b/config/install_transaction_test.go @@ -346,7 +346,7 @@ func TestInspectDriverInstallReceiptUsesSelectedEnvironmentPath(t *testing.T) { } } -func TestValidatePackageReturnsAndComparesRuntimeRegistration(t *testing.T) { +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 { @@ -362,11 +362,20 @@ shared = %q `, externalLibrary)) archive := makeInstallArchiveWithEntries(t, installArchiveEntry{name: "MANIFEST", data: manifest}) file := writeInstallArchive(t, archive, "external-registration") - validation, err := ValidatePackage("example", file, installExpected("example", "source", archive), InstallOptions{}) + 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) } diff --git a/config/package_archive.go b/config/package_archive.go index bbb3c479..07f541b8 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -435,19 +435,6 @@ func decodeDriverShared(value any, required bool) (driverMap, error) { return result, nil } -// InstallPackageArchive verifies and installs an already-downloaded package. -// The expected metadata must describe the resolution that selected the -// archive. The archive remains open for the caller. -func InstallPackageArchive(cfg Config, downloaded *os.File, expected ExpectedPackageMetadata) (Manifest, error) { - if downloaded == nil { - return Manifest{}, errors.New("package archive is nil") - } - if err := validateExpectedPackage(expected); err != nil { - return Manifest{}, err - } - return installPackageArchive(cfg, expected.ID, expected.ID, downloaded, expected) -} - // 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 @@ -883,47 +870,6 @@ func PreparePackage(cfg Config, runtimeID string, downloaded *os.File, expected }, nil } -// ValidatePackage verifies an already-downloaded archive without retaining a -// staged payload. The archive remains open for the caller. -func ValidatePackage(runtimeID string, downloaded *os.File, expected ExpectedPackageMetadata, options InstallOptions) (validation PackageValidation, err error) { - if downloaded == nil { - return PackageValidation{}, errors.New("package archive is nil") - } - expected, err = normalizePackageInstallMetadata(runtimeID, expected) - if err != nil { - return PackageValidation{}, err - } - workDir, err := os.MkdirTemp("", "dbc-package-validate-") - if err != nil { - return PackageValidation{}, fmt.Errorf("could not create private package validation directory: %w", err) - } - defer func() { - if cleanupErr := os.RemoveAll(workDir); cleanupErr != nil { - validation = PackageValidation{} - err = errors.Join(err, fmt.Errorf("could not remove package validation directory: %w", cleanupErr)) - } - }() - - finalDir := filepath.Join(workDir, "installed") - manifest, payloadDir, sharedIdentity, err := stagePackageArchive(workDir, runtimeID, finalDir, downloaded, expected, options.Verify, workDir) - if err != nil { - return PackageValidation{}, err - } - receipt, ok := readPackageReceiptEvidence(workDir, runtimeID, payloadDir) - if !ok { - return PackageValidation{}, errors.New("could not read validated package receipt") - } - return PackageValidation{ - VerifiedLibraryHash: receipt.InstalledLibraryHash, - ArchiveHash: receipt.ArchiveHash, ArchiveSize: receipt.ArchiveSize, - PackageVersion: manifest.PackageVersion, Registration: manifest.DriverInfo, - RegistrationFingerprintAlgorithm: receipt.RegistrationFingerprintAlgorithm, - RegistrationFingerprintVersion: receipt.RegistrationFingerprintVersion, - RegistrationFingerprint: receipt.RegistrationFingerprint, - registrationSharedIdentity: sharedIdentity, - }, 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 diff --git a/config/package_archive_test.go b/config/package_archive_test.go index 0626c6fa..1b432a87 100644 --- a/config/package_archive_test.go +++ b/config/package_archive_test.go @@ -18,6 +18,7 @@ import ( "archive/tar" "bytes" "compress/gzip" + "context" "crypto/sha256" "encoding/hex" "encoding/json" @@ -489,7 +490,7 @@ func TestInflateTarballRejectsArchiveAttacks(t *testing.T) { } } -func TestInstallPackageArchiveChecksMetadataAndDigests(t *testing.T) { +func TestInstallPackageChecksMetadataAndDigests(t *testing.T) { archive := validV2Archive(t, []byte("library")) tests := []struct { name string @@ -508,7 +509,7 @@ func TestInstallPackageArchiveChecksMetadataAndDigests(t *testing.T) { expected := expectedPackage("example", "1.2.3", config.PlatformTuple(), "github.com/example/project", archive) tt.mutate(&expected) f := openPackageArchive(t, archive) - _, err := config.InstallPackageArchive(cfg, f, expected) + _, err := config.InstallPackage(cfg, "example", f, expected, config.InstallOptions{}) _ = f.Close() require.Error(t, err) assert.NoDirExists(t, filepath.Join(root, "example")) @@ -539,8 +540,11 @@ shared = "external-driver" candidate := expectedPackage("example", "1.2.3", config.PlatformTuple(), "packslip-project", archive) candidateFile := openPackageArchive(t, archive) - _, err = config.ValidatePackage("example", candidateFile, candidate, config.InstallOptions{}) + validation, err := config.PreparePackage(cfg, "example", candidateFile, candidate, config.InstallOptions{}) require.ErrorContains(t, err, "dbc package version mismatch: archive declares 0, expected 2") + if validation.Prepared != nil { + require.NoError(t, validation.Prepared.Close()) + } _, err = candidateFile.Seek(0, 0) require.NoError(t, err) _, err = config.InstallPackage(cfg, "example", candidateFile, candidate, config.InstallOptions{}) @@ -555,7 +559,7 @@ shared = "external-driver" assert.Equal(t, beforeLibrary, afterLibrary) } -func TestValidatePackageIsNonMutatingAndArchiveCanBeInstalledAfterward(t *testing.T) { +func TestPreparePackageIsNonMutatingAndPreparedPackageCanBeInstalled(t *testing.T) { root := t.TempDir() cfg := config.Config{Level: config.ConfigEnv, Location: root} oldArchive := validV2Archive(t, []byte("existing library")) @@ -590,14 +594,16 @@ func TestValidatePackageIsNonMutatingAndArchiveCanBeInstalledAfterward(t *testin } return nil }} - validation, err := config.ValidatePackage("example", candidateFile, candidate, options) + 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.ErrorIs(t, err, os.ErrNotExist, "validation staging directory must be removed before return") + 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) @@ -607,17 +613,24 @@ func TestValidatePackageIsNonMutatingAndArchiveCanBeInstalledAfterward(t *testin assert.Equal(t, oldLibrary, currentLibrary) entriesAfter, err := os.ReadDir(root) require.NoError(t, err) - assert.Equal(t, entryNames(entriesBefore), entryNames(entriesAfter)) + 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))) - installed, err := config.InstallPackage(cfg, "example", candidateFile, candidate, options) - require.NoError(t, err, "the archive should remain reusable after validation") - assert.Equal(t, 2, verifyCalls, "install should perform its own verification") - installedLibrary, err := os.ReadFile(installed.Driver.Shared.Get(config.PlatformTuple())) + 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 TestValidatePackageRejectsMetadataAndVerifierFailures(t *testing.T) { +func TestPreparePackageRejectsMetadataAndVerifierFailures(t *testing.T) { archive := validV2Archive(t, []byte("library")) mutations := []struct { name string @@ -637,7 +650,11 @@ func TestValidatePackageRejectsMetadataAndVerifierFailures(t *testing.T) { tt.mutate(&expected) file := openPackageArchive(t, archive) defer file.Close() - _, err := config.ValidatePackage("example", file, expected, config.InstallOptions{}) + 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) }) } @@ -647,12 +664,16 @@ func TestValidatePackageRejectsMetadataAndVerifierFailures(t *testing.T) { var stagingDir string file := openPackageArchive(t, archive) defer file.Close() - _, err := config.ValidatePackage("example", file, + 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) @@ -667,20 +688,21 @@ func entryNames(entries []os.DirEntry) []string { return names } -func TestInstallPackageArchiveReceiptReplacementAndRuntimeManifest(t *testing.T) { +func TestInstallPackageReceiptReplacementAndRuntimeManifest(t *testing.T) { root := t.TempDir() cfg := config.Config{Level: config.ConfigEnv, Location: root} firstArchive := validV2Archive(t, []byte("first library")) first := expectedPackage("example", "1.2.3", config.PlatformTuple(), "github.com/first/source", firstArchive) f := openPackageArchive(t, firstArchive) - manifest, err := config.InstallPackageArchive(cfg, f, first) + manifest, err := config.InstallPackage(cfg, "example", f, first, config.InstallOptions{}) require.NoError(t, err) _ = f.Close() assert.Equal(t, "dbc", manifest.Source) - assert.FileExists(t, filepath.Join(root, "example", "libexample.so")) + firstLibraryPath := manifest.Driver.Shared.Get(config.PlatformTuple()) + assert.FileExists(t, firstLibraryPath) - receiptPath := filepath.Join(root, "example", "dbc-install-receipt.json") - firstReceiptBytes, err := os.ReadFile(receiptPath) + 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)) @@ -695,15 +717,17 @@ func TestInstallPackageArchiveReceiptReplacementAndRuntimeManifest(t *testing.T) secondArchive := validV2Archive(t, []byte("second library")) second := expectedPackage("example", "1.2.3", config.PlatformTuple(), "github.com/second/source", secondArchive) f = openPackageArchive(t, secondArchive) - manifest, err = config.InstallPackageArchive(cfg, f, second) + 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") - contents, err := os.ReadFile(filepath.Join(root, "example", "libexample.so")) + secondLibraryPath := manifest.Driver.Shared.Get(config.PlatformTuple()) + contents, err := os.ReadFile(secondLibraryPath) require.NoError(t, err) assert.Equal(t, "second library", string(contents)) - secondReceiptBytes, err := os.ReadFile(receiptPath) + 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)) @@ -725,7 +749,7 @@ func TestInstallPackageArchiveReceiptReplacementAndRuntimeManifest(t *testing.T) loaded, err := config.GetDriver(cfg, "example") require.NoError(t, err) assert.Equal(t, "dbc", loaded.Source) - assert.Equal(t, filepath.Join(root, "example", "libexample.so"), loaded.Driver.Shared.Get(config.PlatformTuple())) + assert.Equal(t, secondLibraryPath, loaded.Driver.Shared.Get(config.PlatformTuple())) } func TestInstallReceiptPackageVersionProofPreservesUnspecifiedSources(t *testing.T) { @@ -752,29 +776,31 @@ func TestFailedReplacementPreservesExistingPackage(t *testing.T) { archive := validV2Archive(t, []byte("installed library")) expected := expectedPackage("example", "1.2.3", config.PlatformTuple(), "github.com/example/source", archive) f := openPackageArchive(t, archive) - _, err := config.InstallPackageArchive(cfg, f, expected) + installed, err := config.InstallPackage(cfg, "example", f, expected, config.InstallOptions{}) require.NoError(t, err) _ = f.Close() - originalLibrary, err := os.ReadFile(filepath.Join(root, "example", "libexample.so")) + 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(filepath.Join(root, "example", "dbc-install-receipt.json")) + originalReceipt, err := os.ReadFile(originalReceiptPath) require.NoError(t, err) badExpected := expected badExpected.ArchiveHash = "sha256:" + strings.Repeat("0", 64) f = openPackageArchive(t, validV2Archive(t, []byte("replacement library"))) - _, err = config.InstallPackageArchive(cfg, f, badExpected) + _, err = config.InstallPackage(cfg, "example", f, badExpected, config.InstallOptions{}) _ = f.Close() require.Error(t, err) - currentLibrary, err := os.ReadFile(filepath.Join(root, "example", "libexample.so")) + currentLibrary, err := os.ReadFile(originalLibraryPath) require.NoError(t, err) - currentReceipt, err := os.ReadFile(filepath.Join(root, "example", "dbc-install-receipt.json")) + currentReceipt, err := os.ReadFile(originalReceiptPath) require.NoError(t, err) assert.Equal(t, originalLibrary, currentLibrary) assert.Equal(t, originalReceipt, currentReceipt) } -func TestConcurrentInstallPackageArchiveSerializesSameTarget(t *testing.T) { +func TestConcurrentInstallPackageSerializesSameTarget(t *testing.T) { root := t.TempDir() cfg := config.Config{Level: config.ConfigEnv, Location: root} firstArchive := validV2Archive(t, []byte("first library")) @@ -786,12 +812,12 @@ func TestConcurrentInstallPackageArchiveSerializesSameTarget(t *testing.T) { results := make(chan error, 2) go func() { - _, err := config.InstallPackageArchive(cfg, firstFile, firstExpected) + _, err := config.InstallPackage(cfg, "example", firstFile, firstExpected, config.InstallOptions{}) _ = firstFile.Close() results <- err }() go func() { - _, err := config.InstallPackageArchive(cfg, secondFile, secondExpected) + _, err := config.InstallPackage(cfg, "example", secondFile, secondExpected, config.InstallOptions{}) _ = secondFile.Close() results <- err }() @@ -799,11 +825,14 @@ func TestConcurrentInstallPackageArchiveSerializesSameTarget(t *testing.T) { 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(root, "example", "dbc-install-receipt.json")) + 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(filepath.Join(root, "example", "libexample.so")) + library, err := os.ReadFile(libraryPath) require.NoError(t, err) switch receipt.SourceIdentity { case firstExpected.SourceIdentity: diff --git a/config/registration_fingerprint_test.go b/config/registration_fingerprint_test.go index 90589ff2..19a6642c 100644 --- a/config/registration_fingerprint_test.go +++ b/config/registration_fingerprint_test.go @@ -135,10 +135,18 @@ func TestPackageValidationFingerprintMatchesInstalledReceipt(t *testing.T) { expected.Version = "1.2.3" validationFile := writeInstallArchive(t, archive, "fingerprint-validation") defer validationFile.Close() - validation, err := ValidatePackage("example", validationFile, expected, InstallOptions{}) + 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) } From b7fe901f20306e8dbcd9e199b72f656cea1b4642 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 17:31:17 +0000 Subject: [PATCH 100/115] docs(config): update prepared package API reference --- config/package_archive.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/package_archive.go b/config/package_archive.go index 07f541b8..90e18114 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -941,7 +941,7 @@ func InstallReceiptMatchesExpectedPackage(receipt InstallReceipt, expected Expec } // PackageValidationMatchesRuntimeRegistration compares a current registration -// with evidence returned by ValidatePackage. The candidate's shared identity +// 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. From 54ea2f53201c3d2acf0ff5a50aa374e9821e4db7 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 23:47:50 +0000 Subject: [PATCH 101/115] docs: describe source-aware lock replay --- docs/guides/driver_list.md | 38 +++++++++++++++++++++++------------ docs/reference/driver_list.md | 20 ++++++++++++++++++ 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/docs/guides/driver_list.md b/docs/guides/driver_list.md index 1f3bb93f..988a0b44 100644 --- a/docs/guides/driver_list.md +++ b/docs/guides/driver_list.md @@ -98,30 +98,42 @@ $ 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 digest and size without registry or Packslip discovery. +If the current platform is absent, the lock is partial: a refresh may add that +target for the same source and version, but existing target entries are +immutable. ## 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 concrete target artifacts with their location, SHA-256 digest, +and size. 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, and changed bytes fail verification +instead of causing an implicit refresh. 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/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. From 9df705ac9d6d4fe33b16993c8788e7bcd966ae1a Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 23:51:15 +0000 Subject: [PATCH 102/115] docs: clarify partial path lock refresh --- docs/guides/driver_list.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/guides/driver_list.md b/docs/guides/driver_list.md index 988a0b44..e9bec22b 100644 --- a/docs/guides/driver_list.md +++ b/docs/guides/driver_list.md @@ -131,9 +131,11 @@ 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, and changed bytes fail verification -instead of causing an implicit refresh. A dedicated refresh UX is not yet -available, so review lockfile changes as deliberate dependency updates. +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 From 9d3de65c0428436367fdf5e76bcd1638760943b0 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 23:53:55 +0000 Subject: [PATCH 103/115] refactor(config): split package lifecycle files --- config/install_transaction_helpers_test.go | 99 + config/install_transaction_install_test.go | 572 ++++++ ...install_transaction_legacy_helpers_test.go | 76 + config/install_transaction_legacy_test.go | 339 ++++ config/install_transaction_lock_test.go | 207 ++ config/install_transaction_test.go | 1761 ---------------- config/install_transaction_uninstall_test.go | 593 ++++++ config/package_archive.go | 1787 ----------------- config/package_archive_types.go | 236 +++ config/package_install.go | 851 ++++++++ config/package_manifest.go | 207 ++ config/package_receipts.go | 590 ++++++ 12 files changed, 3770 insertions(+), 3548 deletions(-) create mode 100644 config/install_transaction_helpers_test.go create mode 100644 config/install_transaction_install_test.go create mode 100644 config/install_transaction_legacy_helpers_test.go create mode 100644 config/install_transaction_legacy_test.go create mode 100644 config/install_transaction_lock_test.go delete mode 100644 config/install_transaction_test.go create mode 100644 config/install_transaction_uninstall_test.go create mode 100644 config/package_archive_types.go create mode 100644 config/package_install.go create mode 100644 config/package_manifest.go create mode 100644 config/package_receipts.go diff --git a/config/install_transaction_helpers_test.go b/config/install_transaction_helpers_test.go new file mode 100644 index 00000000..2a576526 --- /dev/null +++ b/config/install_transaction_helpers_test.go @@ -0,0 +1,99 @@ +// 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" + "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(`package_version = 2 +id = %q +name = "Example Driver" +version = %q +platform = %q + +[Driver] +entrypoint = "AdbcDriverExampleInit" + +[Files] +driver = %q +`, id, version, PlatformTuple(), library)) + return makeInstallArchiveWithEntries(t, + installArchiveEntry{name: "dbc-package.toml", 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..db98680c --- /dev/null +++ b/config/install_transaction_install_test.go @@ -0,0 +1,572 @@ +// 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: "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 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_test.go b/config/install_transaction_test.go deleted file mode 100644 index b3123817..00000000 --- a/config/install_transaction_test.go +++ /dev/null @@ -1,1761 +0,0 @@ -// 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" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "runtime" - "slices" - "strings" - "sync" - "testing" - "time" - - "github.com/Masterminds/semver/v3" -) - -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(`package_version = 2 -id = %q -name = "Example Driver" -version = %q -platform = %q - -[Driver] -entrypoint = "AdbcDriverExampleInit" - -[Files] -driver = %q -`, id, version, PlatformTuple(), library)) - return makeInstallArchiveWithEntries(t, - installArchiveEntry{name: "dbc-package.toml", 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)), - } -} - -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: "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 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) - } -} - -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) - } - }) - } -} - -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) - } -} - -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 -} - -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_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/package_archive.go b/config/package_archive.go index 90e18114..c6beebae 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -17,1804 +17,17 @@ package config import ( "archive/tar" "compress/gzip" - "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" - "io/fs" - "maps" "os" "path/filepath" - "runtime" - "slices" "strings" - "sync" - - "github.com/Masterminds/semver/v3" - "github.com/pelletier/go-toml/v2" -) - -const ( - installReceiptName = "dbc-install-receipt.json" - legacyPackageManifestName = "MANIFEST" - packageV2MetadataName = "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 -// size of the compressed archive, not the extracted library. -type ExpectedPackageMetadata struct { - ID string - Version string - PackageVersion int - Platform string - SourceType string - SourceIdentity string - ArchiveHash string - ArchiveSize int64 -} - -// 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"` - PackageVersion int `json:"package_version"` - 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 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 - PackageVersion int - 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, size, and package version 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"` -} - -type packageManifest struct { - manifest Manifest - id string - platform string - v2 bool -} - -type packageManifestV2Wire struct { - PackageVersion int64 `toml:"package_version"` - ManifestVersion *int64 `toml:"manifest_version"` - ID string `toml:"id"` - Name string `toml:"name"` - Version string `toml:"version"` - Platform string `toml:"platform"` - Driver struct { - Entrypoint string `toml:"entrypoint"` - } `toml:"Driver"` - Files struct { - Driver string `toml:"driver"` - } `toml:"Files"` -} - -// 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"` -} - -func decodePackageManifest(name string, data []byte) (packageManifest, error) { - switch name { - case packageV2MetadataName: - return decodePackageV2Metadata(data) - case legacyPackageManifestName: - return decodeLegacyPackageManifest(data) - default: - return packageManifest{}, fmt.Errorf("unsupported package metadata filename %q", name) - } -} - -func decodePackageV2Metadata(data []byte) (packageManifest, error) { - var root map[string]any - if err := toml.Unmarshal(data, &root); err != nil { - return packageManifest{}, fmt.Errorf("%w: error decoding package v2 metadata: %v", ErrInvalidManifest, err) - } - - rawVersion, hasDiscriminator := root["package_version"] - if !hasDiscriminator { - return packageManifest{}, fmt.Errorf("%w: package_version = 2 is required in %s", ErrInvalidManifest, packageV2MetadataName) - } - version, ok := rawVersion.(int64) - if !ok { - return packageManifest{}, fmt.Errorf("%w: package_version must be an integer", ErrInvalidManifest) - } - if version != 2 { - return packageManifest{}, fmt.Errorf("%w: package version %d is unsupported", ErrInvalidManifest, version) - } - - var wire packageManifestV2Wire - if err := toml.Unmarshal(data, &wire); err != nil { - return packageManifest{}, fmt.Errorf("%w: error decoding package v2 metadata: %v", ErrInvalidManifest, err) - } - if wire.PackageVersion != 2 { - return packageManifest{}, fmt.Errorf("%w: package_version must be 2", ErrInvalidManifest) - } - if wire.ManifestVersion != nil { - return packageManifest{}, fmt.Errorf("%w: package v2 must not set runtime manifest_version", ErrInvalidManifest) - } - if err := validateFlatName(wire.ID); err != nil { - return packageManifest{}, fmt.Errorf("%w: invalid package id: %v", ErrInvalidManifest, err) - } - if strings.TrimSpace(wire.Name) == "" { - return packageManifest{}, fmt.Errorf("%w: name is required", ErrInvalidManifest) - } - if wire.Version == "" { - return packageManifest{}, fmt.Errorf("%w: version is required", ErrInvalidManifest) - } - parsedVersion, err := semver.StrictNewVersion(wire.Version) - if err != nil { - return packageManifest{}, fmt.Errorf("%w: version %q must be valid SemVer 2.0.0: %v", ErrInvalidManifest, wire.Version, err) - } - if err := validatePlatformIdentifier(wire.Platform); err != nil { - return packageManifest{}, fmt.Errorf("%w: invalid platform: %v", ErrInvalidManifest, err) - } - if strings.TrimSpace(wire.Driver.Entrypoint) == "" { - return packageManifest{}, fmt.Errorf("%w: Driver.entrypoint is required", ErrInvalidManifest) - } - if err := validateFlatName(wire.Files.Driver); err != nil { - return packageManifest{}, fmt.Errorf("%w: invalid Files.driver: %v", ErrInvalidManifest, err) - } - - return packageManifest{ - manifest: Manifest{ - PackageVersion: 2, - PackagePlatform: wire.Platform, - DriverInfo: DriverInfo{ - ID: wire.ID, - Name: wire.Name, - Version: parsedVersion, - Driver: struct { - Entrypoint string - Shared driverMap - }{Entrypoint: wire.Driver.Entrypoint}, - }, - Files: struct { - Driver string `toml:"driver,omitempty"` - Signature string `toml:"signature,omitempty"` - }{Driver: wire.Files.Driver}, - }, - id: wire.ID, platform: wire.Platform, v2: true, - }, nil -} - -func decodeLegacyPackageManifest(data []byte) (packageManifest, error) { - var root map[string]any - if err := toml.Unmarshal(data, &root); err != nil { - return packageManifest{}, fmt.Errorf("%w: error decoding legacy package manifest: %v", ErrInvalidManifest, err) - } - if _, hasPackageVersion := root["package_version"]; hasPackageVersion { - return packageManifest{}, fmt.Errorf("%w: package_version is not allowed in legacy %s; use %s for package v2 metadata", ErrInvalidManifest, legacyPackageManifestName, packageV2MetadataName) - } - - var wire legacyPackageManifestWire - if err := toml.Unmarshal(data, &wire); err != nil { - return packageManifest{}, fmt.Errorf("%w: error decoding legacy package manifest: %v", ErrInvalidManifest, err) - } - if wire.ManifestVersion > currentManifestVersion { - return packageManifest{}, 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 packageManifest{}, fmt.Errorf("%w: name is required", ErrInvalidManifest) - } - if wire.Version == nil { - return packageManifest{}, fmt.Errorf("%w: version is required", ErrInvalidManifest) - } - if wire.Files.Driver != "" { - if err := validateFlatName(wire.Files.Driver); err != nil { - return packageManifest{}, fmt.Errorf("%w: invalid Files.driver: %v", ErrInvalidManifest, err) - } - } - shared, err := decodeDriverShared(wire.Driver.Shared, false) - if err != nil { - return packageManifest{}, err - } - return packageManifest{ - manifest: 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, packageV2MetadataName} { - 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] - v2, hasV2 := metadata[packageV2MetadataName] - if hasLegacy && hasV2 { - return "", nil, fmt.Errorf("package archive must contain either %s or %s, not both", legacyPackageManifestName, packageV2MetadataName) - } - if hasLegacy { - return legacyPackageManifestName, legacy, nil - } - if hasV2 { - return packageV2MetadataName, v2, nil - } - return "", nil, fmt.Errorf("package archive must contain exactly one of %s or %s", legacyPackageManifestName, packageV2MetadataName) -} - -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 -} - -// 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") - } - if manifest.PackageVersion == 2 && requested.ArchiveHash == "" { - return PackageValidation{}, errors.New("package v2 requires archive hash and size metadata") - } - finalExpected := requested - finalExpected.PackageVersion = manifest.PackageVersion - finalExpected.ArchiveHash = receipt.ArchiveHash - finalExpected.ArchiveSize = receipt.ArchiveSize - 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, - PackageVersion: manifest.PackageVersion, 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 -// recorded by a receipt. A zero expected package version preserves the -// unspecified contract used by registry and local legacy sources; a nonzero -// expectation requires an exact receipt match. -func InstallReceiptMatchesExpectedPackage(receipt InstallReceipt, expected ExpectedPackageMetadata) bool { - return expected.ID != "" && expected.Version != "" && expected.Platform != "" && - expected.SourceType != "" && expected.SourceIdentity != "" && expected.ArchiveHash != "" && expected.ArchiveSize > 0 && - receipt.DriverID == expected.ID && receipt.DriverVersion == expected.Version && - receipt.Platform == expected.Platform && receipt.SourceType == expected.SourceType && - receipt.SourceIdentity == expected.SourceIdentity && receipt.ArchiveHash == expected.ArchiveHash && - receipt.ArchiveSize == expected.ArchiveSize && - (expected.PackageVersion == 0 || receipt.PackageVersion == expected.PackageVersion) -} - -// 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.ArchiveSize != 0 { - 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 err := validateExpectedPackageVersion(expected.PackageVersion); err != nil { - return ExpectedPackageMetadata{}, 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) -} - -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 := validateExpectedPackageVersion(receipt.PackageVersion); err != nil { - return fmt.Errorf("invalid receipt package version: %w", err) - } - 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 := validateExpectedPackageVersion(expected.PackageVersion); err != nil { - return 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 be positive") - } - if _, err := parseSHA256(expected.ArchiveHash); err != nil { - return fmt.Errorf("invalid expected archive hash: %w", err) - } - return nil -} - -func validateExpectedPackageVersion(version int) error { - if version != 0 && version != 2 { - return fmt.Errorf("unsupported dbc package version %d", version) - } - return nil -} - // InspectPackageMetadata decodes the archive's package metadata without // installing files. It is intended to classify legacy and versioned package // formats before choosing an install policy; the installer still validates diff --git a/config/package_archive_types.go b/config/package_archive_types.go new file mode 100644 index 00000000..26206b22 --- /dev/null +++ b/config/package_archive_types.go @@ -0,0 +1,236 @@ +// 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" + packageV2MetadataName = "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 +// size of the compressed archive, not the extracted library. +type ExpectedPackageMetadata struct { + ID string + Version string + PackageVersion int + Platform string + SourceType string + SourceIdentity string + ArchiveHash string + ArchiveSize int64 +} + +// 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"` + PackageVersion int `json:"package_version"` + 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 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 + PackageVersion int + 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, size, and package version 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"` +} + +type packageManifest struct { + manifest Manifest + id string + platform string + v2 bool +} + +type packageManifestV2Wire struct { + PackageVersion int64 `toml:"package_version"` + ManifestVersion *int64 `toml:"manifest_version"` + ID string `toml:"id"` + Name string `toml:"name"` + Version string `toml:"version"` + Platform string `toml:"platform"` + Driver struct { + Entrypoint string `toml:"entrypoint"` + } `toml:"Driver"` + Files struct { + Driver string `toml:"driver"` + } `toml:"Files"` +} + +// 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..3a4bb011 --- /dev/null +++ b/config/package_install.go @@ -0,0 +1,851 @@ +// 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") + } + if manifest.PackageVersion == 2 && requested.ArchiveHash == "" { + return PackageValidation{}, errors.New("package v2 requires archive hash and size metadata") + } + finalExpected := requested + finalExpected.PackageVersion = manifest.PackageVersion + finalExpected.ArchiveHash = receipt.ArchiveHash + finalExpected.ArchiveSize = receipt.ArchiveSize + 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, + PackageVersion: manifest.PackageVersion, 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 +// recorded by a receipt. A zero expected package version preserves the +// unspecified contract used by registry and local legacy sources; a nonzero +// expectation requires an exact receipt match. +func InstallReceiptMatchesExpectedPackage(receipt InstallReceipt, expected ExpectedPackageMetadata) bool { + return expected.ID != "" && expected.Version != "" && expected.Platform != "" && + expected.SourceType != "" && expected.SourceIdentity != "" && expected.ArchiveHash != "" && expected.ArchiveSize > 0 && + receipt.DriverID == expected.ID && receipt.DriverVersion == expected.Version && + receipt.Platform == expected.Platform && receipt.SourceType == expected.SourceType && + receipt.SourceIdentity == expected.SourceIdentity && receipt.ArchiveHash == expected.ArchiveHash && + receipt.ArchiveSize == expected.ArchiveSize && + (expected.PackageVersion == 0 || receipt.PackageVersion == expected.PackageVersion) +} + +// 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.ArchiveSize != 0 { + 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 err := validateExpectedPackageVersion(expected.PackageVersion); err != nil { + return ExpectedPackageMetadata{}, 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..e87a33be --- /dev/null +++ b/config/package_manifest.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 ( + "fmt" + "github.com/Masterminds/semver/v3" + "github.com/pelletier/go-toml/v2" + "strings" +) + +func decodePackageManifest(name string, data []byte) (packageManifest, error) { + switch name { + case packageV2MetadataName: + return decodePackageV2Metadata(data) + case legacyPackageManifestName: + return decodeLegacyPackageManifest(data) + default: + return packageManifest{}, fmt.Errorf("unsupported package metadata filename %q", name) + } +} + +func decodePackageV2Metadata(data []byte) (packageManifest, error) { + var root map[string]any + if err := toml.Unmarshal(data, &root); err != nil { + return packageManifest{}, fmt.Errorf("%w: error decoding package v2 metadata: %v", ErrInvalidManifest, err) + } + + rawVersion, hasDiscriminator := root["package_version"] + if !hasDiscriminator { + return packageManifest{}, fmt.Errorf("%w: package_version = 2 is required in %s", ErrInvalidManifest, packageV2MetadataName) + } + version, ok := rawVersion.(int64) + if !ok { + return packageManifest{}, fmt.Errorf("%w: package_version must be an integer", ErrInvalidManifest) + } + if version != 2 { + return packageManifest{}, fmt.Errorf("%w: package version %d is unsupported", ErrInvalidManifest, version) + } + + var wire packageManifestV2Wire + if err := toml.Unmarshal(data, &wire); err != nil { + return packageManifest{}, fmt.Errorf("%w: error decoding package v2 metadata: %v", ErrInvalidManifest, err) + } + if wire.PackageVersion != 2 { + return packageManifest{}, fmt.Errorf("%w: package_version must be 2", ErrInvalidManifest) + } + if wire.ManifestVersion != nil { + return packageManifest{}, fmt.Errorf("%w: package v2 must not set runtime manifest_version", ErrInvalidManifest) + } + if err := validateFlatName(wire.ID); err != nil { + return packageManifest{}, fmt.Errorf("%w: invalid package id: %v", ErrInvalidManifest, err) + } + if strings.TrimSpace(wire.Name) == "" { + return packageManifest{}, fmt.Errorf("%w: name is required", ErrInvalidManifest) + } + if wire.Version == "" { + return packageManifest{}, fmt.Errorf("%w: version is required", ErrInvalidManifest) + } + parsedVersion, err := semver.StrictNewVersion(wire.Version) + if err != nil { + return packageManifest{}, fmt.Errorf("%w: version %q must be valid SemVer 2.0.0: %v", ErrInvalidManifest, wire.Version, err) + } + if err := validatePlatformIdentifier(wire.Platform); err != nil { + return packageManifest{}, fmt.Errorf("%w: invalid platform: %v", ErrInvalidManifest, err) + } + if strings.TrimSpace(wire.Driver.Entrypoint) == "" { + return packageManifest{}, fmt.Errorf("%w: Driver.entrypoint is required", ErrInvalidManifest) + } + if err := validateFlatName(wire.Files.Driver); err != nil { + return packageManifest{}, fmt.Errorf("%w: invalid Files.driver: %v", ErrInvalidManifest, err) + } + + return packageManifest{ + manifest: Manifest{ + PackageVersion: 2, + PackagePlatform: wire.Platform, + DriverInfo: DriverInfo{ + ID: wire.ID, + Name: wire.Name, + Version: parsedVersion, + Driver: struct { + Entrypoint string + Shared driverMap + }{Entrypoint: wire.Driver.Entrypoint}, + }, + Files: struct { + Driver string `toml:"driver,omitempty"` + Signature string `toml:"signature,omitempty"` + }{Driver: wire.Files.Driver}, + }, + id: wire.ID, platform: wire.Platform, v2: true, + }, nil +} + +func decodeLegacyPackageManifest(data []byte) (packageManifest, error) { + var root map[string]any + if err := toml.Unmarshal(data, &root); err != nil { + return packageManifest{}, fmt.Errorf("%w: error decoding legacy package manifest: %v", ErrInvalidManifest, err) + } + if _, hasPackageVersion := root["package_version"]; hasPackageVersion { + return packageManifest{}, fmt.Errorf("%w: package_version is not allowed in legacy %s; use %s for package v2 metadata", ErrInvalidManifest, legacyPackageManifestName, packageV2MetadataName) + } + + var wire legacyPackageManifestWire + if err := toml.Unmarshal(data, &wire); err != nil { + return packageManifest{}, fmt.Errorf("%w: error decoding legacy package manifest: %v", ErrInvalidManifest, err) + } + if wire.ManifestVersion > currentManifestVersion { + return packageManifest{}, 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 packageManifest{}, fmt.Errorf("%w: name is required", ErrInvalidManifest) + } + if wire.Version == nil { + return packageManifest{}, fmt.Errorf("%w: version is required", ErrInvalidManifest) + } + if wire.Files.Driver != "" { + if err := validateFlatName(wire.Files.Driver); err != nil { + return packageManifest{}, fmt.Errorf("%w: invalid Files.driver: %v", ErrInvalidManifest, err) + } + } + shared, err := decodeDriverShared(wire.Driver.Shared, false) + if err != nil { + return packageManifest{}, err + } + return packageManifest{ + manifest: 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, packageV2MetadataName} { + 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] + v2, hasV2 := metadata[packageV2MetadataName] + if hasLegacy && hasV2 { + return "", nil, fmt.Errorf("package archive must contain either %s or %s, not both", legacyPackageManifestName, packageV2MetadataName) + } + if hasLegacy { + return legacyPackageManifestName, legacy, nil + } + if hasV2 { + return packageV2MetadataName, v2, nil + } + return "", nil, fmt.Errorf("package archive must contain exactly one of %s or %s", legacyPackageManifestName, packageV2MetadataName) +} + +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..9c86e4a9 --- /dev/null +++ b/config/package_receipts.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 ( + "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 := validateExpectedPackageVersion(receipt.PackageVersion); err != nil { + return fmt.Errorf("invalid receipt package version: %w", err) + } + 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 := validateExpectedPackageVersion(expected.PackageVersion); err != nil { + return 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 be positive") + } + if _, err := parseSHA256(expected.ArchiveHash); err != nil { + return fmt.Errorf("invalid expected archive hash: %w", err) + } + return nil +} + +func validateExpectedPackageVersion(version int) error { + if version != 0 && version != 2 { + return fmt.Errorf("unsupported dbc package version %d", version) + } + return nil +} From 5ab685fcbd2d01cad8d9f9bcfdb09b8232ec8f8f Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 23:59:05 +0000 Subject: [PATCH 104/115] refactor(packslip): split model responsibilities --- internal/packslip/model.go | 842 +----------------------------- internal/packslip/project.go | 82 +++ internal/packslip/release.go | 357 +++++++++++++ internal/packslip/release_list.go | 128 +++++ internal/packslip/selection.go | 214 ++++++++ internal/packslip/wire.go | 158 ++++++ 6 files changed, 940 insertions(+), 841 deletions(-) create mode 100644 internal/packslip/project.go create mode 100644 internal/packslip/release.go create mode 100644 internal/packslip/release_list.go create mode 100644 internal/packslip/selection.go create mode 100644 internal/packslip/wire.go diff --git a/internal/packslip/model.go b/internal/packslip/model.go index 4a99b3dd..74212ca8 100644 --- a/internal/packslip/model.go +++ b/internal/packslip/model.go @@ -14,27 +14,7 @@ package packslip -import ( - "bytes" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "math" - "net/url" - "regexp" - "strings" - "time" - - "github.com/columnar-tech/dbc/internal/resolution" - "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_.-]*$`) -) +import "encoding/json" type statementEnvelope struct { Type string `json:"_type"` @@ -162,823 +142,3 @@ type parsedList struct { predicate releaseListPredicate byURL map[string]subject } - -// 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 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 -} - -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 -} - -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") -} - -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) -} - -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.dbcPackageVersion == 2 && 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 package support 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].dbcPackageVersion == 2 && 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), - PackageVersion: artifact.dbcPackageVersion, - 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/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/selection.go b/internal/packslip/selection.go new file mode 100644 index 00000000..2fe11ba6 --- /dev/null +++ b/internal/packslip/selection.go @@ -0,0 +1,214 @@ +// 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.dbcPackageVersion == 2 && 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 package support 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].dbcPackageVersion == 2 && 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), + PackageVersion: artifact.dbcPackageVersion, + 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/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") +} From 3ca8f47fc1fa5c7165d31ed7cb42316ea526a648 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Tue, 22 Sep 2026 00:05:53 +0000 Subject: [PATCH 105/115] feat(sync): report lockfile v2 migration --- cmd/dbc/sync.go | 64 ++++++++++++++- cmd/dbc/sync_test.go | 122 ++++++++++++++++++++++++++++- internal/jsonschema/schema.go | 9 +++ internal/jsonschema/schema_test.go | 23 ++++++ 4 files changed, 214 insertions(+), 4 deletions(-) diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index fbeeabcd..a770c70d 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -139,9 +139,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 @@ -177,6 +182,8 @@ 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 worker *syncWorker @@ -200,6 +207,10 @@ type syncWorker struct { 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 { @@ -216,6 +227,11 @@ type syncWorkerResultMsg struct { lock LockFile } +type syncLockMigratedMsg struct { + fromVersion int + toVersion int +} + type syncResolvingMsg struct{ driver string } type syncItemsMsg struct{ items []installItem } @@ -402,10 +418,23 @@ type preparedSyncMsg struct { 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, err + 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 @@ -1097,10 +1126,14 @@ func (s syncModel) runSyncWorker(worker *syncWorker) (result syncWorkerResultMsg return fail("sync_failed", err) } - planned, needsRegistry, err := s.planSyncItems(s.list) + 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. @@ -1160,6 +1193,14 @@ func (s syncModel) runSyncWorker(worker *syncWorker) (result syncWorkerResultMsg 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() @@ -1346,6 +1387,25 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }) } 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, diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index c2d6dc9c..13bffae0 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -1328,6 +1328,46 @@ func (suite *SubcommandTestSuite) TestSync_JSONProgressStream() { suite.Equal("sync.status", kinds[len(kinds)-1]) } +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") @@ -1415,6 +1455,83 @@ func (suite *SubcommandTestSuite) TestSyncCandidateLockFailureDoesNotInstall() { 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") @@ -1674,7 +1791,7 @@ func (suite *SubcommandTestSuite) TestSyncLegacyManifestOnlyProofCompatibility() defer cancel() var output bytes.Buffer program := tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(&output), - tea.WithoutRenderer(), tea.WithContext(ctx), tea.WithFilter(filterProgramMessage)) + tea.WithContext(ctx), tea.WithFilter(filterProgramMessage)) prog = program defer func() { prog = nil }() programModel := tea.Model(model) @@ -1711,8 +1828,9 @@ func (suite *SubcommandTestSuite) TestSyncLegacyManifestOnlyProofCompatibility() data := []byte("legacy external library") fixture := setup(t, path, path, data, data) counts := &syncArchiveRunCounts{} - _, err := run(t, fixture.listPath, fixture.archivePath, counts, getTestDriverRegistry) + 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) diff --git a/internal/jsonschema/schema.go b/internal/jsonschema/schema.go index 014cc76f..3d777461 100644 --- a/internal/jsonschema/schema.go +++ b/internal/jsonschema/schema.go @@ -265,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. @@ -273,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 37f5caed..1ca33f18 100644 --- a/internal/jsonschema/schema_test.go +++ b/internal/jsonschema/schema_test.go @@ -422,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 @@ -437,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) { @@ -455,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", From 8decc319aba03b1980931d97a1853645ac3c5d02 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Tue, 22 Sep 2026 00:24:00 +0000 Subject: [PATCH 106/115] refactor(sync): split planning and execution files --- cmd/dbc/sync.go | 1354 -------------- cmd/dbc/sync_cancellation_test.go | 505 ++++++ cmd/dbc/sync_execution.go | 436 +++++ cmd/dbc/sync_execution_test.go | 697 ++++++++ cmd/dbc/sync_lifecycle_test.go | 384 ++++ cmd/dbc/sync_migration_test.go | 338 ++++ cmd/dbc/sync_planning.go | 646 +++++++ cmd/dbc/sync_planning_test.go | 874 +++++++++ cmd/dbc/sync_test.go | 2747 ----------------------------- cmd/dbc/sync_test_helpers_test.go | 123 ++ cmd/dbc/sync_ui.go | 197 +++ cmd/dbc/sync_worker.go | 178 ++ 12 files changed, 4378 insertions(+), 4101 deletions(-) create mode 100644 cmd/dbc/sync_cancellation_test.go create mode 100644 cmd/dbc/sync_execution.go create mode 100644 cmd/dbc/sync_execution_test.go create mode 100644 cmd/dbc/sync_lifecycle_test.go create mode 100644 cmd/dbc/sync_migration_test.go create mode 100644 cmd/dbc/sync_planning.go create mode 100644 cmd/dbc/sync_planning_test.go create mode 100644 cmd/dbc/sync_test_helpers_test.go create mode 100644 cmd/dbc/sync_ui.go create mode 100644 cmd/dbc/sync_worker.go diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index a770c70d..d89978b7 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -15,31 +15,16 @@ package main import ( - "context" - "errors" "fmt" "io" - "io/fs" "os" - "path/filepath" - "reflect" - "strings" - "sync" - "time" "charm.land/bubbles/v2/progress" "charm.land/bubbles/v2/spinner" 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/fslock" "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/sourceidentity" - "github.com/columnar-tech/dbc/internal/sourceresolution" ) type SyncCmd struct { @@ -189,1342 +174,3 @@ type syncModel struct { worker *syncWorker terminal bool } - -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 -} - -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 := resolvedReleaseFromRegistryPackage(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) - } - size := cloneInt64(pkg.ArtifactSize) - 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, Size: size, - }}, - } - if err := resolution.ValidateResolvedReleaseCandidate(release); err != nil { - return resolution.ResolvedRelease{}, fmt.Errorf("invalid registry package metadata: %w", err) - } - return release, 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...), - } -} - -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 && locked.PackageVersion == selected.PackageVersion && - 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 == "" || selected.Size == nil || *selected.Size <= 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.ArchiveSize != *selected.Size { - return false - } - return true -} - -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_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..6e941592 --- /dev/null +++ b/cmd/dbc/sync_execution.go @@ -0,0 +1,436 @@ +// 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 && locked.PackageVersion == selected.PackageVersion && + 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 == "" || selected.Size == nil || *selected.Size <= 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.ArchiveSize != *selected.Size { + 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..26be256d --- /dev/null +++ b/cmd/dbc/sync_execution_test.go @@ -0,0 +1,697 @@ +// 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" + "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" +) + +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 (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) TestSyncPartialRegistryDownloadsEachArchiveOnceAndRejectsV2WithoutMetadata() { + 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, 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) + + // The registry fixture omits archive metadata. A v2 archive must therefore + // be rejected after its one download rather than treating measured values as + // source-provided metadata. + v2Path := filepath.Join(suite.tempdir, "v2-without-registry-metadata.tar.gz") + makeSyncPackageV2Archive(suite.T(), v2Path, "test-driver-1", "1.1.0", config.PlatformTuple()) + v2List := filepath.Join(suite.tempdir, "v2.toml") + suite.Require().NoError(os.WriteFile(v2List, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0644)) + v2Downloads := 0 + v2Model := SyncCmd{Path: v2List, NoVerify: true}.GetModelCustom(baseModel{ + getDriverRegistry: getTestDriverRegistry, + downloadPkg: func(dbc.PkgInfo) (*os.File, error) { + v2Downloads++ + return os.Open(v2Path) + }, + }) + suite.Contains(suite.runCmdErr(v2Model), "package v2 requires archive hash and size metadata") + suite.Equal(1, v2Downloads) +} + +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.Require().NotNil(artifact.Size) + suite.Equal(int64(len(archiveBytes)), *artifact.Size) + 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)) + makeSyncPackageV2Archive(suite.T(), archivePath, "test-driver-1", "1.2.3", config.PlatformTuple()) + 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..869517d4 --- /dev/null +++ b/cmd/dbc/sync_migration_test.go @@ -0,0 +1,338 @@ +// 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.Require().NotNil(observedPackage.ArtifactSize) + suite.Equal(*lockedArtifact.Size, *observedPackage.ArtifactSize) + 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..c3afc855 --- /dev/null +++ b/cmd/dbc/sync_planning.go @@ -0,0 +1,646 @@ +// 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 := resolvedReleaseFromRegistryPackage(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) + } + size := cloneInt64(pkg.ArtifactSize) + 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, Size: size, + }}, + } + if err := resolution.ValidateResolvedReleaseCandidate(release); err != nil { + return resolution.ResolvedRelease{}, fmt.Errorf("invalid registry package metadata: %w", err) + } + return release, 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..e993fbec --- /dev/null +++ b/cmd/dbc/sync_planning_test.go @@ -0,0 +1,874 @@ +// 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/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 := makeSyncPackageV2Archive(t, archivePath, driverID, version, config.PlatformTuple()) + 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"), 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, 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, 2, primaryArtifact.PackageVersion) + 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, NoVerify: true, + 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.Equal(t, 0, locked.Artifacts[0].PackageVersion, "legacy MANIFEST path packages remain supported") + assert.NotEmpty(t, locked.Artifacts[0].Hash) +} + +func TestPathPackageV2MarkerSurvivesResolutionAndLockSnapshot(t *testing.T) { + dir := t.TempDir() + archivePath := filepath.Join(dir, "driver.tar.gz") + makeSyncPackageV2Archive(t, archivePath, "test-driver-1", "1.2.3", config.PlatformTuple()) + 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, 2, items[0].Release.Artifacts[0].PackageVersion) + 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) + assert.Equal(t, 2, prepared.lock.Drivers[0].Artifacts[0].PackageVersion) + expected, _, err := expectedSyncPackageMetadata(prepared.items[0]) + require.NoError(t, err) + assert.Equal(t, 2, expected.PackageVersion) +} + +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 := makeSyncPackageV2Archive(t, archivePath, "test-driver-1", "1.2.3", config.PlatformTuple()) + 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)) + makeSyncPackageV2Archive(t, archivePath, "test-driver-1", "1.2.3", config.PlatformTuple()) + 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", PackageVersion: 2, + 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" + entry.Artifacts[0].PackageVersion = 2 + 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 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 13bffae0..ba97f836 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -13,2750 +13,3 @@ // limitations under the License. package main - -import ( - "archive/tar" - "bytes" - "compress/gzip" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/url" - "os" - "path/filepath" - "strings" - "sync" - "sync/atomic" - "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/fslock" - "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/stretchr/testify/assert" - "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 makeSyncPackageV2Archive(t *testing.T, path, id, version, platform string) ([]byte, string) { - t.Helper() - metadata := []byte(fmt.Sprintf(`package_version = 2 -id = %q -name = "Test Driver" -version = %q -platform = %q - -[Driver] -entrypoint = "TestDriverInit" - -[Files] -driver = "driver.so" -`, id, version, platform)) - var archive bytes.Buffer - gzipWriter := gzip.NewWriter(&archive) - tarWriter := tar.NewWriter(gzipWriter) - for _, entry := range []struct { - name string - data []byte - }{{name: "dbc-package.toml", 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", PackageVersion: 2, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: url}, Hash: hash, Size: &size}, - {Target: secondary, Format: "tar.gz", PackageVersion: 2, 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") -} - -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 := makeSyncPackageV2Archive(t, archivePath, driverID, version, config.PlatformTuple()) - 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"), 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, 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, 2, primaryArtifact.PackageVersion) - 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, NoVerify: true, - 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.Equal(t, 0, locked.Artifacts[0].PackageVersion, "legacy MANIFEST path packages remain supported") - assert.NotEmpty(t, locked.Artifacts[0].Hash) -} - -func TestPathPackageV2MarkerSurvivesResolutionAndLockSnapshot(t *testing.T) { - dir := t.TempDir() - archivePath := filepath.Join(dir, "driver.tar.gz") - makeSyncPackageV2Archive(t, archivePath, "test-driver-1", "1.2.3", config.PlatformTuple()) - 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, 2, items[0].Release.Artifacts[0].PackageVersion) - 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) - assert.Equal(t, 2, prepared.lock.Drivers[0].Artifacts[0].PackageVersion) - expected, _, err := expectedSyncPackageMetadata(prepared.items[0]) - require.NoError(t, err) - assert.Equal(t, 2, expected.PackageVersion) -} - -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 := makeSyncPackageV2Archive(t, archivePath, "test-driver-1", "1.2.3", config.PlatformTuple()) - 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)) - makeSyncPackageV2Archive(t, archivePath, "test-driver-1", "1.2.3", config.PlatformTuple()) - 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", PackageVersion: 2, - 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" - entry.Artifacts[0].PackageVersion = 2 - 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 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) -} - -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]) -} - -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.Require().NotNil(observedPackage.ArtifactSize) - suite.Equal(*lockedArtifact.Size, *observedPackage.ArtifactSize) - 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) -} - -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 (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) TestSyncPartialRegistryDownloadsEachArchiveOnceAndRejectsV2WithoutMetadata() { - 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, 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) - - // The registry fixture omits archive metadata. A v2 archive must therefore - // be rejected after its one download rather than treating measured values as - // source-provided metadata. - v2Path := filepath.Join(suite.tempdir, "v2-without-registry-metadata.tar.gz") - makeSyncPackageV2Archive(suite.T(), v2Path, "test-driver-1", "1.1.0", config.PlatformTuple()) - v2List := filepath.Join(suite.tempdir, "v2.toml") - suite.Require().NoError(os.WriteFile(v2List, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0644)) - v2Downloads := 0 - v2Model := SyncCmd{Path: v2List, NoVerify: true}.GetModelCustom(baseModel{ - getDriverRegistry: getTestDriverRegistry, - downloadPkg: func(dbc.PkgInfo) (*os.File, error) { - v2Downloads++ - return os.Open(v2Path) - }, - }) - suite.Contains(suite.runCmdErr(v2Model), "package v2 requires archive hash and size metadata") - suite.Equal(1, v2Downloads) -} - -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.Require().NotNil(artifact.Size) - suite.Equal(int64(len(archiveBytes)), *artifact.Size) - 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)) - makeSyncPackageV2Archive(suite.T(), archivePath, "test-driver-1", "1.2.3", config.PlatformTuple()) - 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) - }) - } -} - -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_test_helpers_test.go b/cmd/dbc/sync_test_helpers_test.go new file mode 100644 index 00000000..1278b5ab --- /dev/null +++ b/cmd/dbc/sync_test_helpers_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 +// +// 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 makeSyncPackageV2Archive(t *testing.T, path, id, version, platform string) ([]byte, string) { + t.Helper() + metadata := []byte(fmt.Sprintf(`package_version = 2 +id = %q +name = "Test Driver" +version = %q +platform = %q + +[Driver] +entrypoint = "TestDriverInit" + +[Files] +driver = "driver.so" +`, id, version, platform)) + var archive bytes.Buffer + gzipWriter := gzip.NewWriter(&archive) + tarWriter := tar.NewWriter(gzipWriter) + for _, entry := range []struct { + name string + data []byte + }{{name: "dbc-package.toml", 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", PackageVersion: 2, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: url}, Hash: hash, Size: &size}, + {Target: secondary, Format: "tar.gz", PackageVersion: 2, 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 +} From c59c2734c7aed85f8c0d418ff83abf9d5a01374e Mon Sep 17 00:00:00 2001 From: eitsupi Date: Fri, 25 Sep 2026 02:45:07 +0000 Subject: [PATCH 107/115] feat: resolve hash-only registry locks from metadata --- client_methods.go | 7 +- client_methods_test.go | 19 ++- cmd/dbc/lockfile.go | 56 -------- cmd/dbc/lockfile_v2.go | 63 +++++++++ cmd/dbc/lockfile_v2_test.go | 39 ++++++ cmd/dbc/package_executor.go | 23 +++- cmd/dbc/sync_execution.go | 5 +- cmd/dbc/sync_execution_test.go | 67 ++++++++- cmd/dbc/sync_migration_test.go | 3 +- cmd/dbc/sync_planning.go | 82 ++++++++++- cmd/dbc/sync_planning_test.go | 127 ++++++++++++++++++ config/install_transaction_install_test.go | 44 ++++++ config/package_archive.go | 2 +- config/package_archive_types.go | 25 ++-- config/package_install.go | 9 +- config/package_receipts.go | 4 +- drivers.go | 43 +++++- drivers_test.go | 69 ++++++++++ internal/resolution/types.go | 28 ++-- internal/resolution/types_test.go | 13 +- internal/sourceresolution/requirement_test.go | 2 +- internal/sourceresolution/resolver.go | 3 + internal/sourceresolution/resolver_test.go | 4 +- registry_artifact.go | 15 +-- registry_artifact_test.go | 29 ++-- .../registry-index-artifact-metadata.yaml | 2 +- 26 files changed, 633 insertions(+), 150 deletions(-) create mode 100644 drivers_test.go diff --git a/client_methods.go b/client_methods.go index 84c84034..07dd20ca 100644 --- a/client_methods.go +++ b/client_methods.go @@ -291,7 +291,7 @@ func (c *Client) Install(ctx context.Context, cfg config.Config, driverName stri return nil, fmt.Errorf("failed to inspect package for driver %s: %w", driverName, inspectErr) } if packageManifest.PackageVersion == 2 { - return nil, fmt.Errorf("failed to install driver %s: registry package v2 requires archive hash and size metadata", driverName) + return nil, fmt.Errorf("failed to install driver %s: registry package v2 requires archive hash metadata", driverName) } } @@ -309,10 +309,6 @@ func (c *Client) Install(ctx context.Context, cfg config.Config, driverName stri func expectedRegistryPackageMetadata(pkg PkgInfo) (config.ExpectedPackageMetadata, bool, error) { hasHash := pkg.ArtifactHash != "" - hasSize := pkg.ArtifactSize != nil - if hasHash != hasSize { - return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata must include both archive hash and size") - } if pkg.Version == nil { return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata is missing its version") } @@ -333,7 +329,6 @@ func expectedRegistryPackageMetadata(pkg PkgInfo) (config.ExpectedPackageMetadat } if hasHash { expected.ArchiveHash = pkg.ArtifactHash - expected.ArchiveSize = *pkg.ArtifactSize } return expected, hasHash, nil } diff --git a/client_methods_test.go b/client_methods_test.go index 6554fcf7..629ff599 100644 --- a/client_methods_test.go +++ b/client_methods_test.go @@ -80,7 +80,7 @@ func newV2InstallServer(t *testing.T, archive []byte, metadata string) *httptest url: package.tar.gz `, config.PlatformTuple()) if metadata == "complete" { - index += fmt.Sprintf(" hash: %q\n size: %d\n", "sha256:"+hex.EncodeToString(digest[:]), len(archive)) + 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[:])) } @@ -344,21 +344,26 @@ func TestClientInstallPackageV2WithoutRegistryMetadataIsRejected(t *testing.T) { _, err := c.Install(t.Context(), cfg, "v2-driver") require.Error(t, err) - assert.Contains(t, err.Error(), "registry package v2 requires archive hash and size metadata") + assert.Contains(t, err.Error(), "registry package v2 requires archive hash metadata") assert.NoFileExists(t, filepath.Join(root, "v2-driver.toml")) } -func TestClientInstallRejectsPartialRegistryArchiveMetadata(t *testing.T) { +func TestClientInstallAcceptsHashOnlyRegistryArchiveMetadata(t *testing.T) { archive := makeClientPackageV2Archive(t, config.PlatformTuple()) srv := newV2InstallServer(t, archive, "hash-only") c := newTestClientForServer(t, srv.URL) root := t.TempDir() cfg := config.Config{Level: config.ConfigEnv, Location: root} - _, err := c.Install(t.Context(), cfg, "v2-driver") - require.Error(t, err) - assert.Contains(t, err.Error(), "registry package metadata must include both archive hash and size") - assert.NoDirExists(t, filepath.Join(root, "v2-driver")) + manifest, err := c.Install(t.Context(), cfg, "v2-driver") + 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) { diff --git a/cmd/dbc/lockfile.go b/cmd/dbc/lockfile.go index b87556b7..bbfb2870 100644 --- a/cmd/dbc/lockfile.go +++ b/cmd/dbc/lockfile.go @@ -267,62 +267,6 @@ func validateLockSource(source lockSource) error { return nil } -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 artifact.PackageVersion != 0 && artifact.PackageVersion != 2 { - return fmt.Errorf("artifact %d has unsupported dbc package version %d", i, artifact.PackageVersion) - } - 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 == "" || artifact.Size == nil { - return fmt.Errorf("artifact %d must have a finalized hash and size", i) - } - if err := validateLockHash(artifact.Hash); err != nil { - return fmt.Errorf("artifact %d: %w", i, err) - } - if *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 || !sameLockSize(prior.Size, artifact.Size) { - return fmt.Errorf("artifacts sharing location %q have conflicting hash or size", artifact.Location.Value) - } - } 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 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) -} - func parsePlatformTuple(platform string) (osName, arch, libc, variant string, ok bool) { target, err := resolution.TargetFromPlatformTuple(platform) if err != nil { diff --git a/cmd/dbc/lockfile_v2.go b/cmd/dbc/lockfile_v2.go index 1e142bd0..0e23fbdd 100644 --- a/cmd/dbc/lockfile_v2.go +++ b/cmd/dbc/lockfile_v2.go @@ -36,6 +36,69 @@ var ( 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 artifact.PackageVersion != 0 && artifact.PackageVersion != 2 { + return fmt.Errorf("artifact %d has unsupported dbc package version %d", i, artifact.PackageVersion) + } + 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 { diff --git a/cmd/dbc/lockfile_v2_test.go b/cmd/dbc/lockfile_v2_test.go index 64ee3bce..97c6acce 100644 --- a/cmd/dbc/lockfile_v2_test.go +++ b/cmd/dbc/lockfile_v2_test.go @@ -497,6 +497,8 @@ func TestLockArtifactsAllowSharedLocationOnlyWithConsistentMetadata(t *testing.T 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) @@ -507,6 +509,18 @@ func TestLockArtifactsAllowSharedLocationOnlyWithConsistentMetadata(t *testing.T 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] @@ -620,6 +634,31 @@ func TestSyncAdapterReusesCompleteV2SnapshotWithoutRegistryHashes(t *testing.T) "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" diff --git a/cmd/dbc/package_executor.go b/cmd/dbc/package_executor.go index ca9a96e1..cea38b40 100644 --- a/cmd/dbc/package_executor.go +++ b/cmd/dbc/package_executor.go @@ -141,11 +141,11 @@ func expectedSyncPackageMetadata(item installItem) (config.ExpectedPackageMetada } hasHash := selected.Hash != "" hasSize := selected.Size != nil - if hasHash != hasSize { - return config.ExpectedPackageMetadata{}, false, errors.New("package metadata must include both archive hash and size") + if !hasHash && hasSize { + return config.ExpectedPackageMetadata{}, false, errors.New("package metadata cannot include size without archive hash") } if selected.PackageVersion == 2 && !hasHash { - return config.ExpectedPackageMetadata{}, false, errors.New("package v2 requires archive hash and size metadata") + return config.ExpectedPackageMetadata{}, false, errors.New("package v2 requires archive hash metadata") } expected := config.ExpectedPackageMetadata{ ID: item.Release.DriverID, Version: item.Release.Version, Platform: item.Platform, @@ -154,7 +154,10 @@ func expectedSyncPackageMetadata(item installItem) (config.ExpectedPackageMetada } if hasHash { expected.ArchiveHash = selected.Hash - expected.ArchiveSize = *selected.Size + if hasSize { + expected.ArchiveSize = *selected.Size + expected.ArchiveSizePresent = true + } } return expected, hasHash, nil } @@ -339,15 +342,23 @@ func (e *packageExecutor) downloadAndPrepareItem(ctx context.Context, item *inst if err != nil { return err } + archiveHashWasMissing := selected.Hash == "" if selected.Hash == "" { selected.Hash = validation.ArchiveHash - selected.Size = cloneInt64(&validation.ArchiveSize) + selected.PackageVersion = validation.PackageVersion } - selected.PackageVersion = validation.PackageVersion 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 == "" { diff --git a/cmd/dbc/sync_execution.go b/cmd/dbc/sync_execution.go index 6e941592..0ba309cc 100644 --- a/cmd/dbc/sync_execution.go +++ b/cmd/dbc/sync_execution.go @@ -422,14 +422,15 @@ 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 == "" || selected.Size == nil || *selected.Size <= 0 || item.ValidatedLibraryHash == "" { + 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.ArchiveSize != *selected.Size { + 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 index 26be256d..527e14b6 100644 --- a/cmd/dbc/sync_execution_test.go +++ b/cmd/dbc/sync_execution_test.go @@ -23,6 +23,7 @@ import ( "errors" "fmt" "io" + "net/url" "os" "path/filepath" "strings" @@ -33,6 +34,9 @@ import ( "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() { @@ -64,6 +68,51 @@ func (suite *SubcommandTestSuite) TestSyncLegacyLibraryProofUsesValidatedArchive suite.Equal(libraryHash, entry.Legacy.LibraryHash) } +func TestPackageExecutorKeepsSourceArtifactMetadataInLock(t *testing.T) { + for _, test := range []struct { + name string + sourceType string + sourceReference string + packageVersion int + }{ + {name: "registry metadata without package version", sourceType: "registry", sourceReference: "https://registry.example.test", packageVersion: 0}, + {name: "Packslip declared metadata", sourceType: "packslip", sourceReference: "github.com/example/driver", packageVersion: 2}, + } { + t.Run(test.name, func(t *testing.T) { + archivePath := filepath.Join(t.TempDir(), "driver.tar.gz") + archive, archiveHash := makeSyncPackageV2Archive(t, archivePath, "test-driver-1", "1.2.3", config.PlatformTuple()) + 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", PackageVersion: test.packageVersion, + 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(), true, + 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) + assert.Equal(t, before.Artifacts[0].PackageVersion, after.Artifacts[0].PackageVersion) + }) + } +} + func (suite *SubcommandTestSuite) TestSyncLegacyProofDoesNotSkipDifferentCandidateLibrary() { root := suite.T().TempDir() suite.T().Setenv("ADBC_DRIVER_PATH", root) @@ -343,6 +392,19 @@ func (suite *SubcommandTestSuite) TestSyncPartialRegistryDownloadsEachArchiveOnc 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") + // The registry fixture omits archive metadata. A v2 archive must therefore // be rejected after its one download rather than treating measured values as // source-provided metadata. @@ -358,7 +420,7 @@ func (suite *SubcommandTestSuite) TestSyncPartialRegistryDownloadsEachArchiveOnc return os.Open(v2Path) }, }) - suite.Contains(suite.runCmdErr(v2Model), "package v2 requires archive hash and size metadata") + suite.Contains(suite.runCmdErr(v2Model), "package v2 requires archive hash metadata") suite.Equal(1, v2Downloads) } @@ -492,8 +554,7 @@ func (suite *SubcommandTestSuite) TestSyncManifestOnlyInstallUsesPreparedSnapsho artifact, err := selectLockedArtifact(locked.lockinfo["test-driver-1"], config.PlatformTuple(), false) suite.Require().NoError(err) suite.NotEmpty(artifact.Hash) - suite.Require().NotNil(artifact.Size) - suite.Equal(int64(len(archiveBytes)), *artifact.Size) + 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())) diff --git a/cmd/dbc/sync_migration_test.go b/cmd/dbc/sync_migration_test.go index 869517d4..d9c4cc9d 100644 --- a/cmd/dbc/sync_migration_test.go +++ b/cmd/dbc/sync_migration_test.go @@ -326,8 +326,7 @@ func (suite *SubcommandTestSuite) TestSyncInstallFailureKeepsCompleteCandidateLo suite.Equal(failedDriver, observedPackage.Driver.Path) suite.Equal(lockedArtifact.Location.Value, observedPackage.Path.String()) suite.Equal(lockedArtifact.Hash, observedPackage.ArtifactHash) - suite.Require().NotNil(observedPackage.ArtifactSize) - suite.Equal(*lockedArtifact.Size, *observedPackage.ArtifactSize) + 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) diff --git a/cmd/dbc/sync_planning.go b/cmd/dbc/sync_planning.go index c3afc855..85835664 100644 --- a/cmd/dbc/sync_planning.go +++ b/cmd/dbc/sync_planning.go @@ -471,7 +471,7 @@ func (s syncModel) resolveRegistryPlan(planned plannedSyncItem) (installItem, er return installItem{}, err } - release, err := resolvedReleaseFromRegistryPackage(driver, pkg) + release, err := resolvedRegistryReleaseFromMetadata(driver, pkg) if err != nil { return installItem{}, err } @@ -576,14 +576,13 @@ func resolvedReleaseFromRegistryPackage(driver dbc.Driver, pkg dbc.PkgInfo) (res if err != nil { return resolution.ResolvedRelease{}, fmt.Errorf("invalid registry package platform %q: %w", pkg.PlatformTuple, err) } - size := cloneInt64(pkg.ArtifactSize) 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, Size: size, + Hash: pkg.ArtifactHash, }}, } if err := resolution.ValidateResolvedReleaseCandidate(release); err != nil { @@ -592,6 +591,83 @@ func resolvedReleaseFromRegistryPackage(driver dbc.Driver, pkg dbc.PkgInfo) (res 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 { diff --git a/cmd/dbc/sync_planning_test.go b/cmd/dbc/sync_planning_test.go index e993fbec..64d24139 100644 --- a/cmd/dbc/sync_planning_test.go +++ b/cmd/dbc/sync_planning_test.go @@ -31,6 +31,7 @@ import ( "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" ) @@ -735,6 +736,132 @@ func TestRegistryPackageAdapterRejectsDifferentSourceIdentity(t *testing.T) { 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 := makeSyncPackageV2Archive(t, archivePath, driverID, version, config.PlatformTuple()) + 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() diff --git a/config/install_transaction_install_test.go b/config/install_transaction_install_test.go index db98680c..6a10fa5e 100644 --- a/config/install_transaction_install_test.go +++ b/config/install_transaction_install_test.go @@ -104,6 +104,10 @@ func TestInstallPackageMetadataFailuresPreserveOldGeneration(t *testing.T) { {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" }}, @@ -165,6 +169,46 @@ func TestInstallPackageReceiptHashesLibraryAfterVerification(t *testing.T) { } } +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} diff --git a/config/package_archive.go b/config/package_archive.go index c6beebae..2aab38fa 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -166,7 +166,7 @@ func stagePackageArchive(location, runtimeID, finalDir string, downloaded *os.Fi if expected.ArchiveHash != "" && expected.ArchiveHash != archiveHash { return result, "", sharedIdentity, fmt.Errorf("package archive hash mismatch: got %s, expected %s", archiveHash, expected.ArchiveHash) } - if expected.ArchiveSize > 0 && expected.ArchiveSize != archiveSize { + if expected.hasExpectedArchiveSize() && expected.ArchiveSize != archiveSize { return result, "", sharedIdentity, fmt.Errorf("package archive size mismatch: got %d, expected %d", archiveSize, expected.ArchiveSize) } diff --git a/config/package_archive_types.go b/config/package_archive_types.go index 26206b22..f60bb324 100644 --- a/config/package_archive_types.go +++ b/config/package_archive_types.go @@ -33,16 +33,23 @@ const ( // ExpectedPackageMetadata describes the resolution that selected an archive. // Hashes use the canonical form "sha256:". ArchiveSize is the -// size of the compressed archive, not the extracted library. +// 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 - PackageVersion int - Platform string - SourceType string - SourceIdentity string - ArchiveHash string - ArchiveSize int64 + ID string + Version string + PackageVersion int + 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 diff --git a/config/package_install.go b/config/package_install.go index 3a4bb011..eab28cae 100644 --- a/config/package_install.go +++ b/config/package_install.go @@ -443,12 +443,13 @@ func PreparePackage(cfg Config, runtimeID string, downloaded *os.File, expected return PackageValidation{}, errors.New("could not read prepared package receipt") } if manifest.PackageVersion == 2 && requested.ArchiveHash == "" { - return PackageValidation{}, errors.New("package v2 requires archive hash and size metadata") + return PackageValidation{}, errors.New("package v2 requires archive hash metadata") } finalExpected := requested finalExpected.PackageVersion = manifest.PackageVersion 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, @@ -528,11 +529,11 @@ func InstallReceiptMatchesRuntimeRegistration(receipt InstallReceipt, current Dr // expectation requires an exact receipt match. func InstallReceiptMatchesExpectedPackage(receipt InstallReceipt, expected ExpectedPackageMetadata) bool { return expected.ID != "" && expected.Version != "" && expected.Platform != "" && - expected.SourceType != "" && expected.SourceIdentity != "" && expected.ArchiveHash != "" && expected.ArchiveSize > 0 && + 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 && - receipt.ArchiveSize == expected.ArchiveSize && + (!expected.hasExpectedArchiveSize() || receipt.ArchiveSize == expected.ArchiveSize) && (expected.PackageVersion == 0 || receipt.PackageVersion == expected.PackageVersion) } @@ -710,7 +711,7 @@ func normalizePackageInstallMetadata(runtimeID string, expected ExpectedPackageM 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.ArchiveSize != 0 { + if expected.ArchiveHash != "" || expected.hasExpectedArchiveSize() { if err := validateExpectedPackage(expected); err != nil { return ExpectedPackageMetadata{}, err } diff --git a/config/package_receipts.go b/config/package_receipts.go index 9c86e4a9..fe309076 100644 --- a/config/package_receipts.go +++ b/config/package_receipts.go @@ -573,8 +573,8 @@ func validateExpectedPackage(expected ExpectedPackageMetadata) error { 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 be positive") + 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) diff --git a/drivers.go b/drivers.go index a03b9614..de0026b6 100644 --- a/drivers.go +++ b/drivers.go @@ -324,7 +324,6 @@ type registryPackage struct { PlatformTuple string `yaml:"platform"` URL string `yaml:"url"` Hash yaml.Node `yaml:"hash,omitempty"` - Size yaml.Node `yaml:"size,omitempty"` } func (p pkginfo) GetPackage(d Driver, platformTuple string) (PkgInfo, error) { @@ -352,7 +351,6 @@ func (p pkginfo) GetPackage(d Driver, platformTuple string) (PkgInfo, error) { PlatformTuple: platformTuple, Path: uri, ArtifactHash: artifact.Hash, - ArtifactSize: artifact.Size, }, nil } } @@ -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/internal/resolution/types.go b/internal/resolution/types.go index 67785f45..d4d0ffd6 100644 --- a/internal/resolution/types.go +++ b/internal/resolution/types.go @@ -318,9 +318,9 @@ func ValidateEvidence(evidence []Evidence) error { } // ValidateResolvedReleaseCandidate checks a resolved release before it is -// downloaded or used to form an install candidate. Archive hashes and sizes -// may both be absent at this stage, but any supplied pair and all other -// metadata must be valid. +// 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) @@ -349,15 +349,18 @@ func ValidateResolvedReleaseCandidate(release ResolvedRelease) error { } seenTargets[artifact.Target] = struct{}{} if prior, ok := seenLocations[artifact.Location]; ok { - if prior.Hash != artifact.Hash || !sameSize(prior.Size, artifact.Size) { + 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 hash and size must either both be present or both be absent", i) + 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) @@ -368,7 +371,7 @@ func ValidateResolvedReleaseCandidate(release ResolvedRelease) error { // 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 and size. +// artifact must have a finalized hash; size remains optional metadata. func ValidateResolvedRelease(release ResolvedRelease) error { if err := ValidateResolvedReleaseCandidate(release); err != nil { return err @@ -377,16 +380,13 @@ func ValidateResolvedRelease(release ResolvedRelease) error { if artifact.Hash == "" { return fmt.Errorf("artifact %d has no finalized hash", i) } - if artifact.Size == nil { - return fmt.Errorf("artifact %d has no finalized size", i) - } } return nil } -func sameSize(left, right *int64) bool { - if left == nil || right == nil { - return left == nil && right == nil +func compatibleArtifactMetadata(left, right Artifact) bool { + if left.Hash != "" && right.Hash != "" && left.Hash != right.Hash { + return false } - return *left == *right + 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 index d7eed723..faebd20a 100644 --- a/internal/resolution/types_test.go +++ b/internal/resolution/types_test.go @@ -117,8 +117,7 @@ func TestValidateResolvedReleaseRequiresFinalizedArtifacts(t *testing.T) { mutate func(*ResolvedRelease) wantErr string }{ - {name: "missing hash", mutate: func(r *ResolvedRelease) { r.Artifacts[0].Hash = "" }, wantErr: "hash and size must either both be present or both be absent"}, - {name: "missing size", mutate: func(r *ResolvedRelease) { r.Artifacts[0].Size = nil }, wantErr: "hash and size must either both be present or both be absent"}, + {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: "unsupported package version 1", mutate: func(r *ResolvedRelease) { r.Artifacts[0].PackageVersion = 1 }, wantErr: "unsupported dbc package version 1"}, {name: "negative package version", mutate: func(r *ResolvedRelease) { r.Artifacts[0].PackageVersion = -1 }, wantErr: "unsupported dbc package version -1"}, @@ -139,7 +138,7 @@ func TestValidateResolvedReleaseRequiresFinalizedArtifacts(t *testing.T) { } } -func TestValidateResolvedReleaseCandidateAllowsMissingArchiveHashAndSize(t *testing.T) { +func TestValidateResolvedReleaseCandidateAllowsOptionalArchiveSize(t *testing.T) { size := int64(12) candidate := ResolvedRelease{Artifacts: []Artifact{{ Target: Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, @@ -149,10 +148,11 @@ func TestValidateResolvedReleaseCandidateAllowsMissingArchiveHashAndSize(t *test assert.NoError(t, ValidateResolvedReleaseCandidate(candidate)) assert.ErrorContains(t, ValidateResolvedRelease(candidate), "no finalized hash") candidate.Artifacts[0].Size = &size - assert.ErrorContains(t, ValidateResolvedReleaseCandidate(candidate), "hash and size must either both be present or both be absent") + assert.ErrorContains(t, ValidateResolvedReleaseCandidate(candidate), "size without a hash") candidate.Artifacts[0].Size = nil candidate.Artifacts[0].Hash = "sha256:" + strings.Repeat("a", 64) - assert.ErrorContains(t, ValidateResolvedReleaseCandidate(candidate), "hash and size must either both be present or both be absent") + 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)) @@ -194,6 +194,9 @@ func TestResolvedReleaseAllowsSharedLocationButRejectsConflictingMetadata(t *tes 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") } diff --git a/internal/sourceresolution/requirement_test.go b/internal/sourceresolution/requirement_test.go index 9bd128ef..3da52021 100644 --- a/internal/sourceresolution/requirement_test.go +++ b/internal/sourceresolution/requirement_test.go @@ -226,7 +226,7 @@ func TestRequirementPlanOutcomesAndStaleReleaseIsolation(t *testing.T) { invalidSnapshot.Artifacts[0].Hash = "" invalidPlan := requirement.Plan(&invalidSnapshot, false) assert.Equal(t, PlanReject, invalidPlan.Outcome()) - assert.ErrorContains(t, invalidPlan.Err(), "hash and size must either both be present or both be absent") + assert.ErrorContains(t, invalidPlan.Err(), "size without a hash") _, ok = invalidPlan.Release() assert.False(t, ok) } diff --git a/internal/sourceresolution/resolver.go b/internal/sourceresolution/resolver.go index efa6346c..99ff54a0 100644 --- a/internal/sourceresolution/resolver.go +++ b/internal/sourceresolution/resolver.go @@ -82,6 +82,9 @@ func ResolvePackslip(ctx context.Context, resolver packslip.Resolver, project, d if artifact.PackageVersion != 2 { return resolution.ResolvedRelease{}, fmt.Errorf("packslip artifact %d must declare dbc package_version 2", i) } + if artifact.Size == nil { + return resolution.ResolvedRelease{}, fmt.Errorf("packslip artifact %d must declare archive size", i) + } } return release, nil } diff --git a/internal/sourceresolution/resolver_test.go b/internal/sourceresolution/resolver_test.go index 6cd707e5..f08b336b 100644 --- a/internal/sourceresolution/resolver_test.go +++ b/internal/sourceresolution/resolver_test.go @@ -68,8 +68,8 @@ func TestResolvePackslipRejectsMismatchedMetadata(t *testing.T) { {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: "hash and size must either both be present or both be absent"}, - {name: "artifact size", change: func(r *resolution.ResolvedRelease) { r.Artifacts[0].Size = nil }, want: "hash and size must either both be present or both be absent"}, + {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"}, {name: "missing package marker", change: func(r *resolution.ResolvedRelease) { r.Artifacts[0].PackageVersion = 0 }, want: "must declare dbc package_version 2"}, } for _, test := range tests { diff --git a/registry_artifact.go b/registry_artifact.go index 12bd6250..ccd2ff00 100644 --- a/registry_artifact.go +++ b/registry_artifact.go @@ -107,19 +107,7 @@ func (p registryPackage) resolveArtifact() (resolution.Artifact, error) { return resolution.Artifact{}, fmt.Errorf("artifact hash must not be empty") } - var size *int64 - if p.Size.Kind != 0 { - if p.Size.Kind != yaml.ScalarNode || p.Size.Tag != "!!int" { - return resolution.Artifact{}, fmt.Errorf("artifact size must be an integer") - } - var value int64 - if err := p.Size.Decode(&value); err != nil { - return resolution.Artifact{}, fmt.Errorf("invalid artifact size: %w", err) - } - size = &value - } - - if err := resolution.ValidateArtifactMetadata(hash, size); err != nil { + if err := resolution.ValidateArtifactMetadata(hash, nil); err != nil { return resolution.Artifact{}, err } target, err := resolution.TargetFromPlatformTuple(p.PlatformTuple) @@ -129,7 +117,6 @@ func (p registryPackage) resolveArtifact() (resolution.Artifact, error) { return resolution.Artifact{ Target: target, Hash: hash, - Size: size, }, nil } diff --git a/registry_artifact_test.go b/registry_artifact_test.go index bfdba00b..e61afd8c 100644 --- a/registry_artifact_test.go +++ b/registry_artifact_test.go @@ -42,8 +42,7 @@ func TestRegistryArtifactMetadataFixture(t *testing.T) { pkg, err := driver.GetPackage(nil, "linux_amd64", false) require.NoError(t, err) assert.Equal(t, "sha256:0000000000000000000000000000000000000000000000000000000000000000", pkg.ArtifactHash) - require.NotNil(t, pkg.ArtifactSize) - assert.EqualValues(t, 12345, *pkg.ArtifactSize) + 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) @@ -57,11 +56,17 @@ func TestRegistryArtifactMetadataOptional(t *testing.T) { assert.Empty(t, artifact.Hash) assert.Nil(t, artifact.Size) - pkg = decodeRegistryPackage(t, "platform: linux_amd64\nsize: 0\n") + pkg = decodeRegistryPackage(t, "platform: linux_amd64\nfuture_metadata: anything\n") artifact, err = pkg.resolveArtifact() require.NoError(t, err) - require.NotNil(t, artifact.Size) - assert.Zero(t, *artifact.Size) + 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) { @@ -257,7 +262,7 @@ func TestRegistryTupleAliasesCanonicalizeWithoutChangingImplicitAssetURL(t *test "implicit asset filenames retain the raw registry tuple") } -func TestRegistryArtifactMetadataRejectsInvalidValues(t *testing.T) { +func TestRegistryArtifactMetadataRejectsInvalidHashes(t *testing.T) { tests := []struct { name string metadata string @@ -270,10 +275,6 @@ func TestRegistryArtifactMetadataRejectsInvalidValues(t *testing.T) { {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"}, - {name: "non-integer size", metadata: "size: 1.5\n", want: "size must be an integer"}, - {name: "string size", metadata: "size: \"123\"\n", want: "size must be an integer"}, - {name: "null size", metadata: "size: null\n", want: "size must be an integer"}, - {name: "negative size", metadata: "size: -1\n", want: "must not be negative"}, } for _, tt := range tests { @@ -285,6 +286,14 @@ func TestRegistryArtifactMetadataRejectsInvalidValues(t *testing.T) { } } +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 diff --git a/testdata/registry-index-artifact-metadata.yaml b/testdata/registry-index-artifact-metadata.yaml index e43d47e7..b8e09eff 100644 --- a/testdata/registry-index-artifact-metadata.yaml +++ b/testdata/registry-index-artifact-metadata.yaml @@ -10,4 +10,4 @@ drivers: - platform: linux_amd64 url: example-driver/1.2.3/example-driver-linux_amd64.tar.gz hash: sha256:0000000000000000000000000000000000000000000000000000000000000000 - size: 12345 + future_metadata: ignored From 382be68084d284be4df46a54f7f9ebb44e9c6464 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Fri, 25 Sep 2026 02:50:20 +0000 Subject: [PATCH 108/115] docs: clarify optional lock artifact sizes --- docs/guides/driver_list.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/guides/driver_list.md b/docs/guides/driver_list.md index e9bec22b..b2ec64a8 100644 --- a/docs/guides/driver_list.md +++ b/docs/guides/driver_list.md @@ -106,18 +106,20 @@ The first time you run `dbc sync`, dbc creates a [lockfile](#lockfile) in the sa 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 a complete lock entry exists, `dbc sync` replays the exact version and -artifact, verifying its digest and size without registry or Packslip discovery. -If the current platform is absent, the lock is partial: a refresh may add that -target for the same source and version, but existing target entries are -immutable. +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 v2 lockfile records an exact version, source identity, verification -evidence, and concrete target artifacts with their location, SHA-256 digest, -and size. A missing target is not evidence that the target is available. +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. For example, a source can be pinned explicitly: From 9be32ed7af396dd22d6663b5a3fde254cdfb410e Mon Sep 17 00:00:00 2001 From: eitsupi Date: Fri, 25 Sep 2026 03:00:23 +0000 Subject: [PATCH 109/115] test: use suite config level for registry sync --- cmd/dbc/sync_execution_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/dbc/sync_execution_test.go b/cmd/dbc/sync_execution_test.go index 527e14b6..40091ee9 100644 --- a/cmd/dbc/sync_execution_test.go +++ b/cmd/dbc/sync_execution_test.go @@ -382,7 +382,7 @@ func (suite *SubcommandTestSuite) TestSyncPartialRegistryDownloadsEachArchiveOnc 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, NoVerify: true}.GetModelCustom(baseModel{ + 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]++ @@ -413,7 +413,7 @@ func (suite *SubcommandTestSuite) TestSyncPartialRegistryDownloadsEachArchiveOnc v2List := filepath.Join(suite.tempdir, "v2.toml") suite.Require().NoError(os.WriteFile(v2List, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0644)) v2Downloads := 0 - v2Model := SyncCmd{Path: v2List, NoVerify: true}.GetModelCustom(baseModel{ + v2Model := SyncCmd{Path: v2List, Level: suite.configLevel, NoVerify: true}.GetModelCustom(baseModel{ getDriverRegistry: getTestDriverRegistry, downloadPkg: func(dbc.PkgInfo) (*os.File, error) { v2Downloads++ From 55a8953b025a7227f44556501c6ee8abed460ddb Mon Sep 17 00:00:00 2001 From: eitsupi Date: Fri, 25 Sep 2026 03:25:47 +0000 Subject: [PATCH 110/115] docs: clarify optional artifact size invariant --- internal/resolution/types.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/resolution/types.go b/internal/resolution/types.go index d4d0ffd6..58711344 100644 --- a/internal/resolution/types.go +++ b/internal/resolution/types.go @@ -227,7 +227,8 @@ type ResolvedRelease struct { } // Artifact describes one downloadable archive for one concrete target. Hash -// and Size are either both absent before download or both present afterward. +// may be absent before resolution but is required for a finalized snapshot. +// Size is optional metadata. type Artifact struct { Target Target Format string From bce2cdcbcf143d6e74de022a6409b83e821af031 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Fri, 25 Sep 2026 03:51:08 +0000 Subject: [PATCH 111/115] refactor: keep Packslip package checks at resolution boundary --- cmd/dbc/lockfile_v2.go | 7 --- cmd/dbc/lockfile_v2_test.go | 59 ++++--------------- internal/sourceresolution/plan.go | 3 - internal/sourceresolution/requirement.go | 15 ----- internal/sourceresolution/requirement_test.go | 46 ++++++++++++--- 5 files changed, 47 insertions(+), 83 deletions(-) diff --git a/cmd/dbc/lockfile_v2.go b/cmd/dbc/lockfile_v2.go index 0e23fbdd..f87121e9 100644 --- a/cmd/dbc/lockfile_v2.go +++ b/cmd/dbc/lockfile_v2.go @@ -292,13 +292,6 @@ func validateLockInfo(entry lockInfo) error { if err := validateLockArtifacts(entry.Artifacts); err != nil { return err } - if entry.Source.Type == "packslip" { - for i, artifact := range entry.Artifacts { - if artifact.PackageVersion != 2 { - return fmt.Errorf("driver %q packslip artifact %d must declare dbc package_version = 2, got %d", entry.Name, i, artifact.PackageVersion) - } - } - } return nil } diff --git a/cmd/dbc/lockfile_v2_test.go b/cmd/dbc/lockfile_v2_test.go index 97c6acce..3151d041 100644 --- a/cmd/dbc/lockfile_v2_test.go +++ b/cmd/dbc/lockfile_v2_test.go @@ -22,7 +22,6 @@ import ( "net/url" "os" "path/filepath" - "strconv" "strings" "testing" @@ -210,50 +209,9 @@ func TestLockFileV2AllowsPartialArtifactSetAndReplayNeedsNoDiscovery(t *testing. assert.Contains(t, err.Error(), "locked mode") } -func TestPackslipLockRequiresPackageVersionTwoForEveryArtifact(t *testing.T) { - for artifactIndex := range testLockEntry().Artifacts { - t.Run(strconv.Itoa(artifactIndex), func(t *testing.T) { - entry := testLockEntry() - entry.Artifacts[artifactIndex].PackageVersion = 0 - - err := validateLockInfo(entry) - require.ErrorContains(t, err, "packslip artifact") - require.ErrorContains(t, err, "package_version = 2") - }) - } -} - -func TestPackslipLockWithoutPackageVersionIsRejectedDuringLoad(t *testing.T) { - tests := []struct { - name string - replacement string - }{ - {name: "missing", replacement: ""}, - {name: "zero", replacement: "package_version = 0\n"}, - {name: "unknown", replacement: "package_version = 3\n"}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - path := filepath.Join(t.TempDir(), "dbc.lock") - require.NoError(t, writeLockFileAtomic(path, LockFile{Version: lockFileVersion, Drivers: []lockInfo{testLockEntry()}})) - data, err := os.ReadFile(path) - require.NoError(t, err) - data = []byte(strings.ReplaceAll(string(data), "package_version = 2\n", test.replacement)) - require.NoError(t, os.WriteFile(path, data, 0o600)) - - _, err = loadLockFile(path) - if test.name != "unknown" { - require.ErrorContains(t, err, "packslip artifact") - require.ErrorContains(t, err, "package_version = 2") - } else { - require.ErrorContains(t, err, "unsupported dbc package version 3") - } - }) - } -} - -func TestPackageVersionZeroRemainsAllowedForRegistryAndPathLocks(t *testing.T) { +func TestPackageVersionZeroIsAllowedForAllLockSources(t *testing.T) { sources := []lockSource{ + {Type: "packslip", Project: "github.com/example/driver"}, {Type: "registry", URL: "https://registry.example.test"}, {Type: "path", Path: "./drivers/example"}, } @@ -273,6 +231,10 @@ func TestPackageVersionZeroRemainsAllowedForRegistryAndPathLocks(t *testing.T) { for _, artifact := range loaded.lockinfo[entry.Name].Artifacts { assert.Zero(t, artifact.PackageVersion) } + + entry.Artifacts[0].PackageVersion = 3 + err = validateLockInfo(entry) + require.ErrorContains(t, err, "unsupported dbc package version 3") }) } } @@ -372,15 +334,14 @@ func TestRefreshRejectsArtifactContradictionAndAllowsNewTarget(t *testing.T) { assert.Len(t, merged.Artifacts, 3) } -func TestRefreshRejectsUnprovenPackslipLockInsteadOfBackfilling(t *testing.T) { +func TestRefreshBackfillsMissingPackslipPackageVersion(t *testing.T) { existing := testLockEntry() existing.Artifacts[0].PackageVersion = 0 refreshed := testLockEntry() - _, err := refreshLockEntry(existing, refreshed) - require.ErrorContains(t, err, "existing lock entry") - require.ErrorContains(t, err, "packslip artifact") - require.ErrorContains(t, err, "package_version = 2") + merged, err := refreshLockEntry(existing, refreshed) + require.NoError(t, err) + assert.Equal(t, 2, merged.Artifacts[0].PackageVersion) } func TestRefreshBackfillsMissingNonPackslipPackageVersion(t *testing.T) { diff --git a/internal/sourceresolution/plan.go b/internal/sourceresolution/plan.go index a85d1e64..b40ad2bf 100644 --- a/internal/sourceresolution/plan.go +++ b/internal/sourceresolution/plan.go @@ -88,9 +88,6 @@ func (requirement Requirement) Plan(existing *resolution.ResolvedRelease, locked if err := validateExistingIdentity(*existing); err != nil { return Plan{outcome: PlanReject, reason: "invalid existing release identity: " + err.Error()} } - if err := validatePackslipReleaseArtifacts(*existing); err != nil { - return Plan{outcome: PlanReject, reason: err.Error()} - } if err := requirement.validateReleaseIdentity(*existing); err != nil { return Plan{outcome: PlanResolve} } diff --git a/internal/sourceresolution/requirement.go b/internal/sourceresolution/requirement.go index 58c68c70..09caaf39 100644 --- a/internal/sourceresolution/requirement.go +++ b/internal/sourceresolution/requirement.go @@ -324,9 +324,6 @@ func (requirement Requirement) ValidateResolverResult(release resolution.Resolve if err := resolution.ValidateResolvedReleaseCandidate(release); err != nil { return resolution.Artifact{}, fmt.Errorf("invalid resolved release: %w", err) } - if err := validatePackslipReleaseArtifacts(release); err != nil { - return resolution.Artifact{}, 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) @@ -350,18 +347,6 @@ func (requirement Requirement) validateReleaseIdentity(release resolution.Resolv return nil } -func validatePackslipReleaseArtifacts(release resolution.ResolvedRelease) error { - if release.Source.Type != string(sourceidentity.Packslip) { - return nil - } - for i, artifact := range release.Artifacts { - if artifact.PackageVersion != 2 { - return fmt.Errorf("resolved Packslip artifact %d has unsupported dbc package_version %d", i, artifact.PackageVersion) - } - } - return nil -} - func artifactForTarget(artifacts []resolution.Artifact, target resolution.Target) (resolution.Artifact, bool) { for _, artifact := range artifacts { if artifact.Target == target { diff --git a/internal/sourceresolution/requirement_test.go b/internal/sourceresolution/requirement_test.go index 3da52021..743487a1 100644 --- a/internal/sourceresolution/requirement_test.go +++ b/internal/sourceresolution/requirement_test.go @@ -270,19 +270,47 @@ func TestExplicitSourceAcceptsOnlyExactCanonicalKey(t *testing.T) { missingMarker := cloneResolvedRelease(matching) missingMarker.Artifacts[0].PackageVersion = 0 _, err = requirement.ValidateResolverResult(missingMarker) - assert.ErrorContains(t, err, "package_version") + assert.NoError(t, err, "package version zero is source-neutral unspecified metadata") for _, locked := range []bool{false, true} { plan := requirement.Plan(&missingMarker, locked) - assert.Equal(t, PlanReject, plan.Outcome()) - assert.ErrorContains(t, plan.Err(), "package_version") - _, hasRelease := plan.Release() - assert.False(t, hasRelease, "invalid Packslip artifacts are not refresh inputs") - _, hasArtifact := plan.Artifact() - assert.False(t, hasArtifact, "invalid Packslip artifacts are never executable") + assert.Equal(t, PlanReplay, plan.Outcome()) + selected, hasArtifact := plan.Artifact() + assert.True(t, hasArtifact) + assert.Zero(t, selected.PackageVersion) } newVersionRequirement := mustRequirement(t, "driver", selection, mustPackslipVersion(t, "1.2.4"), planTarget) - assert.Equal(t, PlanReject, newVersionRequirement.Plan(&missingMarker, false).Outcome(), - "an invalid Packslip snapshot cannot be healed by changing the requested version") + assert.Equal(t, PlanResolve, newVersionRequirement.Plan(&missingMarker, false).Outcome(), + "a stale version is resolved without reinterpreting package version metadata") +} + +func TestPackageVersionZeroIsSourceNeutral(t *testing.T) { + registryRequirement := mustRequirement(t, "driver", DefaultRegistrySelection(), mustRegistryVersion(t, "=1.2.3", PrereleaseForbidden), planTarget) + registryRelease := validPlanRelease("registry", "https://registry.example.test", "1.2.3", "driver", planTarget) + registryRelease.Artifacts[0].PackageVersion = 0 + assertPackageVersionZeroCanReplay(t, registryRequirement, registryRelease) + + pathKey, err := sourceidentity.Parse(sourceidentity.Path, "./driver.tar.gz") + require.NoError(t, err) + pathSelection, err := ExplicitSourceSelection(pathKey) + require.NoError(t, err) + pathVersion, err := PathVersionRequirement("1.2.3") + require.NoError(t, err) + pathRequirement := mustRequirement(t, "driver", pathSelection, pathVersion, planTarget) + pathRelease := validPlanRelease("path", "./driver.tar.gz", "1.2.3", "driver", planTarget) + pathRelease.Artifacts[0].PackageVersion = 0 + assertPackageVersionZeroCanReplay(t, pathRequirement, pathRelease) +} + +func assertPackageVersionZeroCanReplay(t *testing.T, requirement Requirement, release resolution.ResolvedRelease) { + t.Helper() + selected, err := requirement.ValidateResolverResult(release) + require.NoError(t, err) + assert.Zero(t, selected.PackageVersion) + plan := requirement.Plan(&release, false) + assert.Equal(t, PlanReplay, plan.Outcome()) + selected, ok := plan.Artifact() + require.True(t, ok) + assert.Zero(t, selected.PackageVersion) } func mustRequirement(t *testing.T, driverID string, source SourceSelection, version VersionRequirement, target resolution.Target) Requirement { From deb556f467dbd171b7b159acffd78d3f96b6f82b Mon Sep 17 00:00:00 2001 From: eitsupi Date: Fri, 25 Sep 2026 12:08:47 +0000 Subject: [PATCH 112/115] fix: decouple Packslip artifact markers from package formats --- cmd/dbc/install_test.go | 19 +++++++++++++- cmd/dbc/packslip_fetch_test.go | 2 +- cmd/dbc/sync_execution_test.go | 2 +- cmd/dbc/sync_planning_test.go | 2 +- cmd/dbc/sync_test_helpers_test.go | 4 +-- docs/guides/installing.md | 2 +- internal/packslip/dbc_extension.go | 29 ++++++++++++++-------- internal/packslip/dbc_extension_test.go | 22 ++++++++++------ internal/packslip/model.go | 26 +++++++++---------- internal/packslip/model_test.go | 2 +- internal/packslip/resolver_native.go | 4 +-- internal/packslip/resolver_native_test.go | 4 +-- internal/packslip/selection.go | 8 +++--- internal/sourceresolution/resolver.go | 4 +-- internal/sourceresolution/resolver_test.go | 4 +-- 15 files changed, 82 insertions(+), 52 deletions(-) diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index 1c83ed7e..5f020faf 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -73,7 +73,7 @@ func packslipInstallRelease(t *testing.T, id, version, artifactURL string, archi Hash: "sha256:" + strings.Repeat("a", 64), }}, Artifacts: []resolution.Artifact{{ - Target: target, Format: "tgz", PackageVersion: 2, + Target: target, Format: "tgz", PackageVersion: 0, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: artifactURL}, Hash: "sha256:" + hex.EncodeToString(digest[:]), Size: &size, }}, @@ -473,6 +473,23 @@ func (suite *SubcommandTestSuite) TestInstallPackslipDirectUsesSignedIDWithoutRe } } +func (suite *SubcommandTestSuite) TestInstallPackslipDirectInspectsLegacyManifestArchive() { + archive, err := os.ReadFile(filepath.Join("testdata", "test-driver-1.1.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, NoVerify: true}. + 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 diff --git a/cmd/dbc/packslip_fetch_test.go b/cmd/dbc/packslip_fetch_test.go index 32a8c2c0..1eb1a6ae 100644 --- a/cmd/dbc/packslip_fetch_test.go +++ b/cmd/dbc/packslip_fetch_test.go @@ -53,7 +53,7 @@ func TestPackslipArtifactFetchPreservesSignedURLWithoutCredentials(t *testing.T) DriverID: "example", Version: "1.2.3", Source: resolution.SourceSpec{Type: "packslip", Reference: "github.com/example/driver"}, Artifacts: []resolution.Artifact{{ - Target: target, Format: "tgz", PackageVersion: 2, + Target: target, Format: "tgz", PackageVersion: 0, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: artifactURL.String()}, }}, }, 0, config.PlatformTuple(), nil) diff --git a/cmd/dbc/sync_execution_test.go b/cmd/dbc/sync_execution_test.go index 40091ee9..09083e42 100644 --- a/cmd/dbc/sync_execution_test.go +++ b/cmd/dbc/sync_execution_test.go @@ -76,7 +76,7 @@ func TestPackageExecutorKeepsSourceArtifactMetadataInLock(t *testing.T) { packageVersion int }{ {name: "registry metadata without package version", sourceType: "registry", sourceReference: "https://registry.example.test", packageVersion: 0}, - {name: "Packslip declared metadata", sourceType: "packslip", sourceReference: "github.com/example/driver", packageVersion: 2}, + {name: "Packslip unspecified format", sourceType: "packslip", sourceReference: "github.com/example/driver", packageVersion: 0}, } { t.Run(test.name, func(t *testing.T) { archivePath := filepath.Join(t.TempDir(), "driver.tar.gz") diff --git a/cmd/dbc/sync_planning_test.go b/cmd/dbc/sync_planning_test.go index 64d24139..940428da 100644 --- a/cmd/dbc/sync_planning_test.go +++ b/cmd/dbc/sync_planning_test.go @@ -140,7 +140,7 @@ func TestFreshPackslipResolutionSnapshotsAndReplaysWithoutDiscovery(t *testing.T 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, 2, primaryArtifact.PackageVersion) + assert.Zero(t, primaryArtifact.PackageVersion) 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") diff --git a/cmd/dbc/sync_test_helpers_test.go b/cmd/dbc/sync_test_helpers_test.go index 1278b5ab..3c20d5df 100644 --- a/cmd/dbc/sync_test_helpers_test.go +++ b/cmd/dbc/sync_test_helpers_test.go @@ -99,8 +99,8 @@ func makeSyncPackslipRelease(id, version, url, hash string, size int64) resoluti Hash: "sha256:" + strings.Repeat("a", 64), }}, Artifacts: []resolution.Artifact{ - {Target: primary, Format: "tgz", PackageVersion: 2, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: url}, Hash: hash, Size: &size}, - {Target: secondary, Format: "tar.gz", PackageVersion: 2, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://assets.example.test/macos.tar.gz"}, Hash: "sha256:" + strings.Repeat("b", 64), Size: &otherSize}, + {Target: primary, Format: "tgz", PackageVersion: 0, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: url}, Hash: hash, Size: &size}, + {Target: secondary, Format: "tar.gz", PackageVersion: 0, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://assets.example.test/macos.tar.gz"}, Hash: "sha256:" + strings.Repeat("b", 64), Size: &otherSize}, }, } } diff --git a/docs/guides/installing.md b/docs/guides/installing.md index b6270673..81df9f7d 100644 --- a/docs/guides/installing.md +++ b/docs/guides/installing.md @@ -271,7 +271,7 @@ dbc can install drivers from local archives as an alternative to a [Driver Regis ### Package archive metadata -dbc 0.4.0 (unreleased) archives use a root-level file named exactly `dbc-package.toml` with `package_version = 2` to describe their contents. Archives for dbc 0.3.0 and earlier use a root-level file named exactly `MANIFEST` and remain accepted for compatibility. These package metadata files are separate from Packslip release metadata and the installed `.toml` ADBC Driver Manifest (`manifest_version = 1`). +dbc accepts both package formats from local paths, registries, and Packslip releases. Legacy archives with a root-level `MANIFEST` remain fully supported, including for dbc 0.3.x compatibility. `dbc-package.toml` with `package_version = 2` is an optional package format that gives the archive explicit driver identity and platform metadata and separates dbc packaging details from the installed `.toml` ADBC Driver Manifest (`manifest_version = 1`). Source metadata, including Packslip extensions, identifies the artifact but does not need to declare its package format; dbc determines that format by inspecting the downloaded archive. For example, `dbc-package.toml` can contain: diff --git a/internal/packslip/dbc_extension.go b/internal/packslip/dbc_extension.go index 9a96cc82..bb46e34b 100644 --- a/internal/packslip/dbc_extension.go +++ b/internal/packslip/dbc_extension.go @@ -21,10 +21,6 @@ type dbcReleaseExtension struct { DriverID string `json:"driver_id"` } -type dbcArtifactExtension struct { - PackageVersion int `json:"package_version"` -} - // validateDBCReleaseExtensions applies dbc's consumer contract to a verified // Packslip release. Other Packslip extensions remain owned by their consumers. func validateDBCReleaseExtensions(release *parsedRelease) error { @@ -53,14 +49,10 @@ func validateDBCReleaseExtensions(release *parsedRelease) error { if !ok { continue } - var declaration dbcArtifactExtension - if err := decodeDBCObject(raw, &declaration); err != nil { + if err := validateDBCArtifactMarker(raw); err != nil { return fmt.Errorf("invalid packslip artifact %q extensions.dbc declaration: %w", artifact.Name, err) } - if declaration.PackageVersion != 2 { - return fmt.Errorf("unsupported dbc package_version %d on packslip artifact %q", declaration.PackageVersion, artifact.Name) - } - artifact.dbcPackageVersion = declaration.PackageVersion + artifact.dbcArtifact = true } return nil } @@ -70,9 +62,24 @@ func decodeDBCObject(raw json.RawMessage, destination any) error { if len(trimmed) == 0 || trimmed[0] != '{' { return errors.New("must be a non-null object") } - if err := decodeStrict(trimmed, destination); err != nil { + 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 } diff --git a/internal/packslip/dbc_extension_test.go b/internal/packslip/dbc_extension_test.go index 3b000a00..45e6522c 100644 --- a/internal/packslip/dbc_extension_test.go +++ b/internal/packslip/dbc_extension_test.go @@ -15,7 +15,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestDBCReleaseExtensionRequiresExactDeclaration(t *testing.T) { +func TestDBCReleaseExtensionValidatesRequiredFieldsAndIgnoresAdditiveFields(t *testing.T) { tests := []struct { name string raw json.RawMessage @@ -26,9 +26,11 @@ func TestDBCReleaseExtensionRequiresExactDeclaration(t *testing.T) { {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: "unknown field", raw: json.RawMessage(`{"schema_version":1,"driver_id":"driver","future":true}`), want: "unknown field"}, + {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"}, } @@ -50,9 +52,11 @@ func TestDBCReleaseExtensionRequiresExactDeclaration(t *testing.T) { 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 TestDBCArtifactExtensionRequiresExactPackageMarker(t *testing.T) { +func TestDBCArtifactExtensionIsExtensibleMembershipMarker(t *testing.T) { tests := []struct { name string raw json.RawMessage @@ -61,9 +65,6 @@ func TestDBCArtifactExtensionRequiresExactPackageMarker(t *testing.T) { {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"}, - {name: "unknown field", raw: json.RawMessage(`{"package_version":2,"future":true}`), want: "unknown field"}, - {name: "missing package version", raw: json.RawMessage(`{}`), want: "package_version 0"}, - {name: "unsupported package version", raw: json.RawMessage(`{"package_version":1}`), want: "unsupported dbc package_version"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -78,7 +79,12 @@ func TestDBCArtifactExtensionRequiresExactPackageMarker(t *testing.T) { release, err := parseRelease(validRelease("1.0.0"), testProject) require.NoError(t, err) require.NoError(t, validateDBCReleaseExtensions(release)) - require.Equal(t, 2, release.predicate.Artifacts[0].dbcPackageVersion) + require.True(t, release.predicate.Artifacts[0].dbcArtifact) + release.predicate.Artifacts[0].Extensions["dbc"] = json.RawMessage(`{"future_optional_metadata":"x","package_version":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) { @@ -86,7 +92,7 @@ func TestDBCArtifactEligibilityExcludesUnrelatedArchivesFromTargetsAndAmbiguity( 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": mustJSON(dbcArtifactExtension{PackageVersion: 2})}, + Extensions: map[string]json.RawMessage{"dbc": json.RawMessage(`{}`)}, } other := selected other.Name = "unrelated-windows.tar.gz" diff --git a/internal/packslip/model.go b/internal/packslip/model.go index 74212ca8..5a144d01 100644 --- a/internal/packslip/model.go +++ b/internal/packslip/model.go @@ -54,19 +54,19 @@ type releasePredicate struct { } 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"` - dbcPackageVersion int + 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 { diff --git a/internal/packslip/model_test.go b/internal/packslip/model_test.go index 6b70f10f..cf92d0fd 100644 --- a/internal/packslip/model_test.go +++ b/internal/packslip/model_test.go @@ -34,7 +34,7 @@ func validRelease(version string, artifacts ...releaseArtifact) []byte { } for i := range artifacts { if artifacts[i].Extensions == nil { - artifacts[i].Extensions = map[string]json.RawMessage{"dbc": mustJSON(dbcArtifactExtension{PackageVersion: 2})} + artifacts[i].Extensions = map[string]json.RawMessage{"dbc": json.RawMessage(`{}`)} } } project := testProject diff --git a/internal/packslip/resolver_native.go b/internal/packslip/resolver_native.go index a9240f6a..bdc8cd79 100644 --- a/internal/packslip/resolver_native.go +++ b/internal/packslip/resolver_native.go @@ -406,7 +406,7 @@ 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.dbcPackageVersion != 2 || !supportedArchiveFormat(stringValue(artifact.Format)) || artifact.OS == nil || artifact.Arch == nil { + if !artifact.dbcArtifact || !supportedArchiveFormat(stringValue(artifact.Format)) || artifact.OS == nil || artifact.Arch == nil { continue } target := resolution.CanonicalTarget(Target{ @@ -482,7 +482,7 @@ func provenancePresence(release *parsedRelease) (map[string]bool, error) { } result := make(map[string]bool, len(release.predicate.Artifacts)) for _, artifact := range release.predicate.Artifacts { - if artifact.dbcPackageVersion != 2 || !supportedArchiveFormat(stringValue(artifact.Format)) { + if !artifact.dbcArtifact || !supportedArchiveFormat(stringValue(artifact.Format)) { continue } selector := artifactSelectorKey(&artifact) diff --git a/internal/packslip/resolver_native_test.go b/internal/packslip/resolver_native_test.go index bc139787..c9dcb6d0 100644 --- a/internal/packslip/resolver_native_test.go +++ b/internal/packslip/resolver_native_test.go @@ -533,7 +533,7 @@ func TestResolverUsesVersionBearingTagAndKeepsProjectAsSourceIdentity(t *testing 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) - require.Equal(t, 2, resolved.Artifacts[0].PackageVersion) + require.Zero(t, resolved.Artifacts[0].PackageVersion) } func TestResolverAdoptsSignedDriverIDWhenNoExpectedIDIsProvided(t *testing.T) { @@ -543,7 +543,7 @@ func TestResolverAdoptsSignedDriverIDWhenNoExpectedIDIsProvided(t *testing.T) { resolved, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{Version: "1.2.3"}) require.NoError(t, err) require.Equal(t, "driver", resolved.DriverID) - require.Equal(t, 2, resolved.Artifacts[0].PackageVersion) + require.Zero(t, resolved.Artifacts[0].PackageVersion) } func TestResolverRejectsExpectedDriverIDThatDiffersFromSignedDeclaration(t *testing.T) { diff --git a/internal/packslip/selection.go b/internal/packslip/selection.go index 2fe11ba6..a5f9bf01 100644 --- a/internal/packslip/selection.go +++ b/internal/packslip/selection.go @@ -27,7 +27,7 @@ func selectArtifact(release *parsedRelease, target Target) (*releaseArtifact, er eligible := *release eligible.predicate.Artifacts = make([]releaseArtifact, 0, len(release.predicate.Artifacts)) for _, artifact := range release.predicate.Artifacts { - if artifact.dbcPackageVersion == 2 && supportedArchiveFormat(stringValue(artifact.Format)) { + if artifact.dbcArtifact && supportedArchiveFormat(stringValue(artifact.Format)) { eligible.predicate.Artifacts = append(eligible.predicate.Artifacts, artifact) } } @@ -91,12 +91,12 @@ func supportedArchiveFormat(format string) bool { } // validateSupportedArtifactSet ensures the release inventory contains at -// least one artifact that declares dbc package support and an installable +// 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].dbcPackageVersion == 2 && supportedArchiveFormat(stringValue(release.predicate.Artifacts[i].Format)) { + if release.predicate.Artifacts[i].dbcArtifact && supportedArchiveFormat(stringValue(release.predicate.Artifacts[i].Format)) { count++ } } @@ -184,7 +184,7 @@ func convertArtifact(release *parsedRelease, artifact *releaseArtifact, artifact result := resolution.Artifact{ Target: resolution.CanonicalTarget(target), Format: stringValue(artifact.Format), - PackageVersion: artifact.dbcPackageVersion, + PackageVersion: 0, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: artifactURL}, Hash: "sha256:" + subject.Digest["sha256"], Size: &size, diff --git a/internal/sourceresolution/resolver.go b/internal/sourceresolution/resolver.go index 99ff54a0..1b3ee64b 100644 --- a/internal/sourceresolution/resolver.go +++ b/internal/sourceresolution/resolver.go @@ -79,8 +79,8 @@ func ResolvePackslip(ctx context.Context, resolver packslip.Resolver, project, d return resolution.ResolvedRelease{}, fmt.Errorf("packslip resolver returned an invalid release: %w", err) } for i, artifact := range release.Artifacts { - if artifact.PackageVersion != 2 { - return resolution.ResolvedRelease{}, fmt.Errorf("packslip artifact %d must declare dbc package_version 2", i) + if artifact.PackageVersion != 0 { + return resolution.ResolvedRelease{}, fmt.Errorf("packslip artifact %d package format must remain unspecified until archive inspection", i) } if artifact.Size == nil { return resolution.ResolvedRelease{}, fmt.Errorf("packslip artifact %d must declare archive size", i) diff --git a/internal/sourceresolution/resolver_test.go b/internal/sourceresolution/resolver_test.go index f08b336b..05b53006 100644 --- a/internal/sourceresolution/resolver_test.go +++ b/internal/sourceresolution/resolver_test.go @@ -70,7 +70,7 @@ func TestResolvePackslipRejectsMismatchedMetadata(t *testing.T) { {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"}, - {name: "missing package marker", change: func(r *resolution.ResolvedRelease) { r.Artifacts[0].PackageVersion = 0 }, want: "must declare dbc package_version 2"}, + {name: "declared package format", change: func(r *resolution.ResolvedRelease) { r.Artifacts[0].PackageVersion = 2 }, want: "remain unspecified"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -188,7 +188,7 @@ func resolvedPackslipRelease(project, version, driverID string) resolution.Resol Version: version, Source: resolution.SourceSpec{Type: "packslip", Reference: project}, Artifacts: []resolution.Artifact{{ - Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, Format: "tar.gz", PackageVersion: 2, + Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, Format: "tar.gz", PackageVersion: 0, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/driver.tar.gz"}, Hash: "sha256:" + strings.Repeat("a", 64), Size: &size, }}, From b8a3efc0b0e51c1ceddddbf596a77512bd3d1376 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Fri, 25 Sep 2026 12:16:44 +0000 Subject: [PATCH 113/115] test: keep Packslip lock refresh format unspecified --- cmd/dbc/lockfile_v2_test.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/cmd/dbc/lockfile_v2_test.go b/cmd/dbc/lockfile_v2_test.go index 3151d041..4cbaaa89 100644 --- a/cmd/dbc/lockfile_v2_test.go +++ b/cmd/dbc/lockfile_v2_test.go @@ -334,14 +334,24 @@ func TestRefreshRejectsArtifactContradictionAndAllowsNewTarget(t *testing.T) { assert.Len(t, merged.Artifacts, 3) } -func TestRefreshBackfillsMissingPackslipPackageVersion(t *testing.T) { +func TestRefreshKeepsPackslipPackageVersionUnspecified(t *testing.T) { existing := testLockEntry() - existing.Artifacts[0].PackageVersion = 0 - refreshed := testLockEntry() + for i := range existing.Artifacts { + existing.Artifacts[i].PackageVersion = 0 + } + + resolved := existing.resolvedRelease() + for i := range resolved.Artifacts { + resolved.Artifacts[i].PackageVersion = 0 + } + refreshed, err := lockInfoFromResolvedRelease(existing.Name, resolved) + require.NoError(t, err) merged, err := refreshLockEntry(existing, refreshed) require.NoError(t, err) - assert.Equal(t, 2, merged.Artifacts[0].PackageVersion) + for _, artifact := range merged.Artifacts { + assert.Zero(t, artifact.PackageVersion) + } } func TestRefreshBackfillsMissingNonPackslipPackageVersion(t *testing.T) { From f11a4ae208db0a33098544d642753d903f9ba3ab Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 26 Sep 2026 01:21:04 +0000 Subject: [PATCH 114/115] refactor: reserve package metadata filename without defining a schema --- client_methods.go | 23 +- client_methods_test.go | 73 +---- cmd/dbc/install_source.go | 15 - cmd/dbc/install_test.go | 82 +++-- cmd/dbc/lockfile.go | 1 - cmd/dbc/lockfile_v2.go | 11 +- cmd/dbc/lockfile_v2_test.go | 87 +---- cmd/dbc/package_executor.go | 13 +- cmd/dbc/packslip_fetch_test.go | 2 +- cmd/dbc/sync_execution.go | 2 +- cmd/dbc/sync_execution_test.go | 40 +-- cmd/dbc/sync_planning_test.go | 25 +- cmd/dbc/sync_test_helpers_test.go | 14 +- config/config.go | 6 +- config/driver.go | 11 +- config/install_transaction_helpers_test.go | 10 +- config/package_archive.go | 81 ++--- config/package_archive_test.go | 308 +++++------------- config/package_archive_types.go | 31 +- config/package_install.go | 16 +- config/package_manifest.go | 150 ++------- config/package_receipts.go | 13 - docs/guides/installing.md | 17 +- internal/packslip/dbc_extension_test.go | 2 +- internal/packslip/resolver_native_test.go | 2 - internal/packslip/selection.go | 11 +- internal/resolution/types.go | 10 +- internal/resolution/types_test.go | 3 - internal/sourceresolution/requirement_test.go | 94 +++--- internal/sourceresolution/resolver.go | 24 +- internal/sourceresolution/resolver_test.go | 52 +-- package_signature.go | 6 +- 32 files changed, 365 insertions(+), 870 deletions(-) diff --git a/client_methods.go b/client_methods.go index 07dd20ca..70395e65 100644 --- a/client_methods.go +++ b/client_methods.go @@ -274,7 +274,7 @@ 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, hasMetadata, err := expectedRegistryPackageMetadata(pkg) + expected, err := expectedRegistryPackageMetadata(pkg) if err != nil { return nil, fmt.Errorf("failed to install driver %s: %w", driverName, err) } @@ -285,15 +285,6 @@ func (c *Client) Install(ctx context.Context, cfg config.Config, driverName stri } defer f.Close() defer os.RemoveAll(filepath.Dir(f.Name())) - if !hasMetadata { - packageManifest, inspectErr := config.InspectPackageMetadata(f) - if inspectErr != nil { - return nil, fmt.Errorf("failed to inspect package for driver %s: %w", driverName, inspectErr) - } - if packageManifest.PackageVersion == 2 { - return nil, fmt.Errorf("failed to install driver %s: registry package v2 requires archive hash metadata", driverName) - } - } manifest, err := config.InstallPackage(cfg, expected.ID, f, expected, config.InstallOptions{ Verify: func(stagingDir string, manifest config.Manifest) error { @@ -307,19 +298,19 @@ func (c *Client) Install(ctx context.Context, cfg config.Config, driverName stri return &manifest, nil } -func expectedRegistryPackageMetadata(pkg PkgInfo) (config.ExpectedPackageMetadata, bool, error) { +func expectedRegistryPackageMetadata(pkg PkgInfo) (config.ExpectedPackageMetadata, error) { hasHash := pkg.ArtifactHash != "" if pkg.Version == nil { - return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata is missing its version") + return config.ExpectedPackageMetadata{}, errors.New("registry package metadata is missing its version") } if strings.TrimSpace(pkg.PlatformTuple) == "" { - return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata is missing its platform") + 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{}, false, errors.New("registry package metadata is missing its source identity") + return config.ExpectedPackageMetadata{}, errors.New("registry package metadata is missing its source identity") } if strings.TrimSpace(pkg.Driver.Path) == "" { - return config.ExpectedPackageMetadata{}, false, errors.New("registry package metadata is missing its driver ID") + return config.ExpectedPackageMetadata{}, errors.New("registry package metadata is missing its driver ID") } expected := config.ExpectedPackageMetadata{ ID: pkg.Driver.Path, @@ -330,7 +321,7 @@ func expectedRegistryPackageMetadata(pkg PkgInfo) (config.ExpectedPackageMetadat if hasHash { expected.ArchiveHash = pkg.ArtifactHash } - return expected, hasHash, nil + return expected, nil } // Uninstall uninstalls a driver with the given name from the specified configuration. diff --git a/client_methods_test.go b/client_methods_test.go index 629ff599..d945cd55 100644 --- a/client_methods_test.go +++ b/client_methods_test.go @@ -15,9 +15,6 @@ package dbc_test import ( - "archive/tar" - "bytes" - "compress/gzip" "crypto/sha256" "encoding/hex" "encoding/json" @@ -37,44 +34,21 @@ import ( "github.com/stretchr/testify/require" ) -func makeClientPackageV2Archive(t *testing.T, platform string) []byte { +func readClientLegacyArchive(t *testing.T) []byte { t.Helper() - manifest := fmt.Sprintf(`package_version = 2 -id = "v2-driver" -name = "V2 Driver" -version = "1.2.3" -platform = %q - -[Driver] -entrypoint = "AdbcDriverV2Init" - -[Files] -driver = "driver.so" -`, platform) - var buffer bytes.Buffer - gz := gzip.NewWriter(&buffer) - tw := tar.NewWriter(gz) - for _, entry := range []struct { - name string - data []byte - }{{"dbc-package.toml", []byte(manifest)}, {"driver.so", []byte("v2 library")}} { - require.NoError(t, tw.WriteHeader(&tar.Header{Name: entry.name, Mode: 0o644, Size: int64(len(entry.data)), Typeflag: tar.TypeReg})) - _, err := tw.Write(entry.data) - require.NoError(t, err) - } - require.NoError(t, tw.Close()) - require.NoError(t, gz.Close()) - return buffer.Bytes() + archive, err := os.ReadFile(filepath.Join("cmd", "dbc", "testdata", "test-driver-1.1.tar.gz")) + require.NoError(t, err) + return archive } -func newV2InstallServer(t *testing.T, archive []byte, metadata string) *httptest.Server { +func newArchiveMetadataInstallServer(t *testing.T, archive []byte, metadata string) *httptest.Server { t.Helper() digest := sha256.Sum256(archive) index := fmt.Sprintf(`drivers: - - name: V2 Driver - path: v2-driver + - name: Test Driver + path: test-driver-1 pkginfo: - - version: v1.2.3 + - version: v1.1.0 packages: - platform: %s url: package.tar.gz @@ -308,17 +282,16 @@ func TestClientInstallLegacyMetadataMismatchPreservesExistingInstallation(t *tes assert.Equal(t, oldLibraryBytes, libraryBytes) } -func TestClientInstallPackageV2UsesRegistryMetadata(t *testing.T) { - archive := makeClientPackageV2Archive(t, config.PlatformTuple()) - srv := newV2InstallServer(t, archive, "complete") +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, "v2-driver") + manifest, err := c.Install(t.Context(), cfg, "test-driver-1") require.NoError(t, err) require.NotNil(t, manifest) - assert.Equal(t, 2, manifest.PackageVersion) var receipt config.InstallReceipt receiptBytes, err := os.ReadFile(filepath.Join(filepath.Dir(manifest.DriverInfo.Driver.Shared.Get(config.PlatformTuple())), "dbc-install-receipt.json")) @@ -329,33 +302,19 @@ func TestClientInstallPackageV2UsesRegistryMetadata(t *testing.T) { assert.NotEmpty(t, receipt.ArchiveHash) assert.Positive(t, receipt.ArchiveSize) - runtimeManifest, err := os.ReadFile(filepath.Join(root, "v2-driver.toml")) + runtimeManifest, err := os.ReadFile(filepath.Join(root, "test-driver-1.toml")) require.NoError(t, err) assert.Contains(t, string(runtimeManifest), "manifest_version = 1") - assert.NotContains(t, string(runtimeManifest), "package_version") -} - -func TestClientInstallPackageV2WithoutRegistryMetadataIsRejected(t *testing.T) { - archive := makeClientPackageV2Archive(t, config.PlatformTuple()) - srv := newV2InstallServer(t, archive, "none") - c := newTestClientForServer(t, srv.URL) - root := t.TempDir() - cfg := config.Config{Level: config.ConfigEnv, Location: root} - - _, err := c.Install(t.Context(), cfg, "v2-driver") - require.Error(t, err) - assert.Contains(t, err.Error(), "registry package v2 requires archive hash metadata") - assert.NoFileExists(t, filepath.Join(root, "v2-driver.toml")) } func TestClientInstallAcceptsHashOnlyRegistryArchiveMetadata(t *testing.T) { - archive := makeClientPackageV2Archive(t, config.PlatformTuple()) - srv := newV2InstallServer(t, archive, "hash-only") + 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, "v2-driver") + manifest, err := c.Install(t.Context(), cfg, "test-driver-1") require.NoError(t, err) require.NotNil(t, manifest) var receipt config.InstallReceipt diff --git a/cmd/dbc/install_source.go b/cmd/dbc/install_source.go index 4be091af..11964687 100644 --- a/cmd/dbc/install_source.go +++ b/cmd/dbc/install_source.go @@ -86,22 +86,7 @@ func (m progressiveInstallModel) resolveDirectInstall(ctx context.Context) (inst if err != nil { return installItem{}, fmt.Errorf("resolve current directory for local package: %w", err) } - inspection, err := os.Open(m.localPackagePath) - if err != nil { - return installItem{}, err - } - metadata, inspectErr := config.InspectPackageMetadata(inspection) - closeErr := inspection.Close() - if inspectErr != nil { - return installItem{}, inspectErr - } - if closeErr != nil { - return installItem{}, closeErr - } driverID := legacyLocalPackageID(m.localPackagePath, platform) - if metadata.PackageVersion == 2 { - driverID = metadata.ID - } release, err = sourceresolution.ResolvePath(ctx, m.localPackagePath, sourceresolution.Request{ DriverID: driverID, Target: target, diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index 5f020faf..bb613b69 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -32,6 +32,8 @@ import ( "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" @@ -73,7 +75,7 @@ func packslipInstallRelease(t *testing.T, id, version, artifactURL string, archi Hash: "sha256:" + strings.Repeat("a", 64), }}, Artifacts: []resolution.Artifact{{ - Target: target, Format: "tgz", PackageVersion: 0, + Target: target, Format: "tgz", Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: artifactURL}, Hash: "sha256:" + hex.EncodeToString(digest[:]), Size: &size, }}, @@ -388,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") } @@ -426,7 +430,7 @@ func (suite *SubcommandTestSuite) TestInstallLocalPackageFixUpName() { } func (suite *SubcommandTestSuite) TestInstallPackslipDirectUsesSignedIDWithoutRegistryLookup() { - archive := packageV2ArchiveForInstall(suite.T(), "signed-driver-id", "1.2.3", config.PlatformTuple()) + 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() @@ -474,7 +478,7 @@ func (suite *SubcommandTestSuite) TestInstallPackslipDirectUsesSignedIDWithoutRe } func (suite *SubcommandTestSuite) TestInstallPackslipDirectInspectsLegacyManifestArchive() { - archive, err := os.ReadFile(filepath.Join("testdata", "test-driver-1.1.tar.gz")) + 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)} @@ -483,7 +487,7 @@ func (suite *SubcommandTestSuite) TestInstallPackslipDirectInspectsLegacyManifes 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, NoVerify: true}. + 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") @@ -499,17 +503,12 @@ func (suite *SubcommandTestSuite) TestInstallPackslipDirectFailsClosedBeforeRunt wantError string wantFetchCall int }{ - {name: "package ID mismatch", packageID: "different-id", wantError: "package id mismatch", wantFetchCall: 1}, {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() { - archiveID := tc.packageID - if tc.name == "package ID mismatch" { - archiveID = "archive-id" - } - archive := packageV2ArchiveForInstall(suite.T(), archiveID, "1.2.3", config.PlatformTuple()) + 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 { @@ -581,30 +580,31 @@ func (suite *SubcommandTestSuite) TestInstallPackslipRejectsInvalidInputsAndPreB suite.Zero(registryCalls) } -func (suite *SubcommandTestSuite) TestInstallLocalPackageV2UsesMetadataIDAndDoesNotReadProjectFiles() { - archive := packageV2ArchiveForInstall(suite.T(), "declared-driver-id", "1.2.3", config.PlatformTuple()) +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("misleading-name.tgz", archive, 0o600)) + 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: "misleading-name.tgz", Level: suite.configLevel, Json: true}.GetModelCustom(base)) + 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("declared-driver-id", status.Driver) - suite.Equal(&jsonschema.InstallSource{Type: "path", Reference: "misleading-name.tgz"}, status.Source) - suite.driverIsInstalled("declared-driver-id", true) + 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) @@ -624,7 +624,7 @@ func TestParsePackslipInstallArgumentRecognizesSupportedSyntax(t *testing.T) { func TestDirectLocalPackageRejectsReplacementAfterResolution(t *testing.T) { root := t.TempDir() path := filepath.Join(root, "driver.tgz") - first := packageV2ArchiveForInstall(t, "local-driver", "1.2.3", config.PlatformTuple()) + first := legacyArchiveForInstall(t, "1.2.3") if err := os.WriteFile(path, first, 0o600); err != nil { t.Fatal(err) } @@ -638,7 +638,7 @@ func TestDirectLocalPackageRejectsReplacementAfterResolution(t *testing.T) { if err != nil { t.Fatal(err) } - _, _ = makeSyncPackageV2Archive(t, path, "local-driver", "1.2.3", config.PlatformTuple()) + _, _ = makeSyncLegacyPackageArchive(t, path, "1.2.3") executor, err := model.newDirectInstallExecutor(item) if err != nil { t.Fatal(err) @@ -816,27 +816,27 @@ func (suite *SubcommandTestSuite) TestInstallDriverWithSubdirectories() { suite.Contains(out, "driver archives must be flat") } -func packageV2ArchiveForInstall(t *testing.T, id, version, platform string) []byte { +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(`package_version = 2 -id = %q + manifest := fmt.Sprintf(`manifest_version = 1 name = "Example Driver" version = %q -platform = %q [Driver] entrypoint = "AdbcDriverExampleInit" [Files] driver = "driver.so" -`, id, version, platform) +signature = "driver.so.sig" +`, version) + library, signature := signedLegacyPayload(t) for _, entry := range []struct { name string data []byte - }{{"dbc-package.toml", []byte(manifest)}, {"driver.so", []byte("driver library")}} { + }{{"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) } @@ -997,3 +997,33 @@ func (suite *SubcommandTestSuite) TestInstallJSON_AlreadyInstalledLibraryTamperi 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 + } + } + require.NotEmpty(t, library) + require.NotEmpty(t, signature) + return library, signature +} diff --git a/cmd/dbc/lockfile.go b/cmd/dbc/lockfile.go index bbfb2870..352d4ad7 100644 --- a/cmd/dbc/lockfile.go +++ b/cmd/dbc/lockfile.go @@ -72,7 +72,6 @@ type lockEvidence struct { type lockArtifact struct { Target resolution.Target `toml:"target"` Format string `toml:"format,omitempty"` - PackageVersion int `toml:"package_version,omitempty"` Location resolution.ArtifactLocation `toml:"location"` Hash string `toml:"hash"` Size *int64 `toml:"size"` diff --git a/cmd/dbc/lockfile_v2.go b/cmd/dbc/lockfile_v2.go index f87121e9..be9947d1 100644 --- a/cmd/dbc/lockfile_v2.go +++ b/cmd/dbc/lockfile_v2.go @@ -40,9 +40,6 @@ 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 artifact.PackageVersion != 0 && artifact.PackageVersion != 2 { - return fmt.Errorf("artifact %d has unsupported dbc package version %d", i, artifact.PackageVersion) - } if err := resolution.ValidateConcreteTarget(artifact.Target); err != nil { return fmt.Errorf("artifact %d has invalid target: %w", i, err) } @@ -183,7 +180,6 @@ func lockArtifactFromResolved(artifact resolution.Artifact) lockArtifact { result := lockArtifact{ Target: resolution.CanonicalTarget(artifact.Target), Format: artifact.Format, - PackageVersion: artifact.PackageVersion, Location: artifact.Location, Hash: artifact.Hash, Size: cloneInt64(artifact.Size), @@ -254,7 +250,6 @@ func (d lockInfo) resolvedRelease() resolution.ResolvedRelease { resolved := resolution.Artifact{ Target: artifact.Target, Format: artifact.Format, - PackageVersion: artifact.PackageVersion, Location: artifact.Location, Hash: artifact.Hash, Size: cloneInt64(artifact.Size), @@ -386,20 +381,16 @@ func refreshLockEntry(existing, refreshed lockInfo) (lockInfo, error) { for _, candidate := range refreshed.Artifacts { candidateIdentity := artifactSelectorIdentity(candidate) found := false - for index, prior := range merged.Artifacts { + 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) || - (prior.PackageVersion != 0 && candidate.PackageVersion != 0 && prior.PackageVersion != candidate.PackageVersion) || !reflect.DeepEqual(canonicalHostRequirements(prior.HostRequirements), canonicalHostRequirements(candidate.HostRequirements)) { return lockInfo{}, fmt.Errorf("metadata refresh contradicts locked artifact %q", candidateIdentity) } - if merged.Artifacts[index].PackageVersion == 0 { - merged.Artifacts[index].PackageVersion = candidate.PackageVersion - } break } if !found { diff --git a/cmd/dbc/lockfile_v2_test.go b/cmd/dbc/lockfile_v2_test.go index 4cbaaa89..58e85133 100644 --- a/cmd/dbc/lockfile_v2_test.go +++ b/cmd/dbc/lockfile_v2_test.go @@ -64,7 +64,7 @@ func testLockEntry() lockInfo { }}, Artifacts: []lockArtifact{ { - Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu", Variant: "v1"}, Format: "tar.gz", PackageVersion: 2, + 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"}, @@ -72,7 +72,7 @@ func testLockEntry() lockInfo { }, }, { - Target: resolution.Target{OS: "macos", Arch: "arm64"}, Format: "tar.gz", PackageVersion: 2, + 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), }, }, @@ -143,7 +143,6 @@ func TestLockFileV2DeterministicRoundTripPreservesArtifactMetadata(t *testing.T) 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]") - assert.Contains(t, string(first), "package_version = 2") var zeroSizePreserved bool for _, artifact := range loaded.lockinfo["local"].Artifacts { if artifact.Target == (resolution.Target{OS: "windows", Arch: "amd64"}) { @@ -209,36 +208,6 @@ func TestLockFileV2AllowsPartialArtifactSetAndReplayNeedsNoDiscovery(t *testing. assert.Contains(t, err.Error(), "locked mode") } -func TestPackageVersionZeroIsAllowedForAllLockSources(t *testing.T) { - sources := []lockSource{ - {Type: "packslip", Project: "github.com/example/driver"}, - {Type: "registry", URL: "https://registry.example.test"}, - {Type: "path", Path: "./drivers/example"}, - } - for _, source := range sources { - t.Run(source.Type, func(t *testing.T) { - entry := testLockEntry() - entry.Source = source - for i := range entry.Artifacts { - entry.Artifacts[i].PackageVersion = 0 - } - - require.NoError(t, validateLockInfo(entry)) - 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) - for _, artifact := range loaded.lockinfo[entry.Name].Artifacts { - assert.Zero(t, artifact.PackageVersion) - } - - entry.Artifacts[0].PackageVersion = 3 - err = validateLockInfo(entry) - require.ErrorContains(t, err, "unsupported dbc package version 3") - }) - } -} - func TestLockFileV2RejectsUnknownVersion(t *testing.T) { path := filepath.Join(t.TempDir(), "dbc.lock") require.NoError(t, os.WriteFile(path, []byte("version = 99\n"), 0o600)) @@ -319,7 +288,6 @@ func TestRefreshRejectsArtifactContradictionAndAllowsNewTarget(t *testing.T) { existing := testLockEntry() refreshed := existing newArtifact := testLockArtifact("windows_amd64", "c", 22) - newArtifact.PackageVersion = 2 contradictory := existing.Artifacts[0] contradictory.Hash = "sha256:" + strings.Repeat("f", 64) contradictory.Size = int64Pointer(999) @@ -334,52 +302,6 @@ func TestRefreshRejectsArtifactContradictionAndAllowsNewTarget(t *testing.T) { assert.Len(t, merged.Artifacts, 3) } -func TestRefreshKeepsPackslipPackageVersionUnspecified(t *testing.T) { - existing := testLockEntry() - for i := range existing.Artifacts { - existing.Artifacts[i].PackageVersion = 0 - } - - resolved := existing.resolvedRelease() - for i := range resolved.Artifacts { - resolved.Artifacts[i].PackageVersion = 0 - } - refreshed, err := lockInfoFromResolvedRelease(existing.Name, resolved) - require.NoError(t, err) - - merged, err := refreshLockEntry(existing, refreshed) - require.NoError(t, err) - for _, artifact := range merged.Artifacts { - assert.Zero(t, artifact.PackageVersion) - } -} - -func TestRefreshBackfillsMissingNonPackslipPackageVersion(t *testing.T) { - sources := []lockSource{ - {Type: "registry", URL: "https://registry.example.test"}, - {Type: "path", Path: "./drivers/example"}, - } - for _, source := range sources { - t.Run(source.Type, func(t *testing.T) { - existing := testLockEntry() - existing.Source = source - for i := range existing.Artifacts { - existing.Artifacts[i].PackageVersion = 0 - } - refreshed := cloneLockInfo(existing) - for i := range refreshed.Artifacts { - refreshed.Artifacts[i].PackageVersion = 2 - } - - merged, err := refreshLockEntry(existing, refreshed) - require.NoError(t, err) - for _, artifact := range merged.Artifacts { - require.Equal(t, 2, artifact.PackageVersion) - } - }) - } -} - func TestRefreshReplacesEvidenceWithLatestCanonicalSnapshot(t *testing.T) { oldIndex := lockEvidence{ Kind: resolution.EvidenceKindReleaseIndex, @@ -584,9 +506,6 @@ func TestSyncAdapterReusesCompleteV2SnapshotWithoutRegistryHashes(t *testing.T) 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.PackageVersion = 2 - assert.False(t, canReuseLockedEntry(item), "a different package version declaration is not the selected artifact proof") - selected.PackageVersion = entry.Artifacts[0].PackageVersion 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 @@ -1001,7 +920,7 @@ func testResolvedRelease() resolution.ResolvedRelease { Hash: "sha256:" + strings.Repeat("e", 64), }}, Artifacts: []resolution.Artifact{{ - Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu", Variant: "v1"}, Format: "tar.gz", PackageVersion: 2, + 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"}, diff --git a/cmd/dbc/package_executor.go b/cmd/dbc/package_executor.go index cea38b40..a1c0ba96 100644 --- a/cmd/dbc/package_executor.go +++ b/cmd/dbc/package_executor.go @@ -141,16 +141,15 @@ func expectedSyncPackageMetadata(item installItem) (config.ExpectedPackageMetada } 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") } - if selected.PackageVersion == 2 && !hasHash { - return config.ExpectedPackageMetadata{}, false, errors.New("package v2 requires archive hash metadata") - } expected := config.ExpectedPackageMetadata{ ID: item.Release.DriverID, Version: item.Release.Version, Platform: item.Platform, SourceType: item.Release.Source.Type, SourceIdentity: item.Release.Source.Reference, - PackageVersion: selected.PackageVersion, } if hasHash { expected.ArchiveHash = selected.Hash @@ -319,7 +318,10 @@ func (e *packageExecutor) downloadAndPrepareItem(ctx context.Context, item *inst } archive := item.Archive.File var verify func(string, config.Manifest) error - if !e.noVerify { + // 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. + if !e.noVerify && item.Release.Source.Type != "packslip" { verify = func(stagingDir string, manifest config.Manifest) error { return dbc.VerifyPackageSignature(stagingDir, manifest) } @@ -345,7 +347,6 @@ func (e *packageExecutor) downloadAndPrepareItem(ctx context.Context, item *inst archiveHashWasMissing := selected.Hash == "" if selected.Hash == "" { selected.Hash = validation.ArchiveHash - selected.PackageVersion = validation.PackageVersion } expected, _, err = expectedSyncPackageMetadata(*item) if err != nil { diff --git a/cmd/dbc/packslip_fetch_test.go b/cmd/dbc/packslip_fetch_test.go index 1eb1a6ae..e586f4bf 100644 --- a/cmd/dbc/packslip_fetch_test.go +++ b/cmd/dbc/packslip_fetch_test.go @@ -53,7 +53,7 @@ func TestPackslipArtifactFetchPreservesSignedURLWithoutCredentials(t *testing.T) DriverID: "example", Version: "1.2.3", Source: resolution.SourceSpec{Type: "packslip", Reference: "github.com/example/driver"}, Artifacts: []resolution.Artifact{{ - Target: target, Format: "tgz", PackageVersion: 0, + Target: target, Format: "tgz", Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: artifactURL.String()}, }}, }, 0, config.PlatformTuple(), nil) diff --git a/cmd/dbc/sync_execution.go b/cmd/dbc/sync_execution.go index 0ba309cc..096eb26a 100644 --- a/cmd/dbc/sync_execution.go +++ b/cmd/dbc/sync_execution.go @@ -78,7 +78,7 @@ func canReuseLockedEntry(item installItem) bool { } return locked.Target == selected.Target && locked.Location == selected.Location && locked.Hash == selected.Hash && sameLockSize(locked.Size, selected.Size) && - locked.Format == selected.Format && locked.PackageVersion == selected.PackageVersion && + locked.Format == selected.Format && reflect.DeepEqual(canonicalHostRequirements(locked.HostRequirements), canonicalHostRequirements(lockHostRequirementsFromResolution(selected.HostRequirements))) } diff --git a/cmd/dbc/sync_execution_test.go b/cmd/dbc/sync_execution_test.go index 09083e42..a7ab37f0 100644 --- a/cmd/dbc/sync_execution_test.go +++ b/cmd/dbc/sync_execution_test.go @@ -73,21 +73,20 @@ func TestPackageExecutorKeepsSourceArtifactMetadataInLock(t *testing.T) { name string sourceType string sourceReference string - packageVersion int }{ - {name: "registry metadata without package version", sourceType: "registry", sourceReference: "https://registry.example.test", packageVersion: 0}, - {name: "Packslip unspecified format", sourceType: "packslip", sourceReference: "github.com/example/driver", packageVersion: 0}, + {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 := makeSyncPackageV2Archive(t, archivePath, "test-driver-1", "1.2.3", config.PlatformTuple()) + 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", PackageVersion: test.packageVersion, + Target: target, Format: "tar.gz", Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://assets.example.test/driver.tar.gz"}, Hash: archiveHash, Size: &size, }}, @@ -96,7 +95,7 @@ func TestPackageExecutorKeepsSourceArtifactMetadataInLock(t *testing.T) { 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(), true, + 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)) @@ -108,7 +107,13 @@ func TestPackageExecutorKeepsSourceArtifactMetadataInLock(t *testing.T) { 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) - assert.Equal(t, before.Artifacts[0].PackageVersion, after.Artifacts[0].PackageVersion) + 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") + } }) } } @@ -378,7 +383,7 @@ shared = %q return output.Bytes() } -func (suite *SubcommandTestSuite) TestSyncPartialRegistryDownloadsEachArchiveOnceAndRejectsV2WithoutMetadata() { +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{} @@ -405,23 +410,6 @@ func (suite *SubcommandTestSuite) TestSyncPartialRegistryDownloadsEachArchiveOnc suite.True(managed && present && valid) suite.Positive(receipt.ArchiveSize, "the receipt still retains the measured archive size") - // The registry fixture omits archive metadata. A v2 archive must therefore - // be rejected after its one download rather than treating measured values as - // source-provided metadata. - v2Path := filepath.Join(suite.tempdir, "v2-without-registry-metadata.tar.gz") - makeSyncPackageV2Archive(suite.T(), v2Path, "test-driver-1", "1.1.0", config.PlatformTuple()) - v2List := filepath.Join(suite.tempdir, "v2.toml") - suite.Require().NoError(os.WriteFile(v2List, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0644)) - v2Downloads := 0 - v2Model := SyncCmd{Path: v2List, Level: suite.configLevel, NoVerify: true}.GetModelCustom(baseModel{ - getDriverRegistry: getTestDriverRegistry, - downloadPkg: func(dbc.PkgInfo) (*os.File, error) { - v2Downloads++ - return os.Open(v2Path) - }, - }) - suite.Contains(suite.runCmdErr(v2Model), "package v2 requires archive hash metadata") - suite.Equal(1, v2Downloads) } func (suite *SubcommandTestSuite) TestSyncExactLockedArtifactConvergesWithoutRegistryDiscovery() { @@ -569,7 +557,7 @@ func (suite *SubcommandTestSuite) TestSyncPathReplayUsesReceiptBeforeOpeningChan archivePath := filepath.Join(root, "packages", "driver.tar.gz") declaredPath := "./packages/driver.tar.gz" suite.Require().NoError(os.MkdirAll(filepath.Dir(archivePath), 0o700)) - makeSyncPackageV2Archive(suite.T(), archivePath, "test-driver-1", "1.2.3", config.PlatformTuple()) + 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 diff --git a/cmd/dbc/sync_planning_test.go b/cmd/dbc/sync_planning_test.go index 940428da..c319e920 100644 --- a/cmd/dbc/sync_planning_test.go +++ b/cmd/dbc/sync_planning_test.go @@ -91,7 +91,7 @@ func TestFreshPackslipResolutionSnapshotsAndReplaysWithoutDiscovery(t *testing.T const version = "1.2.3+build.5" dir := t.TempDir() archivePath := filepath.Join(dir, "driver.tgz") - archiveBytes, archiveHash := makeSyncPackageV2Archive(t, archivePath, driverID, version, config.PlatformTuple()) + archiveBytes, archiveHash := makeSyncLegacyPackageArchive(t, archivePath, version) archiveSize := int64(len(archiveBytes)) location := "https://assets.example.test/driver.tgz" release := makeSyncPackslipRelease(driverID, version, location, archiveHash, archiveSize) @@ -109,7 +109,7 @@ func TestFreshPackslipResolutionSnapshotsAndReplaysWithoutDiscovery(t *testing.T return os.Open(archivePath) }, }, - Path: projectPath, LockFilePath: filepath.Join(dir, "dbc.lock"), NoVerify: true, + Path: projectPath, LockFilePath: filepath.Join(dir, "dbc.lock"), cfg: config.Config{Level: config.ConfigEnv, Location: filepath.Join(dir, "install")}, } @@ -140,7 +140,6 @@ func TestFreshPackslipResolutionSnapshotsAndReplaysWithoutDiscovery(t *testing.T 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.Zero(t, primaryArtifact.PackageVersion) 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") @@ -165,7 +164,7 @@ func TestFreshPackslipResolutionSnapshotsAndReplaysWithoutDiscovery(t *testing.T return os.Open(archivePath) }, }, - Path: projectPath, LockFilePath: model.LockFilePath, NoVerify: true, + Path: projectPath, LockFilePath: model.LockFilePath, cfg: config.Config{Level: config.ConfigEnv, Location: filepath.Join(dir, "replay-install")}, } replayPlan, replayNeedsRegistry, err := replay.planSyncItems(list) @@ -239,14 +238,13 @@ func TestPathResolutionDerivesVersionAndUsesProjectRelativeArchive(t *testing.T) assert.Equal(t, declaredPath, locked.Source.Path) require.Len(t, locked.Artifacts, 1) assert.Equal(t, declaredPath, locked.Artifacts[0].Location.Value) - assert.Equal(t, 0, locked.Artifacts[0].PackageVersion, "legacy MANIFEST path packages remain supported") assert.NotEmpty(t, locked.Artifacts[0].Hash) } -func TestPathPackageV2MarkerSurvivesResolutionAndLockSnapshot(t *testing.T) { +func TestPathLegacyPackageResolutionAndLockSnapshot(t *testing.T) { dir := t.TempDir() archivePath := filepath.Join(dir, "driver.tar.gz") - makeSyncPackageV2Archive(t, archivePath, "test-driver-1", "1.2.3", config.PlatformTuple()) + 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")}} @@ -258,16 +256,14 @@ func TestPathPackageV2MarkerSurvivesResolutionAndLockSnapshot(t *testing.T) { items, err := model.createInstallListContext(context.Background(), planned) require.NoError(t, err) require.Len(t, items, 1) - assert.Equal(t, 2, items[0].Release.Artifacts[0].PackageVersion) 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) - assert.Equal(t, 2, prepared.lock.Drivers[0].Artifacts[0].PackageVersion) expected, _, err := expectedSyncPackageMetadata(prepared.items[0]) require.NoError(t, err) - assert.Equal(t, 2, expected.PackageVersion) + assert.Equal(t, items[0].Release.Artifacts[0].Hash, expected.ArchiveHash) } func TestPathArchiveMutationAfterResolutionFailsBeforeCandidateLock(t *testing.T) { @@ -295,7 +291,7 @@ func TestPathMetadataDerivedRefreshAdoptsArchiveVersionWithoutOldSnapshotProof(t const declaredPath = "./packages/driver.tar.gz" archivePath := filepath.Join(dir, "packages", "driver.tar.gz") require.NoError(t, os.MkdirAll(filepath.Dir(archivePath), 0o700)) - _, newHash := makeSyncPackageV2Archive(t, archivePath, "test-driver-1", "1.2.3", config.PlatformTuple()) + _, 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) @@ -342,7 +338,7 @@ func TestPathExactRefreshStillRejectsChangedArchiveVersion(t *testing.T) { const declaredPath = "./packages/driver.tar.gz" archivePath := filepath.Join(dir, "packages", "driver.tar.gz") require.NoError(t, os.MkdirAll(filepath.Dir(archivePath), 0o700)) - makeSyncPackageV2Archive(t, archivePath, "test-driver-1", "1.2.3", config.PlatformTuple()) + 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}})) @@ -376,7 +372,7 @@ func partialPathLockForSync(t *testing.T, declaredPath, version, hash string, si Hash: "sha256:" + strings.Repeat("d", 64), }}, Artifacts: []resolution.Artifact{{ - Target: resolution.Target{OS: "plan9", Arch: "amd64"}, Format: "tar.gz", PackageVersion: 2, + Target: resolution.Target{OS: "plan9", Arch: "amd64"}, Format: "tar.gz", Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationPath, Value: declaredPath}, Hash: hash, Size: &size, }}, @@ -392,7 +388,6 @@ func TestSourceVersionOrIdentityMismatchDiscardsOldLockProof(t *testing.T) { entry.DriverID = "test-driver-1" entry.Source = resolution.SourceSpec{Type: "packslip", Reference: "github.com/example/old"} entry.Version = "1.2.3+foo" - entry.Artifacts[0].PackageVersion = 2 oldLock, err := lockInfoFromResolvedRelease(entry.DriverID, entry) require.NoError(t, err) oldLock.Legacy = &legacyLibraryProof{Platform: config.PlatformTuple(), LibraryHash: strings.Repeat("c", 64)} @@ -746,7 +741,7 @@ func TestRegistrySyncUsesOnlyExactReleaseMetadataForAllTargetLock(t *testing.T) const driverID, version = "test-driver-1", "1.2.3" dir := t.TempDir() archivePath := filepath.Join(dir, "host.tar.gz") - _, hostHash := makeSyncPackageV2Archive(t, archivePath, driverID, version, config.PlatformTuple()) + _, hostHash := makeSyncLegacyPackageArchive(t, archivePath, version) platforms := []string{"windows_amd64", "linux_amd64", "darwin_arm64"} var otherPlatform, missingHashPlatform string for _, platform := range platforms { diff --git a/cmd/dbc/sync_test_helpers_test.go b/cmd/dbc/sync_test_helpers_test.go index 3c20d5df..5f8b6d5b 100644 --- a/cmd/dbc/sync_test_helpers_test.go +++ b/cmd/dbc/sync_test_helpers_test.go @@ -51,27 +51,25 @@ func (stub *syncPackslipResolverStub) Resolve(_ context.Context, source packslip return cloneResolvedReleaseForSync(stub.release), nil } -func makeSyncPackageV2Archive(t *testing.T, path, id, version, platform string) ([]byte, string) { +func makeSyncLegacyPackageArchive(t *testing.T, path, version string) ([]byte, string) { t.Helper() - metadata := []byte(fmt.Sprintf(`package_version = 2 -id = %q + metadata := []byte(fmt.Sprintf(`manifest_version = 1 name = "Test Driver" version = %q -platform = %q [Driver] entrypoint = "TestDriverInit" [Files] driver = "driver.so" -`, id, version, platform)) +`, version)) var archive bytes.Buffer gzipWriter := gzip.NewWriter(&archive) tarWriter := tar.NewWriter(gzipWriter) for _, entry := range []struct { name string data []byte - }{{name: "dbc-package.toml", data: metadata}, {name: "driver.so", data: []byte("test library bytes")}} { + }{{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) @@ -99,8 +97,8 @@ func makeSyncPackslipRelease(id, version, url, hash string, size int64) resoluti Hash: "sha256:" + strings.Repeat("a", 64), }}, Artifacts: []resolution.Artifact{ - {Target: primary, Format: "tgz", PackageVersion: 0, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: url}, Hash: hash, Size: &size}, - {Target: secondary, Format: "tar.gz", PackageVersion: 0, Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://assets.example.test/macos.tar.gz"}, Hash: "sha256:" + strings.Repeat("b", 64), Size: &otherSize}, + {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}, }, } } diff --git a/config/config.go b/config/config.go index c68293ce..e35824c6 100644 --- a/config/config.go +++ b/config/config.go @@ -260,7 +260,7 @@ func InflateTarball(f *os.File, outDir string) (Manifest, error) { 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) + manifest, files, err := extractPackageArchive(archivePath, payloadDir) if err != nil { return Manifest{}, fmt.Errorf("could not extract tarball: %w", err) } @@ -280,10 +280,6 @@ func decodeManifest(r io.Reader, driverName string, requireShared bool) (Manifes if err := toml.NewDecoder(r).Decode(&di); err != nil { return Manifest{}, fmt.Errorf("error decoding manifest: %w", err) } - if di.PackageVersion != nil { - return Manifest{}, fmt.Errorf("%w: package manifest cannot be loaded as an ADBC runtime manifest", ErrInvalidManifest) - } - 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) diff --git a/config/driver.go b/config/driver.go index ee5d3946..d23ad1ca 100644 --- a/config/driver.go +++ b/config/driver.go @@ -34,13 +34,6 @@ const currentManifestVersion = 1 type Manifest struct { DriverInfo - // PackageVersion is the parsed package wire version. It is in-memory - // metadata only; runtime ADBC manifests continue to use ManifestVersion. - PackageVersion int - // PackagePlatform is set when package metadata declares a concrete platform. - // Legacy package MANIFEST files do not carry this field. - PackagePlatform string - Files struct { Driver string `toml:"driver,omitempty"` Signature string `toml:"signature,omitempty"` @@ -125,10 +118,8 @@ func (d driverMap) String() string { } // runtimeManifestWire is the installed ADBC Driver Manifest format. A legacy -// package's MANIFEST uses legacyPackageManifestWire, while dbc-package.toml -// uses packageManifestV2Wire. +// package's MANIFEST uses legacyPackageManifestWire. type runtimeManifestWire struct { - PackageVersion *int64 `toml:"package_version,omitempty"` ManifestVersion int32 `toml:"manifest_version"` Name string `toml:"name"` Publisher string `toml:"publisher"` diff --git a/config/install_transaction_helpers_test.go b/config/install_transaction_helpers_test.go index 2a576526..fba67574 100644 --- a/config/install_transaction_helpers_test.go +++ b/config/install_transaction_helpers_test.go @@ -23,6 +23,7 @@ import ( "fmt" "io" "os" + "strings" "testing" ) @@ -33,20 +34,17 @@ type installArchiveEntry struct { func makeInstallArchive(t *testing.T, id, version, library string, libraryData []byte) []byte { t.Helper() - manifest := []byte(fmt.Sprintf(`package_version = 2 -id = %q -name = "Example Driver" + manifest := []byte(fmt.Sprintf(`name = %q version = %q -platform = %q [Driver] entrypoint = "AdbcDriverExampleInit" [Files] driver = %q -`, id, version, PlatformTuple(), library)) +`, strings.ToUpper(id[:1])+id[1:]+" Driver", version, library)) return makeInstallArchiveWithEntries(t, - installArchiveEntry{name: "dbc-package.toml", data: manifest}, + installArchiveEntry{name: "MANIFEST", data: manifest}, installArchiveEntry{name: library, data: libraryData}, ) } diff --git a/config/package_archive.go b/config/package_archive.go index 2aab38fa..9cd3cf11 100644 --- a/config/package_archive.go +++ b/config/package_archive.go @@ -29,8 +29,7 @@ import ( ) // InspectPackageMetadata decodes the archive's package metadata without -// installing files. It is intended to classify legacy and versioned package -// formats before choosing an install policy; the installer still validates +// 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 { @@ -110,7 +109,7 @@ func InspectPackageMetadata(downloaded *os.File) (Manifest, error) { if err != nil { return Manifest{}, err } - return manifest.manifest, nil + return manifest, nil } func installPackageArchive(cfg Config, targetName, runtimeID string, downloaded *os.File, expected ExpectedPackageMetadata) (Manifest, error) { @@ -174,30 +173,18 @@ func stagePackageArchive(location, runtimeID, finalDir string, downloaded *os.Fi if err := os.Mkdir(payloadDir, 0o700); err != nil { return result, "", sharedIdentity, fmt.Errorf("could not create private package staging directory: %w", err) } - manifest, meta, files, err := extractPackageArchive(archivePath, payloadDir) + manifest, files, err := extractPackageArchive(archivePath, payloadDir) if err != nil { return result, "", sharedIdentity, fmt.Errorf("failed to extract package archive: %w", err) } - if expected.ID != "" && meta.v2 && expected.ID != meta.id { - return result, "", sharedIdentity, fmt.Errorf("package id mismatch: archive declares %q, expected %q", meta.id, expected.ID) - } 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) } - if expected.PackageVersion != 0 && expected.PackageVersion != manifest.PackageVersion { - return result, "", sharedIdentity, fmt.Errorf("dbc package version mismatch: archive declares %d, expected %d", manifest.PackageVersion, expected.PackageVersion) - } platform := expected.Platform - if meta.v2 { - if platform != "" && meta.platform != platform { - return result, "", sharedIdentity, fmt.Errorf("package platform mismatch: archive declares %q, expected %q", meta.platform, platform) - } - platform = meta.platform - } if platform == "" { platform = PlatformTuple() } - if err := validatePackageFileReferences(manifest, files, meta.v2); err != nil { + if err := validatePackageFileReferences(manifest, files); err != nil { return result, "", sharedIdentity, err } if _, exists := files[strings.ToLower(installReceiptName)]; exists { @@ -251,8 +238,7 @@ func stagePackageArchive(location, runtimeID, finalDir string, downloaded *os.Fi receipt := InstallReceipt{ SourceType: expected.SourceType, SourceIdentity: expected.SourceIdentity, DriverID: runtimeID, DriverVersion: manifest.Version.String(), Platform: platform, - PackageVersion: manifest.PackageVersion, - ArchiveHash: archiveHash, ArchiveSize: archiveSize, InstalledLibrary: installedLibrary, + ArchiveHash: archiveHash, ArchiveSize: archiveSize, InstalledLibrary: installedLibrary, InstalledLibraryHash: installedHash, RegistrationFingerprintAlgorithm: registrationFingerprintAlgorithm, RegistrationFingerprintVersion: registrationFingerprintVersion, @@ -300,16 +286,16 @@ func snapshotArchive(source *os.File, target string) (string, int64, error) { return "sha256:" + hex.EncodeToString(h.Sum(nil)), n, nil } -func extractPackageArchive(archivePath, payloadDir string) (Manifest, packageManifest, map[string]string, error) { +func extractPackageArchive(archivePath, payloadDir string) (Manifest, map[string]string, error) { var empty Manifest archive, err := os.Open(archivePath) if err != nil { - return empty, packageManifest{}, nil, err + return empty, nil, err } defer archive.Close() gz, err := gzip.NewReader(archive) if err != nil { - return empty, packageManifest{}, nil, fmt.Errorf("could not create gzip reader: %w", err) + return empty, nil, fmt.Errorf("could not create gzip reader: %w", err) } defer gz.Close() reader := tar.NewReader(gz) @@ -322,44 +308,44 @@ func extractPackageArchive(archivePath, payloadDir string) (Manifest, packageMan break } if err != nil { - return empty, packageManifest{}, nil, fmt.Errorf("error reading tar archive: %w", err) + return empty, nil, fmt.Errorf("error reading tar archive: %w", err) } if header.Typeflag == tar.TypeDir { - return empty, packageManifest{}, nil, fmt.Errorf("found a directory entry %q; driver archives must be flat", header.Name) + 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, packageManifest{}, nil, fmt.Errorf("archive entry %q is not a regular file", header.Name) + 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, packageManifest{}, nil, fmt.Errorf("archive entry %q uses an unsupported sparse-file extension", header.Name) + 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, packageManifest{}, nil, fmt.Errorf("invalid archive entry %q: %w", header.Name, err) + return empty, nil, fmt.Errorf("invalid archive entry %q: %w", header.Name, err) } if header.Size < 0 { - return empty, packageManifest{}, nil, fmt.Errorf("archive entry %q has a negative size", header.Name) + 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, packageManifest{}, nil, fmt.Errorf("archive entries %q and %q collide by name", previous, header.Name) + 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, packageManifest{}, nil, err + return empty, nil, err } if isMetadata { if header.Size > maxPackageMetadataSize { - return empty, packageManifest{}, nil, errors.New("package metadata exceeds 1 MiB") + return empty, nil, errors.New("package metadata exceeds 1 MiB") } data, err := io.ReadAll(io.LimitReader(reader, maxPackageMetadataSize+1)) if err != nil { - return empty, packageManifest{}, nil, fmt.Errorf("could not read package metadata: %w", err) + return empty, nil, fmt.Errorf("could not read package metadata: %w", err) } if int64(len(data)) != header.Size { - return empty, packageManifest{}, nil, errors.New("package metadata size does not match its tar header") + return empty, nil, errors.New("package metadata size does not match its tar header") } metadata[metadataName] = data continue @@ -371,7 +357,7 @@ func extractPackageArchive(archivePath, payloadDir string) (Manifest, packageMan } file, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) if err != nil { - return empty, packageManifest{}, nil, fmt.Errorf("could not create staged package file %q: %w", header.Name, err) + 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 { @@ -382,38 +368,33 @@ func extractPackageArchive(archivePath, payloadDir string) (Manifest, packageMan } closeErr := file.Close() if writeErr != nil { - return empty, packageManifest{}, nil, fmt.Errorf("could not write staged package file %q: %w", header.Name, writeErr) + return empty, nil, fmt.Errorf("could not write staged package file %q: %w", header.Name, writeErr) } if closeErr != nil { - return empty, packageManifest{}, nil, fmt.Errorf("could not close staged package file %q: %w", header.Name, closeErr) + 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, packageManifest{}, nil, err + return empty, nil, err } - parsed, err := decodePackageManifest(metadataName, data) + manifest, err := decodePackageManifest(metadataName, data) if err != nil { - return empty, packageManifest{}, nil, err + return empty, nil, err } if _, err := io.Copy(io.Discard, gz); err != nil { - return empty, packageManifest{}, nil, fmt.Errorf("could not verify gzip stream: %w", err) + return empty, nil, fmt.Errorf("could not verify gzip stream: %w", err) } - if err := validatePackageFileReferences(parsed.manifest, files, parsed.v2); err != nil { - return empty, packageManifest{}, nil, err + if err := validatePackageFileReferences(manifest, files); err != nil { + return empty, nil, err } - return parsed.manifest, parsed, files, nil + return manifest, files, nil } -func validatePackageFileReferences(manifest Manifest, files map[string]string, requireDriver bool) error { +func validatePackageFileReferences(manifest Manifest, files map[string]string) error { driver := manifest.Files.Driver - if driver == "" { - if requireDriver { - // TODO: Model externally managed drivers as an explicit package variant with ownership semantics, not an empty Files.driver exception. - return fmt.Errorf("%w: Files.driver is required", ErrInvalidManifest) - } - } else { + if driver != "" { if err := validateFlatName(driver); err != nil { return fmt.Errorf("%w: invalid Files.driver: %v", ErrInvalidManifest, err) } diff --git a/config/package_archive_test.go b/config/package_archive_test.go index 1b432a87..34fcf96e 100644 --- a/config/package_archive_test.go +++ b/config/package_archive_test.go @@ -41,19 +41,16 @@ type archiveEntry struct { linkname string } -func packageV2Manifest(id, version, platform, driverFile string) []byte { - return []byte(fmt.Sprintf(`package_version = 2 -id = %q -name = "Example Driver" +func legacyPackageManifest(version, driverFile string) []byte { + return []byte(fmt.Sprintf(`name = "Example Driver" version = %q -platform = %q [Driver] entrypoint = "AdbcDriverExampleInit" [Files] driver = %q -`, id, version, platform, driverFile)) +`, version, driverFile)) } func makePackageArchive(t *testing.T, entries ...archiveEntry) []byte { @@ -100,37 +97,20 @@ func openPackageArchive(t *testing.T, data []byte) *os.File { return file } -func differentPackagePlatform() string { - switch config.PlatformTuple() { - case "linux_amd64": - return "linux_arm64" - case "linux_arm64": - return "linux_amd64" - case "macos_amd64": - return "macos_arm64" - case "macos_arm64": - return "macos_amd64" - case "windows_amd64": - return "linux_amd64" - default: - return "linux_amd64" - } -} - func expectedPackage(id, version, platform, source string, archive []byte) config.ExpectedPackageMetadata { digest := sha256.Sum256(archive) return config.ExpectedPackageMetadata{ - ID: id, Version: version, PackageVersion: 2, Platform: platform, + ID: id, Version: version, Platform: platform, SourceType: "packslip", SourceIdentity: source, ArchiveHash: "sha256:" + hex.EncodeToString(digest[:]), ArchiveSize: int64(len(archive)), } } -func validV2Archive(t *testing.T, contents []byte) []byte { +func validLegacyArchive(t *testing.T, contents []byte) []byte { t.Helper() - manifest := packageV2Manifest("example", "1.2.3", config.PlatformTuple(), "libexample.so") + manifest := legacyPackageManifest("1.2.3", "libexample.so") return makePackageArchive(t, - archiveEntry{name: "dbc-package.toml", data: manifest}, + archiveEntry{name: "MANIFEST", data: manifest}, archiveEntry{name: "libexample.so", data: contents}, archiveEntry{name: "LICENSE", data: []byte("license")}, ) @@ -148,57 +128,14 @@ func TestPackageArchiveMetadataVersions(t *testing.T) { assert.FileExists(t, filepath.Join(outDir, manifest.Files.Driver)) }) - t.Run("package v2 is decoded separately from runtime v1", func(t *testing.T) { - data := makePackageArchive(t, - archiveEntry{name: "dbc-package.toml", data: packageV2Manifest("example", "1.2.3", "linux_amd64", "libexample.so")}, - archiveEntry{name: "libexample.so", data: []byte("library")}, - ) - f := openPackageArchive(t, data) - manifest, err := config.InflateTarball(f, t.TempDir()) - require.NoError(t, err) - assert.Equal(t, "example", manifest.ID) - assert.Equal(t, 2, manifest.PackageVersion) - assert.Equal(t, "AdbcDriverExampleInit", manifest.Driver.Entrypoint) - assert.Equal(t, "libexample.so", manifest.Files.Driver) - assert.Empty(t, manifest.Driver.Shared.Get("linux_amd64")) - - inspected, err := config.InspectPackageMetadata(openPackageArchive(t, data)) - require.NoError(t, err) - assert.Equal(t, 2, inspected.PackageVersion) - }) - - t.Run("legacy package remains distinguishable from v2", func(t *testing.T) { + 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.Zero(t, manifest.PackageVersion) - }) - - t.Run("unknown discriminator does not fall back to legacy", func(t *testing.T) { - manifest := []byte("package_version = 3\nname = 'Legacy-looking name'\nversion = '1.0.0'\n\n[Files]\ndriver = 'driver.so'\n") - data := makePackageArchive(t, - archiveEntry{name: "dbc-package.toml", data: manifest}, - archiveEntry{name: "driver.so", data: []byte("library")}, - ) - f := openPackageArchive(t, data) - _, err := config.InflateTarball(f, t.TempDir()) - require.Error(t, err) - assert.ErrorIs(t, err, config.ErrInvalidManifest) - }) - - t.Run("malformed discriminator does not fall back to legacy", func(t *testing.T) { - manifest := []byte("package_version = '2'\nname = 'Legacy-looking name'\nversion = '1.0.0'\n\n[Files]\ndriver = 'driver.so'\n") - data := makePackageArchive(t, - archiveEntry{name: "dbc-package.toml", data: manifest}, - archiveEntry{name: "driver.so", data: []byte("library")}, - ) - f := openPackageArchive(t, data) - _, err := config.InflateTarball(f, t.TempDir()) - require.Error(t, err) - assert.ErrorIs(t, err, config.ErrInvalidManifest) + assert.Equal(t, "Test Driver 1", manifest.Name) }) t.Run("legacy manifest-only package keeps its runtime shared value", func(t *testing.T) { @@ -254,59 +191,7 @@ entrypoint = "AdbcDriverLegacyTableInit" }) } -func TestPackageV2RequiresStrictSemVer(t *testing.T) { - for _, version := range []string{ - "1.2.3", - "1.2.3-rc.1", - "1.2.3+build.5", - "1.2.3-rc.1+build.5", - } { - t.Run("accepts "+version, func(t *testing.T) { - archive := makePackageArchive(t, - archiveEntry{name: "dbc-package.toml", data: packageV2Manifest("example", version, config.PlatformTuple(), "driver.so")}, - archiveEntry{name: "driver.so", data: []byte("library")}, - ) - for name, inspect := range map[string]func(*os.File) (config.Manifest, error){ - "inspect": config.InspectPackageMetadata, - "extract": func(file *os.File) (config.Manifest, error) { - return config.InflateTarball(file, t.TempDir()) - }, - } { - t.Run(name, func(t *testing.T) { - manifest, err := inspect(openPackageArchive(t, archive)) - require.NoError(t, err) - require.NotNil(t, manifest.Version) - assert.Equal(t, version, manifest.Version.String()) - }) - } - }) - } - - for _, version := range []string{"v1.2.3", "1.2", "01.2.3"} { - t.Run("rejects "+version, func(t *testing.T) { - archive := makePackageArchive(t, - archiveEntry{name: "dbc-package.toml", data: packageV2Manifest("example", version, config.PlatformTuple(), "driver.so")}, - archiveEntry{name: "driver.so", data: []byte("library")}, - ) - for name, inspect := range map[string]func(*os.File) error{ - "inspect": func(file *os.File) error { - _, err := config.InspectPackageMetadata(file) - return err - }, - "extract": func(file *os.File) error { - _, err := config.InflateTarball(file, t.TempDir()) - return err - }, - } { - t.Run(name, func(t *testing.T) { - err := inspect(openPackageArchive(t, archive)) - require.ErrorIs(t, err, config.ErrInvalidManifest) - assert.Contains(t, err.Error(), "must be valid SemVer 2.0.0") - }) - } - }) - } - +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" @@ -323,8 +208,6 @@ shared = "legacy_driver" func TestPackageArchiveMetadataFilenames(t *testing.T) { legacy := []byte("name = 'Legacy Driver'\nversion = '1.0.0'\n") - v2 := packageV2Manifest("example", "1.2.3", config.PlatformTuple(), "driver.so") - v2WithRuntimeVersion := bytes.Replace(v2, []byte("id = "), []byte("manifest_version = 1\nid = "), 1) tests := []struct { name string entries []archiveEntry @@ -333,31 +216,29 @@ func TestPackageArchiveMetadataFilenames(t *testing.T) { { name: "missing metadata", entries: []archiveEntry{{name: "driver.so", data: []byte("library")}}, - wantMessage: "must contain exactly one of MANIFEST or dbc-package.toml", + wantMessage: "must contain MANIFEST metadata", }, { name: "both formats", entries: []archiveEntry{ {name: "MANIFEST", data: legacy}, - {name: "dbc-package.toml", data: v2}, + {name: "dbc-package.toml", data: []byte("not valid TOML =")}, {name: "driver.so", data: []byte("library")}, }, - wantMessage: "not both", + wantMessage: "must not contain both", }, { - name: "legacy filename requires legacy format", - entries: []archiveEntry{{name: "MANIFEST", data: v2}, {name: "driver.so", data: []byte("library")}}, - wantMessage: "package_version is not allowed in legacy MANIFEST", - }, - { - name: "v2 filename requires package version", - entries: []archiveEntry{{name: "dbc-package.toml", data: []byte("name = 'Legacy-looking name'\nversion = '1.0.0'\n")}, {name: "driver.so", data: []byte("library")}}, - wantMessage: "package_version = 2 is required", + 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: "v2 metadata rejects runtime manifest version", - entries: []archiveEntry{{name: "dbc-package.toml", data: v2WithRuntimeVersion}, {name: "driver.so", data: []byte("library")}}, - wantMessage: "must not set runtime manifest_version", + 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", @@ -365,8 +246,8 @@ func TestPackageArchiveMetadataFilenames(t *testing.T) { wantMessage: `must be named exactly "MANIFEST"`, }, { - name: "v2 filename case mismatch", - entries: []archiveEntry{{name: "dbc-Package.toml", data: v2}}, + name: "reserved filename case mismatch", + entries: []archiveEntry{{name: "dbc-Package.toml", data: []byte("reserved")}}, wantMessage: `must be named exactly "dbc-package.toml"`, }, { @@ -377,40 +258,30 @@ func TestPackageArchiveMetadataFilenames(t *testing.T) { }, wantMessage: "collide by name", }, - { - name: "v2 case collision", - entries: []archiveEntry{ - {name: "dbc-package.toml", data: v2}, - {name: "DBC-PACKAGE.TOML", data: v2}, - }, - wantMessage: "collide by name", - }, - { - name: "duplicate v2 metadata", - entries: []archiveEntry{ - {name: "dbc-package.toml", data: v2}, - {name: "dbc-package.toml", data: v2}, - }, - 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) error{ - "inspect": func(file *os.File) error { + 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) error { - _, err := config.InflateTarball(file, t.TempDir()) + "extract": func(file *os.File, outDir string) error { + _, err := config.InflateTarball(file, outDir) return err }, } { t.Run(name, func(t *testing.T) { - err := inspect(openPackageArchive(t, archive)) + 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) + } }) } }) @@ -419,7 +290,7 @@ func TestPackageArchiveMetadataFilenames(t *testing.T) { func TestInspectPackageMetadataScansPastMetadata(t *testing.T) { archive := makePackageArchive(t, - archiveEntry{name: "dbc-package.toml", data: packageV2Manifest("example", "1.2.3", config.PlatformTuple(), "driver.so")}, + archiveEntry{name: "MANIFEST", data: legacyPackageManifest("1.2.3", "driver.so")}, archiveEntry{name: "driver.so", data: []byte("library")}, archiveEntry{name: "nested/file", data: []byte("unsafe")}, ) @@ -428,26 +299,24 @@ func TestInspectPackageMetadataScansPastMetadata(t *testing.T) { assert.Contains(t, err.Error(), "path separators") } -func TestPackageMetadataSizeLimitAppliesToBothFormats(t *testing.T) { +func TestPackageMetadataSizeLimitAppliesToMetadataFilenames(t *testing.T) { oversized := bytes.Repeat([]byte("x"), (1<<20)+1) for _, name := range []string{"MANIFEST", "dbc-package.toml"} { - t.Run(name, func(t *testing.T) { - 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") - } - }) + 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") + } } } @@ -491,14 +360,14 @@ func TestInflateTarballRejectsArchiveAttacks(t *testing.T) { } func TestInstallPackageChecksMetadataAndDigests(t *testing.T) { - archive := validV2Archive(t, []byte("library")) + 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: "platform mismatch", mutate: func(e *config.ExpectedPackageMetadata) { e.Platform = differentPackagePlatform() }}, + {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++ }}, } @@ -517,7 +386,7 @@ func TestInstallPackageChecksMetadataAndDigests(t *testing.T) { } } -func TestPackslipPackageVersionRejectsLegacyArchiveBeforeRuntimeMutation(t *testing.T) { +func TestPackslipPackageInstallsLegacyArchive(t *testing.T) { legacy := []byte(`name = "Legacy Driver" version = "1.2.3" @@ -527,42 +396,23 @@ shared = "external-driver" archive := makePackageArchive(t, archiveEntry{name: "MANIFEST", data: legacy}) root := t.TempDir() cfg := config.Config{Level: config.ConfigEnv, Location: root} - existingArchive := validV2Archive(t, []byte("existing")) - existingFile := openPackageArchive(t, existingArchive) - installed, err := config.InstallPackage(cfg, "example", existingFile, - expectedPackage("example", "1.2.3", config.PlatformTuple(), "existing", existingArchive), config.InstallOptions{}) - require.NoError(t, err) - require.NoError(t, existingFile.Close()) - beforeManifest, err := os.ReadFile(filepath.Join(root, "example.toml")) - require.NoError(t, err) - beforeLibrary, err := os.ReadFile(installed.Driver.Shared.Get(config.PlatformTuple())) - require.NoError(t, err) - candidate := expectedPackage("example", "1.2.3", config.PlatformTuple(), "packslip-project", archive) candidateFile := openPackageArchive(t, archive) - validation, err := config.PreparePackage(cfg, "example", candidateFile, candidate, config.InstallOptions{}) - require.ErrorContains(t, err, "dbc package version mismatch: archive declares 0, expected 2") - if validation.Prepared != nil { - require.NoError(t, validation.Prepared.Close()) - } - _, err = candidateFile.Seek(0, 0) + installed, err := config.InstallPackage(cfg, "example", candidateFile, candidate, config.InstallOptions{}) require.NoError(t, err) - _, err = config.InstallPackage(cfg, "example", candidateFile, candidate, config.InstallOptions{}) - require.ErrorContains(t, err, "dbc package version mismatch") require.NoError(t, candidateFile.Close()) - - afterManifest, err := os.ReadFile(filepath.Join(root, "example.toml")) - require.NoError(t, err) - afterLibrary, err := os.ReadFile(installed.Driver.Shared.Get(config.PlatformTuple())) + 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.Equal(t, beforeManifest, afterManifest) - assert.Equal(t, beforeLibrary, afterLibrary) + assert.Len(t, receipts, 1) } func TestPreparePackageIsNonMutatingAndPreparedPackageCanBeInstalled(t *testing.T) { root := t.TempDir() cfg := config.Config{Level: config.ConfigEnv, Location: root} - oldArchive := validV2Archive(t, []byte("existing library")) + 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{}) @@ -576,7 +426,7 @@ func TestPreparePackageIsNonMutatingAndPreparedPackageCanBeInstalled(t *testing. require.NoError(t, err) candidateLibrary := []byte("candidate verified library") - candidateArchive := validV2Archive(t, candidateLibrary) + candidateArchive := validLegacyArchive(t, candidateLibrary) candidate := expectedPackage("example", "1.2.3", config.PlatformTuple(), "candidate-source", candidateArchive) candidateFile := openPackageArchive(t, candidateArchive) defer candidateFile.Close() @@ -631,14 +481,14 @@ func TestPreparePackageIsNonMutatingAndPreparedPackageCanBeInstalled(t *testing. } func TestPreparePackageRejectsMetadataAndVerifierFailures(t *testing.T) { - archive := validV2Archive(t, []byte("library")) + 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 = differentPackagePlatform() }}, + {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) }}, @@ -691,7 +541,7 @@ func entryNames(entries []os.DirEntry) []string { func TestInstallPackageReceiptReplacementAndRuntimeManifest(t *testing.T) { root := t.TempDir() cfg := config.Config{Level: config.ConfigEnv, Location: root} - firstArchive := validV2Archive(t, []byte("first library")) + 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{}) @@ -708,13 +558,12 @@ func TestInstallPackageReceiptReplacementAndRuntimeManifest(t *testing.T) { require.NoError(t, json.Unmarshal(firstReceiptBytes, &firstReceipt)) assert.Equal(t, "github.com/first/source", firstReceipt.SourceIdentity) assert.Equal(t, first.ArchiveHash, firstReceipt.ArchiveHash) - assert.Equal(t, 2, firstReceipt.PackageVersion) - assert.True(t, config.InstallReceiptMatchesExpectedPackage(firstReceipt, first), "the new package version is part of the receipt proof") + 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 := validV2Archive(t, []byte("second library")) + 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{}) @@ -743,7 +592,6 @@ func TestInstallPackageReceiptReplacementAndRuntimeManifest(t *testing.T) { assert.Contains(t, runtimeText, "manifest_version = 1") assert.Contains(t, runtimeText, "source = 'dbc'") assert.Contains(t, runtimeText, "[Driver.shared]") - assert.NotContains(t, runtimeText, "package_version") assert.NotContains(t, runtimeText, "[Files]") loaded, err := config.GetDriver(cfg, "example") @@ -752,28 +600,26 @@ func TestInstallPackageReceiptReplacementAndRuntimeManifest(t *testing.T) { assert.Equal(t, secondLibraryPath, loaded.Driver.Shared.Get(config.PlatformTuple())) } -func TestInstallReceiptPackageVersionProofPreservesUnspecifiedSources(t *testing.T) { +func TestInstallReceiptProofIncludesSourceAndArchive(t *testing.T) { expected := config.ExpectedPackageMetadata{ - ID: "example", Version: "1.2.3", PackageVersion: 2, Platform: config.PlatformTuple(), + 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", PackageVersion: 2, Platform: config.PlatformTuple(), + 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.PackageVersion = 0 - assert.False(t, config.InstallReceiptMatchesExpectedPackage(receipt, expected), "old receipts cannot prove the Packslip package contract") - expected.PackageVersion = 0 - assert.True(t, config.InstallReceiptMatchesExpectedPackage(receipt, expected), "registry and local legacy sources retain unspecified package-version matching") + 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 := validV2Archive(t, []byte("installed library")) + 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{}) @@ -788,7 +634,7 @@ func TestFailedReplacementPreservesExistingPackage(t *testing.T) { badExpected := expected badExpected.ArchiveHash = "sha256:" + strings.Repeat("0", 64) - f = openPackageArchive(t, validV2Archive(t, []byte("replacement library"))) + f = openPackageArchive(t, validLegacyArchive(t, []byte("replacement library"))) _, err = config.InstallPackage(cfg, "example", f, badExpected, config.InstallOptions{}) _ = f.Close() require.Error(t, err) @@ -803,8 +649,8 @@ func TestFailedReplacementPreservesExistingPackage(t *testing.T) { func TestConcurrentInstallPackageSerializesSameTarget(t *testing.T) { root := t.TempDir() cfg := config.Config{Level: config.ConfigEnv, Location: root} - firstArchive := validV2Archive(t, []byte("first library")) - secondArchive := validV2Archive(t, []byte("second library")) + 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) diff --git a/config/package_archive_types.go b/config/package_archive_types.go index f60bb324..e611ba19 100644 --- a/config/package_archive_types.go +++ b/config/package_archive_types.go @@ -25,7 +25,7 @@ import ( const ( installReceiptName = "dbc-install-receipt.json" legacyPackageManifestName = "MANIFEST" - packageV2MetadataName = "dbc-package.toml" + reservedPackageMetadataName = "dbc-package.toml" maxPackageMetadataSize = 1 << 20 registrationFingerprintAlgorithm = "sha256" registrationFingerprintVersion = 1 @@ -39,7 +39,6 @@ const ( type ExpectedPackageMetadata struct { ID string Version string - PackageVersion int Platform string SourceType string SourceIdentity string @@ -61,7 +60,6 @@ type InstallReceipt struct { DriverID string `json:"driver_id"` DriverVersion string `json:"driver_version"` Platform string `json:"platform"` - PackageVersion int `json:"package_version"` ArchiveHash string `json:"archive_hash"` ArchiveSize int64 `json:"archive_size"` InstalledLibrary string `json:"installed_library,omitempty"` @@ -84,7 +82,6 @@ type PackageValidation struct { VerifiedLibraryHash string ArchiveHash string ArchiveSize int64 - PackageVersion int Registration DriverInfo RegistrationFingerprintAlgorithm string RegistrationFingerprintVersion int @@ -131,8 +128,8 @@ func (p *PreparedPackage) Close() error { // MatchesExpected reports whether this unconsumed prepared payload was // validated for the supplied expected package metadata. It also accepts the -// finalized metadata whose archive hash, size, and package version were -// measured while preparing the package. +// 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 @@ -190,28 +187,6 @@ type runtimeRegistrationFingerprintV1 struct { Shared registrationSharedIdentity `json:"shared"` } -type packageManifest struct { - manifest Manifest - id string - platform string - v2 bool -} - -type packageManifestV2Wire struct { - PackageVersion int64 `toml:"package_version"` - ManifestVersion *int64 `toml:"manifest_version"` - ID string `toml:"id"` - Name string `toml:"name"` - Version string `toml:"version"` - Platform string `toml:"platform"` - Driver struct { - Entrypoint string `toml:"entrypoint"` - } `toml:"Driver"` - Files struct { - Driver string `toml:"driver"` - } `toml:"Files"` -} - // 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. diff --git a/config/package_install.go b/config/package_install.go index eab28cae..70740949 100644 --- a/config/package_install.go +++ b/config/package_install.go @@ -442,11 +442,7 @@ func PreparePackage(cfg Config, runtimeID string, downloaded *os.File, expected if !ok { return PackageValidation{}, errors.New("could not read prepared package receipt") } - if manifest.PackageVersion == 2 && requested.ArchiveHash == "" { - return PackageValidation{}, errors.New("package v2 requires archive hash metadata") - } finalExpected := requested - finalExpected.PackageVersion = manifest.PackageVersion finalExpected.ArchiveHash = receipt.ArchiveHash finalExpected.ArchiveSize = receipt.ArchiveSize finalExpected.ArchiveSizePresent = true @@ -458,7 +454,7 @@ func PreparePackage(cfg Config, runtimeID string, downloaded *os.File, expected return PackageValidation{ VerifiedLibraryHash: receipt.InstalledLibraryHash, ArchiveHash: receipt.ArchiveHash, ArchiveSize: receipt.ArchiveSize, - PackageVersion: manifest.PackageVersion, Registration: manifest.DriverInfo, + Registration: manifest.DriverInfo, RegistrationFingerprintAlgorithm: receipt.RegistrationFingerprintAlgorithm, RegistrationFingerprintVersion: receipt.RegistrationFingerprintVersion, RegistrationFingerprint: receipt.RegistrationFingerprint, @@ -524,17 +520,14 @@ func InstallReceiptMatchesRuntimeRegistration(receipt InstallReceipt, current Dr } // InstallReceiptMatchesExpectedPackage checks the resolved package identity -// recorded by a receipt. A zero expected package version preserves the -// unspecified contract used by registry and local legacy sources; a nonzero -// expectation requires an exact receipt match. +// 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) && - (expected.PackageVersion == 0 || receipt.PackageVersion == expected.PackageVersion) + (!expected.hasExpectedArchiveSize() || receipt.ArchiveSize == expected.ArchiveSize) } // PackageValidationMatchesRuntimeRegistration compares a current registration @@ -727,9 +720,6 @@ func normalizePackageInstallMetadata(runtimeID string, expected ExpectedPackageM } } } - if err := validateExpectedPackageVersion(expected.PackageVersion); err != nil { - return ExpectedPackageMetadata{}, err - } if expected.SourceType == "" { expected.SourceType = "local" } diff --git a/config/package_manifest.go b/config/package_manifest.go index e87a33be..0a29fc23 100644 --- a/config/package_manifest.go +++ b/config/package_manifest.go @@ -16,148 +16,64 @@ package config import ( "fmt" - "github.com/Masterminds/semver/v3" "github.com/pelletier/go-toml/v2" "strings" ) -func decodePackageManifest(name string, data []byte) (packageManifest, error) { +func decodePackageManifest(name string, data []byte) (Manifest, error) { switch name { - case packageV2MetadataName: - return decodePackageV2Metadata(data) 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 packageManifest{}, fmt.Errorf("unsupported package metadata filename %q", name) + return Manifest{}, fmt.Errorf("unsupported package metadata filename %q", name) } } -func decodePackageV2Metadata(data []byte) (packageManifest, error) { - var root map[string]any - if err := toml.Unmarshal(data, &root); err != nil { - return packageManifest{}, fmt.Errorf("%w: error decoding package v2 metadata: %v", ErrInvalidManifest, err) - } - - rawVersion, hasDiscriminator := root["package_version"] - if !hasDiscriminator { - return packageManifest{}, fmt.Errorf("%w: package_version = 2 is required in %s", ErrInvalidManifest, packageV2MetadataName) - } - version, ok := rawVersion.(int64) - if !ok { - return packageManifest{}, fmt.Errorf("%w: package_version must be an integer", ErrInvalidManifest) - } - if version != 2 { - return packageManifest{}, fmt.Errorf("%w: package version %d is unsupported", ErrInvalidManifest, version) - } - - var wire packageManifestV2Wire - if err := toml.Unmarshal(data, &wire); err != nil { - return packageManifest{}, fmt.Errorf("%w: error decoding package v2 metadata: %v", ErrInvalidManifest, err) - } - if wire.PackageVersion != 2 { - return packageManifest{}, fmt.Errorf("%w: package_version must be 2", ErrInvalidManifest) - } - if wire.ManifestVersion != nil { - return packageManifest{}, fmt.Errorf("%w: package v2 must not set runtime manifest_version", ErrInvalidManifest) - } - if err := validateFlatName(wire.ID); err != nil { - return packageManifest{}, fmt.Errorf("%w: invalid package id: %v", ErrInvalidManifest, err) - } - if strings.TrimSpace(wire.Name) == "" { - return packageManifest{}, fmt.Errorf("%w: name is required", ErrInvalidManifest) - } - if wire.Version == "" { - return packageManifest{}, fmt.Errorf("%w: version is required", ErrInvalidManifest) - } - parsedVersion, err := semver.StrictNewVersion(wire.Version) - if err != nil { - return packageManifest{}, fmt.Errorf("%w: version %q must be valid SemVer 2.0.0: %v", ErrInvalidManifest, wire.Version, err) - } - if err := validatePlatformIdentifier(wire.Platform); err != nil { - return packageManifest{}, fmt.Errorf("%w: invalid platform: %v", ErrInvalidManifest, err) - } - if strings.TrimSpace(wire.Driver.Entrypoint) == "" { - return packageManifest{}, fmt.Errorf("%w: Driver.entrypoint is required", ErrInvalidManifest) - } - if err := validateFlatName(wire.Files.Driver); err != nil { - return packageManifest{}, fmt.Errorf("%w: invalid Files.driver: %v", ErrInvalidManifest, err) - } - - return packageManifest{ - manifest: Manifest{ - PackageVersion: 2, - PackagePlatform: wire.Platform, - DriverInfo: DriverInfo{ - ID: wire.ID, - Name: wire.Name, - Version: parsedVersion, - Driver: struct { - Entrypoint string - Shared driverMap - }{Entrypoint: wire.Driver.Entrypoint}, - }, - Files: struct { - Driver string `toml:"driver,omitempty"` - Signature string `toml:"signature,omitempty"` - }{Driver: wire.Files.Driver}, - }, - id: wire.ID, platform: wire.Platform, v2: true, - }, nil -} - -func decodeLegacyPackageManifest(data []byte) (packageManifest, error) { - var root map[string]any - if err := toml.Unmarshal(data, &root); err != nil { - return packageManifest{}, fmt.Errorf("%w: error decoding legacy package manifest: %v", ErrInvalidManifest, err) - } - if _, hasPackageVersion := root["package_version"]; hasPackageVersion { - return packageManifest{}, fmt.Errorf("%w: package_version is not allowed in legacy %s; use %s for package v2 metadata", ErrInvalidManifest, legacyPackageManifestName, packageV2MetadataName) - } - +func decodeLegacyPackageManifest(data []byte) (Manifest, error) { var wire legacyPackageManifestWire if err := toml.Unmarshal(data, &wire); err != nil { - return packageManifest{}, fmt.Errorf("%w: error decoding legacy package manifest: %v", ErrInvalidManifest, err) + return Manifest{}, fmt.Errorf("%w: error decoding legacy package manifest: %v", ErrInvalidManifest, err) } if wire.ManifestVersion > currentManifestVersion { - return packageManifest{}, fmt.Errorf("manifest version %d is unsupported, only %d and lower are supported by this version of dbc", 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 packageManifest{}, fmt.Errorf("%w: name is required", ErrInvalidManifest) + return Manifest{}, fmt.Errorf("%w: name is required", ErrInvalidManifest) } if wire.Version == nil { - return packageManifest{}, fmt.Errorf("%w: version is required", ErrInvalidManifest) + return Manifest{}, fmt.Errorf("%w: version is required", ErrInvalidManifest) } if wire.Files.Driver != "" { if err := validateFlatName(wire.Files.Driver); err != nil { - return packageManifest{}, fmt.Errorf("%w: invalid Files.driver: %v", ErrInvalidManifest, err) + return Manifest{}, fmt.Errorf("%w: invalid Files.driver: %v", ErrInvalidManifest, err) } } shared, err := decodeDriverShared(wire.Driver.Shared, false) if err != nil { - return packageManifest{}, err - } - return packageManifest{ - manifest: 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, + 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, packageV2MetadataName} { + for _, expected := range []string{legacyPackageManifestName, reservedPackageMetadataName} { if !strings.EqualFold(name, expected) { continue } @@ -171,17 +87,17 @@ func classifyPackageMetadataName(name string) (string, bool, error) { func selectPackageMetadata(metadata map[string][]byte) (string, []byte, error) { legacy, hasLegacy := metadata[legacyPackageManifestName] - v2, hasV2 := metadata[packageV2MetadataName] - if hasLegacy && hasV2 { - return "", nil, fmt.Errorf("package archive must contain either %s or %s, not both", legacyPackageManifestName, packageV2MetadataName) + 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 hasV2 { - return packageV2MetadataName, v2, nil + if hasReserved { + return reservedPackageMetadataName, reserved, nil } - return "", nil, fmt.Errorf("package archive must contain exactly one of %s or %s", legacyPackageManifestName, packageV2MetadataName) + return "", nil, fmt.Errorf("package archive must contain %s metadata", legacyPackageManifestName) } func decodeDriverShared(value any, required bool) (driverMap, error) { diff --git a/config/package_receipts.go b/config/package_receipts.go index fe309076..f22194d3 100644 --- a/config/package_receipts.go +++ b/config/package_receipts.go @@ -535,9 +535,6 @@ func readPackageReceiptEvidence(location, runtimeID, directory string) (InstallR } func parseReceiptMetadata(receipt InstallReceipt) error { - if err := validateExpectedPackageVersion(receipt.PackageVersion); err != nil { - return fmt.Errorf("invalid receipt package version: %w", err) - } if _, err := semver.NewVersion(receipt.DriverVersion); err != nil { return fmt.Errorf("invalid receipt driver version: %w", err) } @@ -567,9 +564,6 @@ func validateExpectedPackage(expected ExpectedPackageMetadata) error { if _, err := semver.NewVersion(expected.Version); err != nil { return fmt.Errorf("invalid expected package version %q: %w", expected.Version, err) } - if err := validateExpectedPackageVersion(expected.PackageVersion); err != nil { - return err - } if err := validatePlatformIdentifier(expected.Platform); err != nil { return fmt.Errorf("invalid expected package platform: %w", err) } @@ -581,10 +575,3 @@ func validateExpectedPackage(expected ExpectedPackageMetadata) error { } return nil } - -func validateExpectedPackageVersion(version int) error { - if version != 0 && version != 2 { - return fmt.Errorf("unsupported dbc package version %d", version) - } - return nil -} diff --git a/docs/guides/installing.md b/docs/guides/installing.md index 81df9f7d..20588227 100644 --- a/docs/guides/installing.md +++ b/docs/guides/installing.md @@ -271,16 +271,15 @@ dbc can install drivers from local archives as an alternative to a [Driver Regis ### Package archive metadata -dbc accepts both package formats from local paths, registries, and Packslip releases. Legacy archives with a root-level `MANIFEST` remain fully supported, including for dbc 0.3.x compatibility. `dbc-package.toml` with `package_version = 2` is an optional package format that gives the archive explicit driver identity and platform metadata and separates dbc packaging details from the installed `.toml` ADBC Driver Manifest (`manifest_version = 1`). Source metadata, including Packslip extensions, identifies the artifact but does not need to declare its package format; dbc determines that format by inspecting the downloaded archive. +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. -For example, `dbc-package.toml` can contain: +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 -package_version = 2 -id = "example" name = "Example ADBC Driver" version = "1.0.0" -platform = "linux_amd64" [Driver] entrypoint = "AdbcDriverExampleInit" @@ -289,7 +288,11 @@ entrypoint = "AdbcDriverExampleInit" driver = "libadbc_driver_example.so" ``` -The archive must be flat: place `dbc-package.toml` and the library file at its root. `id` is the installed runtime ID, `name` is the display name, `version` is a SemVer version, `platform` is the target tuple, `Driver.entrypoint` is the exported initialization symbol, and `Files.driver` names the library file in the archive root. +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: @@ -305,7 +308,7 @@ Installed example 1.0.0 to /Users/user/Library/Application Support/ADBC/Drivers !!! note - With dbc 0.4.0, use the `id` from `dbc-package.toml` (`example` above) as the driver name when connecting through a [Driver Manager](../concepts/driver_manager.md). With dbc 0.3.0 and earlier, the runtime ID may be derived from the archive filename. + 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/internal/packslip/dbc_extension_test.go b/internal/packslip/dbc_extension_test.go index 45e6522c..1ad0f446 100644 --- a/internal/packslip/dbc_extension_test.go +++ b/internal/packslip/dbc_extension_test.go @@ -80,7 +80,7 @@ func TestDBCArtifactExtensionIsExtensibleMembershipMarker(t *testing.T) { 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","package_version":2}`) + 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`) diff --git a/internal/packslip/resolver_native_test.go b/internal/packslip/resolver_native_test.go index c9dcb6d0..8f20ce44 100644 --- a/internal/packslip/resolver_native_test.go +++ b/internal/packslip/resolver_native_test.go @@ -533,7 +533,6 @@ func TestResolverUsesVersionBearingTagAndKeepsProjectAsSourceIdentity(t *testing 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) - require.Zero(t, resolved.Artifacts[0].PackageVersion) } func TestResolverAdoptsSignedDriverIDWhenNoExpectedIDIsProvided(t *testing.T) { @@ -543,7 +542,6 @@ func TestResolverAdoptsSignedDriverIDWhenNoExpectedIDIsProvided(t *testing.T) { resolved, err := resolver.Resolve(context.Background(), PackslipSource{Project: testProject}, Request{Version: "1.2.3"}) require.NoError(t, err) require.Equal(t, "driver", resolved.DriverID) - require.Zero(t, resolved.Artifacts[0].PackageVersion) } func TestResolverRejectsExpectedDriverIDThatDiffersFromSignedDeclaration(t *testing.T) { diff --git a/internal/packslip/selection.go b/internal/packslip/selection.go index a5f9bf01..46b6c9e2 100644 --- a/internal/packslip/selection.go +++ b/internal/packslip/selection.go @@ -182,12 +182,11 @@ func convertArtifact(release *parsedRelease, artifact *releaseArtifact, artifact } size := int64(*artifact.Size) result := resolution.Artifact{ - Target: resolution.CanonicalTarget(target), - Format: stringValue(artifact.Format), - PackageVersion: 0, - Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: artifactURL}, - Hash: "sha256:" + subject.Digest["sha256"], - Size: &size, + 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 diff --git a/internal/resolution/types.go b/internal/resolution/types.go index 58711344..09edca31 100644 --- a/internal/resolution/types.go +++ b/internal/resolution/types.go @@ -230,11 +230,8 @@ type ResolvedRelease struct { // may be absent before resolution but is required for a finalized snapshot. // Size is optional metadata. type Artifact struct { - Target Target - Format string - // PackageVersion is the dbc package contract declared by the source. Zero - // means no explicit package-version marker is present. - PackageVersion int + Target Target + Format string Location ArtifactLocation Hash string Size *int64 @@ -333,9 +330,6 @@ func ValidateResolvedReleaseCandidate(release ResolvedRelease) error { seenTargets := make(map[Target]struct{}, len(release.Artifacts)) seenLocations := make(map[ArtifactLocation]Artifact, len(release.Artifacts)) for i, artifact := range release.Artifacts { - if artifact.PackageVersion != 0 && artifact.PackageVersion != 2 { - return fmt.Errorf("artifact %d has unsupported dbc package version %d", i, artifact.PackageVersion) - } if err := ValidateArtifactLocation(artifact.Location); err != nil { return fmt.Errorf("artifact %d has invalid location: %w", i, err) } diff --git a/internal/resolution/types_test.go b/internal/resolution/types_test.go index faebd20a..0bc65110 100644 --- a/internal/resolution/types_test.go +++ b/internal/resolution/types_test.go @@ -119,9 +119,6 @@ func TestValidateResolvedReleaseRequiresFinalizedArtifacts(t *testing.T) { }{ {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: "unsupported package version 1", mutate: func(r *ResolvedRelease) { r.Artifacts[0].PackageVersion = 1 }, wantErr: "unsupported dbc package version 1"}, - {name: "negative package version", mutate: func(r *ResolvedRelease) { r.Artifacts[0].PackageVersion = -1 }, wantErr: "unsupported dbc package version -1"}, - {name: "future package version", mutate: func(r *ResolvedRelease) { r.Artifacts[0].PackageVersion = 3 }, wantErr: "unsupported dbc package version 3"}, {name: "duplicate target", mutate: func(r *ResolvedRelease) { duplicate := r.Artifacts[0] r.Artifacts = append(r.Artifacts, duplicate) diff --git a/internal/sourceresolution/requirement_test.go b/internal/sourceresolution/requirement_test.go index 743487a1..8212994b 100644 --- a/internal/sourceresolution/requirement_test.go +++ b/internal/sourceresolution/requirement_test.go @@ -152,7 +152,7 @@ func TestRequirementValidateResolvedReleaseChecksSourceDriverVersionAndStructure 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].PackageVersion = 3 }, want: "unsupported dbc package 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) { @@ -267,50 +267,53 @@ func TestExplicitSourceAcceptsOnlyExactCanonicalKey(t *testing.T) { _, err = requirement.ValidateResolverResult(differentBuild) assert.ErrorContains(t, err, "version") - missingMarker := cloneResolvedRelease(matching) - missingMarker.Artifacts[0].PackageVersion = 0 - _, err = requirement.ValidateResolverResult(missingMarker) - assert.NoError(t, err, "package version zero is source-neutral unspecified metadata") + snapshot := cloneResolvedRelease(matching) + _, err = requirement.ValidateResolverResult(snapshot) + assert.NoError(t, err, "source metadata is valid") for _, locked := range []bool{false, true} { - plan := requirement.Plan(&missingMarker, locked) + plan := requirement.Plan(&snapshot, locked) assert.Equal(t, PlanReplay, plan.Outcome()) - selected, hasArtifact := plan.Artifact() + _, hasArtifact := plan.Artifact() assert.True(t, hasArtifact) - assert.Zero(t, selected.PackageVersion) } newVersionRequirement := mustRequirement(t, "driver", selection, mustPackslipVersion(t, "1.2.4"), planTarget) - assert.Equal(t, PlanResolve, newVersionRequirement.Plan(&missingMarker, false).Outcome(), - "a stale version is resolved without reinterpreting package version metadata") + assert.Equal(t, PlanResolve, newVersionRequirement.Plan(&snapshot, false).Outcome(), + "a stale version is resolved") } -func TestPackageVersionZeroIsSourceNeutral(t *testing.T) { - registryRequirement := mustRequirement(t, "driver", DefaultRegistrySelection(), mustRegistryVersion(t, "=1.2.3", PrereleaseForbidden), planTarget) - registryRelease := validPlanRelease("registry", "https://registry.example.test", "1.2.3", "driver", planTarget) - registryRelease.Artifacts[0].PackageVersion = 0 - assertPackageVersionZeroCanReplay(t, registryRequirement, registryRelease) - - pathKey, err := sourceidentity.Parse(sourceidentity.Path, "./driver.tar.gz") - require.NoError(t, err) - pathSelection, err := ExplicitSourceSelection(pathKey) - require.NoError(t, err) - pathVersion, err := PathVersionRequirement("1.2.3") - require.NoError(t, err) - pathRequirement := mustRequirement(t, "driver", pathSelection, pathVersion, planTarget) - pathRelease := validPlanRelease("path", "./driver.tar.gz", "1.2.3", "driver", planTarget) - pathRelease.Artifacts[0].PackageVersion = 0 - assertPackageVersionZeroCanReplay(t, pathRequirement, pathRelease) -} - -func assertPackageVersionZeroCanReplay(t *testing.T, requirement Requirement, release resolution.ResolvedRelease) { - t.Helper() - selected, err := requirement.ValidateResolverResult(release) - require.NoError(t, err) - assert.Zero(t, selected.PackageVersion) - plan := requirement.Plan(&release, false) - assert.Equal(t, PlanReplay, plan.Outcome()) - selected, ok := plan.Artifact() - require.True(t, ok) - assert.Zero(t, selected.PackageVersion) +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 { @@ -336,21 +339,16 @@ func mustPackslipVersion(t *testing.T, value string) VersionRequirement { func validPlanRelease(sourceType, reference, version, driverID string, target resolution.Target) resolution.ResolvedRelease { size := int64(1) - packageVersion := 0 - if sourceType == "packslip" { - packageVersion = 2 - } return resolution.ResolvedRelease{ DriverID: driverID, Version: version, Source: resolution.SourceSpec{Type: sourceType, Reference: reference}, Artifacts: []resolution.Artifact{{ - Target: target, - Format: "tar.gz", - PackageVersion: packageVersion, - Location: resolution.ArtifactLocation{Kind: resolution.ArtifactLocationURL, Value: "https://example.test/driver.tar.gz"}, - Hash: "sha256:" + strings.Repeat("a", 64), - Size: &size, + 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 index 1b3ee64b..c68e9156 100644 --- a/internal/sourceresolution/resolver.go +++ b/internal/sourceresolution/resolver.go @@ -28,7 +28,7 @@ import ( // 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 by package v2 metadata. +// 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. @@ -79,9 +79,6 @@ func ResolvePackslip(ctx context.Context, resolver packslip.Resolver, project, d return resolution.ResolvedRelease{}, fmt.Errorf("packslip resolver returned an invalid release: %w", err) } for i, artifact := range release.Artifacts { - if artifact.PackageVersion != 0 { - return resolution.ResolvedRelease{}, fmt.Errorf("packslip artifact %d package format must remain unspecified until archive inspection", i) - } if artifact.Size == nil { return resolution.ResolvedRelease{}, fmt.Errorf("packslip artifact %d must declare archive size", i) } @@ -145,14 +142,6 @@ func ResolvePath(ctx context.Context, declaredPath string, request Request) (res if request.Version != "" && resolvedVersion != request.Version { return resolution.ResolvedRelease{}, fmt.Errorf("local package version %q does not match requested version %q", resolvedVersion, request.Version) } - if manifest.PackageVersion == 2 { - if manifest.ID != request.DriverID { - return resolution.ResolvedRelease{}, fmt.Errorf("local package ID %q does not match requested driver %q", manifest.ID, request.DriverID) - } - if manifest.PackagePlatform != request.Platform { - return resolution.ResolvedRelease{}, fmt.Errorf("local package platform %q does not match current platform %q", manifest.PackagePlatform, request.Platform) - } - } // 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. @@ -166,12 +155,11 @@ func ResolvePath(ctx context.Context, declaredPath string, request Request) (res Version: resolvedVersion, Source: resolution.SourceSpec{Type: "path", Reference: declaredPath}, Artifacts: []resolution.Artifact{{ - Target: target, - Format: format, - PackageVersion: manifest.PackageVersion, - Location: location, - Hash: archiveHash, - Size: &size, + Target: target, + Format: format, + Location: location, + Hash: archiveHash, + Size: &size, }}, } if err := resolution.ValidateResolvedRelease(release); err != nil { diff --git a/internal/sourceresolution/resolver_test.go b/internal/sourceresolution/resolver_test.go index 05b53006..3423b718 100644 --- a/internal/sourceresolution/resolver_test.go +++ b/internal/sourceresolution/resolver_test.go @@ -70,7 +70,6 @@ func TestResolvePackslipRejectsMismatchedMetadata(t *testing.T) { {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"}, - {name: "declared package format", change: func(r *resolution.ResolvedRelease) { r.Artifacts[0].PackageVersion = 2 }, want: "remain unspecified"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -100,7 +99,7 @@ func TestResolvePathUsesProjectBaseAndPreservesDeclaration(t *testing.T) { projectDir := t.TempDir() cwd := t.TempDir() require.NoError(t, os.Mkdir(filepath.Join(projectDir, "packages"), 0o700)) - archive := testPackageArchive(t, "dbc-package.toml", v2Metadata("example", "1.2.3", "linux_amd64")) + 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) @@ -117,35 +116,11 @@ func TestResolvePathUsesProjectBaseAndPreservesDeclaration(t *testing.T) { 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, 2, artifact.PackageVersion) 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 TestResolvePathRejectsV2DriverIDAndPlatformMismatch(t *testing.T) { - for _, test := range []struct { - name string - metadata []byte - platform string - want string - }{ - {name: "driver ID", metadata: v2Metadata("other", "1.2.3", "linux_amd64"), platform: "linux_amd64", want: "ID"}, - {name: "platform", metadata: v2Metadata("example", "1.2.3", "macos_arm64"), platform: "linux_amd64", want: "platform"}, - } { - t.Run(test.name, func(t *testing.T) { - projectDir := t.TempDir() - archive := testPackageArchive(t, "dbc-package.toml", test.metadata) - 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: "linux", Arch: "amd64", LibC: "gnu"}, - Platform: test.platform, BaseDir: projectDir, - }) - require.ErrorContains(t, err, test.want) - }) - } -} - 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")) @@ -163,8 +138,8 @@ func TestResolvePathAdaptsLegacyPackageToRequestedIdentityAndTarget(t *testing.T func TestResolvePathDetectsMissingAndMutatedArchives(t *testing.T) { projectDir := t.TempDir() path := filepath.Join(projectDir, "driver.tar.gz") - firstBytes := testPackageArchive(t, "dbc-package.toml", v2Metadata("example", "1.2.3", "linux_amd64")) - secondBytes := testPackageArchive(t, "dbc-package.toml", v2Metadata("example", "1.2.3", "linux_amd64")) + 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} @@ -188,15 +163,22 @@ func resolvedPackslipRelease(project, version, driverID string) resolution.Resol Version: version, Source: resolution.SourceSpec{Type: "packslip", Reference: project}, Artifacts: []resolution.Artifact{{ - Target: resolution.Target{OS: "linux", Arch: "amd64", LibC: "gnu"}, Format: "tar.gz", PackageVersion: 0, + 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 v2Metadata(id, version, platform string) []byte { - return []byte(fmt.Sprintf("package_version = 2\nid = %q\nname = 'Example Driver'\nversion = %q\nplatform = %q\n\n[Driver]\nentrypoint = 'ExampleInit'\n\n[Files]\ndriver = 'driver.so'\n", id, version, platform)) +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 { @@ -219,7 +201,7 @@ func digest(contents []byte) string { func TestResolvePathRequiresDeclaredVersion(t *testing.T) { projectDir := t.TempDir() - archive := testPackageArchive(t, "dbc-package.toml", v2Metadata("example", "1.2.3", "linux_amd64")) + 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"}, @@ -230,7 +212,7 @@ func TestResolvePathRequiresDeclaredVersion(t *testing.T) { func TestResolvePathDerivesOmittedVersionFromPackageMetadata(t *testing.T) { projectDir := t.TempDir() - archive := testPackageArchive(t, "dbc-package.toml", v2Metadata("example", "1.2.3", "linux_amd64")) + 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{ @@ -243,7 +225,7 @@ func TestResolvePathDerivesOmittedVersionFromPackageMetadata(t *testing.T) { func TestResolvePathRejectsNonArchiveExtension(t *testing.T) { projectDir := t.TempDir() - archive := testPackageArchive(t, "dbc-package.toml", v2Metadata("example", "1.2.3", "linux_amd64")) + 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"}, @@ -262,7 +244,7 @@ func TestResolvePathRequiresAbsoluteProjectBase(t *testing.T) { func TestResolvePathRequiresPlatformTargetConsistency(t *testing.T) { projectDir := t.TempDir() - archive := testPackageArchive(t, "dbc-package.toml", v2Metadata("example", "1.2.3", "linux_amd64")) + 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"}, diff --git a/package_signature.go b/package_signature.go index d46f0a6a..daccfbc3 100644 --- a/package_signature.go +++ b/package_signature.go @@ -25,10 +25,10 @@ import ( ) // VerifyPackageSignature verifies a legacy package's library signature while -// the extracted package is still in its private staging directory. Version 2 -// packages and packages without a library file do not use legacy signatures. +// 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.PackageVersion == 2 || manifest.Files.Driver == "" { + if manifest.Files.Driver == "" { return nil } From a68a96a054cd4d53f59ec8d4d43939d2e949d64f Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 26 Sep 2026 01:45:06 +0000 Subject: [PATCH 115/115] docs: clarify package verification and receipt reuse policies --- cmd/dbc/package_executor.go | 4 ++++ config/package_archive_types.go | 3 +++ package_signature.go | 7 ++++--- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/cmd/dbc/package_executor.go b/cmd/dbc/package_executor.go index a1c0ba96..6cdf6338 100644 --- a/cmd/dbc/package_executor.go +++ b/cmd/dbc/package_executor.go @@ -321,6 +321,8 @@ func (e *packageExecutor) downloadAndPrepareItem(ctx context.Context, item *inst // 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) @@ -392,6 +394,8 @@ func (e *packageExecutor) itemCurrentMatches(item *installItem, current *config. } 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) { diff --git a/config/package_archive_types.go b/config/package_archive_types.go index e611ba19..250e64a3 100644 --- a/config/package_archive_types.go +++ b/config/package_archive_types.go @@ -72,6 +72,9 @@ type InstallReceipt struct { // 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 } diff --git a/package_signature.go b/package_signature.go index daccfbc3..707e9ee9 100644 --- a/package_signature.go +++ b/package_signature.go @@ -24,9 +24,10 @@ import ( "github.com/columnar-tech/dbc/config" ) -// VerifyPackageSignature verifies a legacy package's library signature while -// the extracted package is still in its private staging directory. Packages -// without a library file do not use library signatures. +// 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