diff --git a/cmd/wfctl/plugin_compat_model.go b/cmd/wfctl/plugin_compat_model.go index 0f3fad95..831915b6 100644 --- a/cmd/wfctl/plugin_compat_model.go +++ b/cmd/wfctl/plugin_compat_model.go @@ -11,6 +11,7 @@ import ( "slices" "strings" + "golang.org/x/mod/module" "golang.org/x/mod/semver" ) @@ -190,6 +191,20 @@ func canonicalStrictSemver(version, label string) (string, error) { return version, nil } +func CanonicalEvidenceEngineVersion(version string) (string, error) { + version = strings.TrimSpace(version) + if version == "" { + return "", fmt.Errorf("engine version is required") + } + if !strings.HasPrefix(version, "v") { + version = "v" + version + } + if (!strictSemverRe.MatchString(version) && !module.IsPseudoVersion(version)) || !semver.IsValid(version) { + return "", fmt.Errorf("engine version %q must be semver MAJOR.MINOR.PATCH or a Go pseudo-version, with optional leading v", strings.TrimPrefix(version, "v")) + } + return version, nil +} + func NormalizeSHA256Hex(value string) (string, error) { value = strings.TrimSpace(value) if len(value) != sha256.Size*2 { @@ -210,12 +225,12 @@ func ValidateCompatibilityEvidence(ev PluginCompatibilityEvidence) (PluginCompat if ev.Version, err = CanonicalPluginVersion(ev.Version); err != nil { return ev, err } - if ev.EngineVersion, err = CanonicalEngineVersion(ev.EngineVersion); err != nil { + if ev.EngineVersion, err = CanonicalEvidenceEngineVersion(ev.EngineVersion); err != nil { return ev, err } if ev.WfctlVersion != "" { ev.WfctlVersion = strings.TrimSpace(ev.WfctlVersion) - if canonical, err := CanonicalEngineVersion(ev.WfctlVersion); err == nil { + if canonical, err := CanonicalEvidenceEngineVersion(ev.WfctlVersion); err == nil { ev.WfctlVersion = canonical } } diff --git a/cmd/wfctl/plugin_compat_model_test.go b/cmd/wfctl/plugin_compat_model_test.go index e42052ed..bddb96a0 100644 --- a/cmd/wfctl/plugin_compat_model_test.go +++ b/cmd/wfctl/plugin_compat_model_test.go @@ -18,7 +18,6 @@ func TestPluginCompatVersionCanonicalization(t *testing.T) { t.Fatalf("CanonicalEngineVersion(%q) = %q, want v0.51.2", input, got) } } - got, err := CanonicalPluginVersion("1.2.3") if err != nil { t.Fatalf("CanonicalPluginVersion: %v", err) @@ -34,6 +33,38 @@ func TestPluginCompatVersionRejectsInvalid(t *testing.T) { t.Fatalf("CanonicalEngineVersion(%q) succeeded, want error", input) } } + if _, err := CanonicalPluginVersion("v1.2.3-0.20260511085732-8246535de8bd"); err == nil { + t.Fatalf("CanonicalPluginVersion accepted pseudo-version") + } +} + +func TestPluginCompatEvidenceEngineVersionCanonicalization(t *testing.T) { + for _, input := range []string{ + "0.51.2", + "v0.51.2", + "0.0.0-20260511085732-8246535de8bd", + "v0.0.0-20260511085732-8246535de8bd", + "v0.51.3-0.20260511085732-8246535de8bd", + "v0.51.3-pre.0.20260511085732-8246535de8bd", + } { + got, err := CanonicalEvidenceEngineVersion(input) + if err != nil { + t.Fatalf("CanonicalEvidenceEngineVersion(%q): %v", input, err) + } + if !strings.HasPrefix(got, "v") { + t.Fatalf("CanonicalEvidenceEngineVersion(%q) = %q, want leading v", input, got) + } + } +} + +func TestPluginCompatResolverEngineVersionStaysReleaseComparable(t *testing.T) { + got, comparable := resolvePluginCompatEngineVersion("v0.51.3-0.20260511085732-8246535de8bd") + if comparable { + t.Fatalf("pseudo engine version comparable = true, got %q", got) + } + if got != "v0.0.0" { + t.Fatalf("pseudo engine fallback = %q, want v0.0.0", got) + } } func TestPluginCompatDigestOmitsEvidenceDigest(t *testing.T) {