From 826121937e227164b17e4a2ee5cad3850789947d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 04:24:57 -0400 Subject: [PATCH] fix(wfctl): support nested plugin conformance builds --- cmd/wfctl/plugin_conformance.go | 61 +++++++++++- cmd/wfctl/plugin_conformance_test.go | 139 ++++++++++++++++++++++++++- docs/WFCTL.md | 2 + 3 files changed, 198 insertions(+), 4 deletions(-) diff --git a/cmd/wfctl/plugin_conformance.go b/cmd/wfctl/plugin_conformance.go index 38e912b0..0c7d754d 100644 --- a/cmd/wfctl/plugin_conformance.go +++ b/cmd/wfctl/plugin_conformance.go @@ -25,6 +25,7 @@ func runPluginConformance(args []string) error { fs := flag.NewFlagSet("plugin conformance", flag.ContinueOnError) mode := fs.String("mode", PluginCompatibilityModeTypedIaC, "Conformance mode (typed-iac)") artifact := fs.String("artifact", "", "Release artifact tar.gz to test") + buildPackage := fs.String("build-package", ".", "Go package to build when testing a source directory") engineVersion := fs.String("engine-version", "", "Workflow engine version for evidence metadata") format := fs.String("format", "text", "Output format: text or json") output := fs.String("output", "", "Write JSON evidence to this path") @@ -35,15 +36,29 @@ func runPluginConformance(args []string) error { if err := fs.Parse(args); err != nil { return err } + buildPackageSet := false + fs.Visit(func(f *flag.Flag) { + if f.Name == "build-package" { + buildPackageSet = true + } + }) if *mode != PluginCompatibilityModeTypedIaC { return fmt.Errorf("unsupported conformance mode %q", *mode) } if *format != "text" && *format != "json" { return fmt.Errorf("--format must be text or json") } + normalizedBuildPackage, err := normalizeConformanceBuildPackage(*buildPackage) + if err != nil { + return err + } + *buildPackage = normalizedBuildPackage if *artifact != "" && fs.NArg() > 0 { return fmt.Errorf("specify exactly one of or --artifact") } + if *artifact != "" && buildPackageSet { + return fmt.Errorf("--build-package is only supported with ") + } if *artifact == "" && fs.NArg() != 1 { fs.Usage() return fmt.Errorf("specify exactly one of or --artifact") @@ -62,6 +77,7 @@ func runPluginConformance(args []string) error { Mode: *mode, SourceDir: source, ArtifactPath: *artifact, + BuildPackage: *buildPackage, EngineVersion: *engineVersion, Timeout: *timeout, }) @@ -97,7 +113,7 @@ func runPluginConformance(args []string) error { } func printPluginConformanceUsage(w io.Writer, fs *flag.FlagSet) { - fmt.Fprintf(w, "Usage: wfctl plugin conformance [options] \n wfctl plugin conformance --artifact [options]\n\nRun executable plugin/host conformance checks. This executes plugin code; run only on trusted local sources or CI artifacts.\n\nFlags: --artifact --mode --engine-version --format --output --timeout\n\nOptions:\n") + fmt.Fprintf(w, "Usage: wfctl plugin conformance [options] \n wfctl plugin conformance --artifact [options]\n\nRun executable plugin/host conformance checks. This executes plugin code; run only on trusted local sources or CI artifacts.\n\nFlags: --artifact --build-package --mode --engine-version --format --output --timeout\n\nOptions:\n") fs.PrintDefaults() } @@ -113,10 +129,45 @@ func resolveConformanceEngineVersion() string { return "v0.0.0" } +func normalizeConformanceBuildPackage(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", fmt.Errorf("--build-package must not be empty") + } + if strings.HasPrefix(value, "-") { + return "", fmt.Errorf("--build-package must be a package path, not a go build flag") + } + if filepath.IsAbs(value) { + return "", fmt.Errorf("--build-package must stay inside the plugin directory") + } + if strings.Contains(value, "...") { + return "", fmt.Errorf("--build-package must name one package, not a package pattern") + } + if strings.Contains(value, "\\") { + return "", fmt.Errorf("--build-package must use slash-separated Go package paths") + } + if value == "." { + return ".", nil + } + if !strings.HasPrefix(value, "./") { + return "", fmt.Errorf("--build-package must be . or a ./ path inside the plugin directory") + } + rel := strings.TrimPrefix(value, "./") + if rel == "" { + return "", fmt.Errorf("--build-package must name a package") + } + clean := filepath.Clean(rel) + if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("--build-package must stay inside the plugin directory") + } + return "./" + filepath.ToSlash(clean), nil +} + type pluginConformanceOptions struct { Mode string SourceDir string ArtifactPath string + BuildPackage string EngineVersion string Timeout time.Duration } @@ -175,12 +226,16 @@ func runPluginConformanceCheck(opts pluginConformanceOptions) (PluginCompatibili return PluginCompatibilityEvidence{}, err } binaryPath := filepath.Join(installDir, installName) - if info, statErr := os.Stat(filepath.Join(sourceDir, installName)); statErr == nil && !info.IsDir() && info.Mode()&0o111 != 0 { + if info, statErr := os.Stat(filepath.Join(sourceDir, installName)); opts.ArtifactPath != "" && statErr == nil && !info.IsDir() && info.Mode()&0o111 != 0 { if err := copyFile(filepath.Join(sourceDir, installName), binaryPath, info.Mode()); err != nil { return PluginCompatibilityEvidence{}, err } } else { - cmd := exec.Command("go", "build", "-mod=mod", "-o", binaryPath, ".") //nolint:gosec // command args are fixed; dir is staged source. + buildPackage := opts.BuildPackage + if buildPackage == "" { + buildPackage = "." + } + cmd := exec.Command("go", "build", "-mod=mod", "-o", binaryPath, buildPackage) //nolint:gosec // command args are fixed; dir is staged source. cmd.Dir = sourceDir cmd.Env = append(os.Environ(), "GOWORK=off") out, err := cmd.CombinedOutput() diff --git a/cmd/wfctl/plugin_conformance_test.go b/cmd/wfctl/plugin_conformance_test.go index c1138a6b..72e4fecc 100644 --- a/cmd/wfctl/plugin_conformance_test.go +++ b/cmd/wfctl/plugin_conformance_test.go @@ -10,6 +10,7 @@ import ( "io" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -30,7 +31,7 @@ func TestPluginConformanceHelpListsFlags(t *testing.T) { if !errors.Is(err, flag.ErrHelp) { t.Fatalf("runPluginConformance --help error = %v, want flag.ErrHelp", err) } - for _, want := range []string{"--artifact", "--mode", "--engine-version", "--timeout", "executes plugin code"} { + for _, want := range []string{"--artifact", "--build-package", "--mode", "--engine-version", "--timeout", "executes plugin code"} { if !strings.Contains(output, want) { t.Fatalf("help output missing %q:\n%s", want, output) } @@ -90,6 +91,142 @@ func TestPluginConformanceLocalJSONPass(t *testing.T) { } } +func TestPluginConformanceBuildsRequestedPackage(t *testing.T) { + fixture := prepareIACPassFixture(t) + cmdDir := filepath.Join(fixture, "cmd", "plugin") + if err := os.MkdirAll(cmdDir, 0o750); err != nil { + t.Fatalf("mkdir cmd/plugin: %v", err) + } + if err := os.Rename(filepath.Join(fixture, "main.go"), filepath.Join(cmdDir, "main.go")); err != nil { + t.Fatalf("move main.go: %v", err) + } + out := filepath.Join(t.TempDir(), "evidence.json") + if err := runPluginConformance([]string{ + "--mode", "typed-iac", + "--engine-version", "v0.51.2", + "--build-package", "./cmd/plugin", + "--format", "json", + "--output", out, + fixture, + }); err != nil { + t.Fatalf("runPluginConformance with build package: %v", err) + } + ev := readEvidence(t, out) + if ev.Status != PluginCompatibilityStatusPass { + t.Fatalf("status = %q, want pass", ev.Status) + } +} + +func TestPluginConformanceSourceBuildPackageIgnoresRootBinary(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell executable fixture is Unix-specific") + } + fixture := prepareIACPassFixture(t) + cmdDir := filepath.Join(fixture, "cmd", "plugin") + if err := os.MkdirAll(cmdDir, 0o750); err != nil { + t.Fatalf("mkdir cmd/plugin: %v", err) + } + if err := os.Rename(filepath.Join(fixture, "main.go"), filepath.Join(cmdDir, "main.go")); err != nil { + t.Fatalf("move main.go: %v", err) + } + staleBinary := filepath.Join(fixture, "iac-pass") + if err := os.WriteFile(staleBinary, []byte("#!/bin/sh\necho stale-root-binary >&2\nexit 9\n"), 0o755); err != nil { + t.Fatalf("write stale root binary: %v", err) + } + out := filepath.Join(t.TempDir(), "evidence.json") + if err := runPluginConformance([]string{ + "--mode", "typed-iac", + "--engine-version", "v0.51.2", + "--build-package", "./cmd/plugin", + "--format", "json", + "--output", out, + fixture, + }); err != nil { + t.Fatalf("runPluginConformance with stale root binary: %v", err) + } + ev := readEvidence(t, out) + if strings.Contains(ev.StderrTail, "stale-root-binary") { + t.Fatalf("source conformance used stale root binary: %#v", ev) + } +} + +func TestPluginConformanceRejectsUnsafeBuildPackage(t *testing.T) { + fixture := prepareIACPassFixture(t) + for _, buildPackage := range []string{ + "-toolexec=/tmp/evil", + "example.com/other/plugin", + "../other", + "./../other", + "/tmp/plugin", + "./...", + "./cmd/...", + } { + t.Run(buildPackage, func(t *testing.T) { + err := runPluginConformance([]string{ + "--mode", "typed-iac", + "--engine-version", "v0.51.2", + "--build-package", buildPackage, + fixture, + }) + if err == nil { + t.Fatal("expected unsafe build package to be rejected") + } + if !strings.Contains(err.Error(), "build-package") { + t.Fatalf("error = %v, want build-package context", err) + } + }) + } +} + +func TestPluginConformanceRejectsExplicitBuildPackageWithArtifact(t *testing.T) { + fixture := prepareIACPassFixture(t) + archive := filepath.Join(t.TempDir(), "iac-pass.tar.gz") + writeTarGzFromDir(t, fixture, archive) + err := runPluginConformance([]string{ + "--mode", "typed-iac", + "--artifact", archive, + "--build-package", ".", + }) + if err == nil { + t.Fatal("expected explicit build package with artifact to fail") + } + if !strings.Contains(err.Error(), "--build-package") { + t.Fatalf("error = %v, want --build-package context", err) + } +} + +func TestNormalizeConformanceBuildPackage(t *testing.T) { + for _, tc := range []struct { + name string + in string + want string + wantErr bool + }{ + {name: "root", in: ".", want: "."}, + {name: "trim", in: " ./cmd/plugin ", want: "./cmd/plugin"}, + {name: "clean", in: "./cmd/../plugin", want: "./plugin"}, + {name: "empty", in: "", wantErr: true}, + {name: "slash root", in: "./", wantErr: true}, + {name: "backslash", in: `.\cmd\plugin`, wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := normalizeConformanceBuildPackage(tc.in) + if tc.wantErr { + if err == nil { + t.Fatalf("normalizeConformanceBuildPackage(%q) succeeded, want error", tc.in) + } + return + } + if err != nil { + t.Fatalf("normalizeConformanceBuildPackage(%q): %v", tc.in, err) + } + if got != tc.want { + t.Fatalf("normalizeConformanceBuildPackage(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + func TestPluginConformanceDefaultEngineVersionIsStrictSemver(t *testing.T) { t.Setenv("WFCTL_ENGINE_VERSION", "") got := resolveConformanceEngineVersion() diff --git a/docs/WFCTL.md b/docs/WFCTL.md index f8a7e90b..1f47ea34 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -558,6 +558,7 @@ wfctl plugin conformance --artifact [options] |------|---------|-------------| | `--mode` | `typed-iac` | Conformance mode. Currently checks strict typed IaC plugin launch/contract compatibility | | `--artifact` | _(none)_ | Release artifact tar.gz to test instead of a local plugin directory | +| `--build-package` | `.` | Go package to build when testing a source directory, for example `./cmd/plugin` | | `--engine-version` | build version or `WFCTL_ENGINE_VERSION` | Workflow engine version recorded in evidence | | `--format` | `text` | Output format: `text` or `json` | | `--output` | _(none)_ | Write JSON evidence to a file | @@ -567,6 +568,7 @@ Local directory evidence is useful during development. Registry enforcement shou ```bash wfctl plugin conformance --mode typed-iac --format json ./workflow-plugin-digitalocean +wfctl plugin conformance --mode typed-iac --build-package ./cmd/plugin --format json ./workflow-plugin-digitalocean wfctl plugin conformance --artifact dist/workflow-plugin-digitalocean.tar.gz --engine-version v0.51.2 --output evidence.json ```